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..61fa924 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# Unreleased +- 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 +63,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 +192,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..3d2b812 100644 --- a/README.md +++ b/README.md @@ -1,590 +1,264 @@ # abogen -[![Build Status](https://github.com/denizsafak/abogen/actions/workflows/test_pip.yml/badge.svg)](https://github.com/denizsafak/abogen/actions) -[![GitHub Release](https://img.shields.io/github/v/release/denizsafak/abogen)](https://github.com/denizsafak/abogen/releases/latest) -[![Abogen PyPi Python Versions](https://img.shields.io/pypi/pyversions/abogen)](https://pypi.org/project/abogen/) -[![Operating Systems](https://img.shields.io/badge/os-windows%20%7C%20linux%20%7C%20macos%20-blue)](https://github.com/denizsafak/abogen/releases/latest) -[![PyPi Total Downloads](https://img.shields.io/pepy/dt/abogen?label=downloads%20(pypi)&color=blue)](https://pypi.org/project/abogen/) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![License: MIT](https://img.shields.io/badge/License-MIT-maroon.svg)](https://opensource.org/licenses/MIT) +Abogen is a web-first text-to-speech workstation. Drop in an EPUB, PDF, Markdown, or plain text file and Abogen will turn it into high-quality audio with perfectly synced subtitles. The new interface runs entirely inside your browser using Flask + htmx, so it behaves like a modern web app whether you launch it locally or from a container. -denizsafak%2Fabogen | Trendshift +## Highlights +- Natural-sounding speech powered by Kokoro-82M with per-job voice, speed, GPU toggle, and subtitle style controls +- Clean dashboard that tracks the status, progress, and logs of every job in real time (thanks to htmx partial updates) +- Automatic chapter detection and subtitle generation with SRT/ASS exports +- LLM-assisted text normalization with live previews and configurable prompts +- Runs well in Docker, ships a REST-style JSON API, and works across macOS, Linux, and Windows -Abogen is a powerful text-to-speech conversion tool that makes it easy to turn ePub, PDF, text, markdown, or subtitle files into high-quality audio with matching subtitles in seconds. Use it for audiobooks, voiceovers for Instagram, YouTube, TikTok, or any project that needs natural-sounding text-to-speech, using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M). +## Interfaces - +Abogen offers **two interfaces** to suit different workflows: -## Demo +| Command | Interface | Best for | +|---------|-----------|----------| +| `abogen` | PyQt6 Desktop GUI | Local desktop use on Windows/macOS/Linux | +| `abogen-web` | Flask Web UI | Docker, headless servers, remote access | -https://github.com/user-attachments/assets/094ba3df-7d66-494a-bc31-0e4b41d0b865 +Both interfaces share the same core processing engine and produce identical output. -> This demo was generated in just 5 seconds, producing ∼1 minute of audio with perfectly synced subtitles. To create a similar video, see [the demo guide](https://github.com/denizsafak/abogen/tree/main/demo). - -## `How to install?` Abogen Compatible PyPi Python Versions - -### `Windows` -Go to [espeak-ng latest release](https://github.com/espeak-ng/espeak-ng/releases/latest) download and run the *.msi file. - -#### OPTION 1: Install using script -1. [Download](https://github.com/denizsafak/abogen/archive/refs/heads/main.zip) the repository -2. Extract the ZIP file -3. Run `WINDOWS_INSTALL.bat` by double-clicking it - -This method handles everything automatically - installing all dependencies including CUDA in a self-contained environment without requiring a separate Python installation. (You still need to install [espeak-ng](https://github.com/espeak-ng/espeak-ng/releases/latest).) - -> [!NOTE] -> You don't need to install Python separately. The script will install Python automatically. - -#### OPTION 2: Install using uv -First, [install uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already. +## Quick start +Abogen supports Python 3.10–3.12. +### Install with pip ```bash -# For NVIDIA GPUs (CUDA 12.8) - Recommended -uv tool install --python 3.12 abogen[cuda] --extra-index-url https://download.pytorch.org/whl/cu128 --index-strategy unsafe-best-match - -# For NVIDIA GPUs (CUDA 12.6) - Older drivers -uv tool install --python 3.12 abogen[cuda126] --extra-index-url https://download.pytorch.org/whl/cu126 --index-strategy unsafe-best-match - -# For NVIDIA GPUs (CUDA 13.0) - Newer drivers -uv tool install --python 3.12 abogen[cuda130] --extra-index-url https://download.pytorch.org/whl/cu130 --index-strategy unsafe-best-match - -# For AMD GPUs or without GPU - If you have AMD GPU, you need to use Linux for GPU acceleration, because ROCm is not available on Windows. -uv tool install --python 3.12 abogen -``` - -
-Alternative: Install using pip (click to expand) - -```bash -# Create a virtual environment (optional) -mkdir abogen && cd abogen -python -m venv venv -venv\Scripts\activate - -# For NVIDIA GPUs: -# We need to use an older version of PyTorch (2.8.0) until this issue is fixed: https://github.com/pytorch/pytorch/issues/166628 -pip install torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128 - -# For AMD GPUs: -# Not supported yet, because ROCm is not available on Windows. Use Linux if you have AMD GPU. - -# Install abogen +python -m venv .venv +source .venv/bin/activate # On Windows use: .venv\Scripts\activate pip install abogen ``` -
- -### `Mac` - -First, [install uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already. - -```bash -# Install espeak-ng -brew install espeak-ng - -# For Silicon Mac (M1, M2 etc.) -uv tool install --python 3.13 abogen --with "kokoro @ git+https://github.com/hexgrad/kokoro.git,numpy<2" - -# For Intel Mac -uv tool install --python 3.12 abogen --with "kokoro @ git+https://github.com/hexgrad/kokoro.git,numpy<2" -``` - -
-Alternative: Install using pip (click to expand) - -```bash -# Install espeak-ng -brew install espeak-ng - -# Create a virtual environment (recommended) -mkdir abogen && cd abogen -python3 -m venv venv -source venv/bin/activate - -# Install abogen -pip3 install abogen - -# For Silicon Mac (M1, M2 etc.) -# After installing abogen, we need to install Kokoro's development version which includes MPS support. -pip3 install git+https://github.com/hexgrad/kokoro.git -``` - -
- -### `Linux` - -First, [install uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already. - -```bash -# Install espeak-ng -sudo apt install espeak-ng # Ubuntu/Debian -sudo pacman -S espeak-ng # Arch Linux -sudo dnf install espeak-ng # Fedora - -# For NVIDIA GPUs or without GPU - No need to include [cuda] in here. -uv tool install --python 3.12 abogen - -# For AMD GPUs (ROCm 6.4) -uv tool install --python 3.12 abogen[rocm] --extra-index-url https://download.pytorch.org/whl/nightly/rocm6.4 --index-strategy unsafe-best-match -``` - -
-Alternative: Install using pip (click to expand) - -```bash -# Install espeak-ng -sudo apt install espeak-ng # Ubuntu/Debian -sudo pacman -S espeak-ng # Arch Linux -sudo dnf install espeak-ng # Fedora - -# Create a virtual environment (recommended) -mkdir abogen && cd abogen -python3 -m venv venv -source venv/bin/activate - -# Install abogen -pip3 install abogen - -# For NVIDIA GPUs: -# Already supported, no need to install CUDA separately. - -# For AMD GPUs: -# After installing abogen, we need to uninstall the existing torch package -pip3 uninstall torch -pip3 install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm6.4 -``` -
- - -> See [How to fix "CUDA GPU is not available. Using CPU" warning?](#cuda-warning) - -> See [How to fix "WARNING: The script abogen-cli is installed in '/home/username/.local/bin' which is not on PATH" error in Linux?](#path-warning) - -> See [How to fix "No matching distribution found" error?](#no-matching-distribution-found) - -> See [How to fix "[WinError 1114] A dynamic link library (DLL) initialization routine failed" error?](#WinError-1114) - -> Special thanks to [@hg000125](https://github.com/hg000125) for his contribution in [#23](https://github.com/denizsafak/abogen/issues/23). AMD GPU support is possible thanks to his work. - -## `How to run?` - -You can simply run this command to start Abogen: - +### Launch the desktop app (PyQt6) ```bash abogen ``` -> [!TIP] -> If you installed Abogen using the Windows installer `(WINDOWS_INSTALL.bat)`, It should have created a shortcut in the same folder, or your desktop. You can run it from there. If you lost the shortcut, Abogen is located in `python_embedded/Scripts/abogen.exe`. You can run it from there directly. -## `How to use?` -1) Drag and drop any ePub, PDF, text, markdown, or subtitle file (or use the built-in text editor) -2) Configure the settings: - - Set speech speed - - Select a voice (or create a custom voice using voice mixer) - - Select subtitle generation style (by sentence, word, etc.) - - Select output format - - Select where to save the output -3) Hit Start - -## `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. - -## `Configuration` - -| Options | Description | -|---------|-------------| -| **Input Box** | Drag and drop `ePub`, `PDF`, `.TXT`, `.MD`, `.SRT`, `.ASS` or `.VTT` files (or use built-in text editor) | -| **Queue options** | Add multiple files to a queue and process them in batch, with individual settings for each file. See [Queue mode](#queue-mode) for more details. | -| **Speed** | Adjust speech rate from `0.1x` to `2.0x` | -| **Select Voice** | First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female. | -| **Voice mixer** | Create custom voices by mixing different voice models with a profile system. See [Voice Mixer](#voice-mixer) for more details. | -| **Voice preview** | Listen to the selected voice before processing. | -| **Generate subtitles** | `Disabled`, `Line`, `Sentence`, `Sentence + Comma`, `Sentence + Highlighting`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry) | -| **Output voice format** | `.WAV`, `.FLAC`, `.MP3`, `.OPUS (best compression)` and `M4B (with chapters)` | -| **Output subtitle format** | Configures the subtitle format as `SRT (standard)`, `ASS (wide)`, `ASS (narrow)`, `ASS (centered wide)`, or `ASS (centered narrow)`. | -| **Replace single newlines with spaces** | Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks. | -| **Save location** | `Save next to input file`, `Save to desktop`, or `Choose output folder` | - -> Special thanks to [@brianxiadong](https://github.com/brianxiadong) for adding markdown support in PR [#75](https://github.com/denizsafak/abogen/pull/75) - -> Special thanks to [@jborza](https://github.com/jborza) for chapter support in PR [#10](https://github.com/denizsafak/abogen/pull/10) - -> Special thanks to [@mleg](https://github.com/mleg) for adding `Line` option in subtitle generation in PR [#94](https://github.com/denizsafak/abogen/pull/94) - -| Book handler options | Description | -|---------|-------------| -| **Chapter Control** | Select specific `chapters` from ePUBs or markdown files or `chapters + pages` from PDFs. | -| **Save each chapter separately** | Save each chapter in e-books as a separate audio file. | -| **Create a merged version** | Create a single audio file that combines all chapters. (If `Save each chapter separately` is disabled, this option will be the default behavior.) | -| **Save in a project folder with metadata** | Save the converted items in a project folder with available metadata files. | - -| Menu options | Description | -|---------|-------------| -| **Theme** | Change the application's theme using `System`, `Light`, or `Dark` options. | -| **Configure max words per subtitle** | Configures the maximum number of words per subtitle entry. | -| **Configure silence between chapters** | Configures the duration of silence between chapters (in seconds). | -| **Configure max lines in log window** | Configures the maximum number of lines to display in the log window. | -| **Separate chapters audio format** | Configures the audio format for separate chapters as `wav`, `flac`, `mp3`, or `opus`. | -| **Create desktop shortcut** | Creates a shortcut on your desktop for easy access. | -| **Open config directory** | Opens the directory where the configuration file is stored. | -| **Open cache directory** | Opens the cache directory where converted text files are stored. | -| **Clear cache files** | Deletes cache files created during the conversion or preview. | -| **Use silent gaps between subtitles** | Prevents unnecessary audio speed-up by letting speech continue into the silent gaps between subtitle etries. In short, it ignores the end times in subtitle entries and uses the silent space until the beginning of the next subtitle entry. When disabled, it speeds up the audio to fit the exact time interval specified in the subtitle. (for subtitle files). | -| **Subtitle speed adjustment method** | Choose how to speed up audio when needed: `TTS Regeneration (better quality)` re-generates the audio at a faster speed, while `FFmpeg Time-stretch (better speed)` quickly speeds up the generated audio. (for subtitle files). | -| **Use spaCy for sentence segmentation** | When this option is enabled, Abogen uses [spaCy](https://spacy.io/) to detect sentence boundaries more accurately, instead of using punctuation marks (like periods, question marks, etc.) to split sentences, which could incorrectly cut off phrases like "Mr." or "Dr.". With spaCy, sentences are divided more accurately. For non-English text, spaCy runs **before** audio generation to create sentence chunks. For English text, spaCy runs **during** subtitle generation to improve timing and readability. spaCy is only used when subtitle mode is `Sentence` or `Sentence + Comma`. If you prefer the old punctuation splitting method, you can turn this option off. | -| **Pre-download models and voices for offline use** | Opens a window that displays the available models and voices. Click `Download all` button to download all required models and voices, allowing you to use Abogen completely offline without any internet connection. | -| **Disable Kokoro's internet access** | Prevents Kokoro from downloading models or voices from HuggingFace Hub, useful for offline use. | -| **Check for updates at startup** | Automatically checks for updates when the program starts. | -| **Reset to default settings** | Resets all settings to their default values. | - -> Special thanks to [@robmckinnon](https://github.com/robmckinnon) for adding Sentence + Highlighting feature in PR [#65](https://github.com/denizsafak/abogen/pull/65) - -## `Voice Mixer` - - -With voice mixer, you can create custom voices by mixing different voice models. You can adjust the weight of each voice and save your custom voice as a profile for future use. The voice mixer allows you to create unique and personalized voices. - -> Special thanks to [@jborza](https://github.com/jborza) for making this possible through his contributions in [#5](https://github.com/denizsafak/abogen/pull/5) - -## `Queue Mode` - - -Abogen supports **queue mode**, allowing you to add multiple files to a processing queue. This is useful if you want to convert several files in one batch. - -- You can add text files (`.txt`) and subtitle files (`.srt`, `.ass`, `.vtt`) directly using the **Add files** button in the Queue Manager or by dragging and dropping them into the queue list. To add PDF, EPUB, or markdown files, use the input box in the main window and click the **Add to Queue** button. -- Each file in the queue keeps the configuration settings that were active when it was added. Changing the main window configuration afterward does **not** affect files already in the queue. -- You can enable the **Override item settings with current selection** option to force all items in the queue to use the configuration currently selected in the main window, overriding their saved settings. -- You can view each file's configuration by hovering over them. - -Abogen will process each item in the queue automatically, saving outputs as configured. - -> Special thanks to [@jborza](https://github.com/jborza) for adding queue mode in PR [#35](https://github.com/denizsafak/abogen/pull/35) - -## `About Chapter Markers` -When you process ePUB, PDF or markdown files, Abogen converts them into text files stored in your cache directory. When you click "Edit," you're actually modifying these converted text files. In these text files, you'll notice tags that look like this: - -``` -<> -``` -These are chapter markers. They are automatically added when you process ePUB, PDF or markdown files, based on the chapters you select. They serve an important purpose: -- Allow you to split the text into separate audio files for each chapter -- Save time by letting you reprocess only specific chapters if errors occur, rather than the entire file - -You can manually add these markers to plain text files for the same benefits. Simply include them in your text like this: - -``` -<> -This is the beginning of my text... - -<> -Here's another part... -``` -When you process the text file, Abogen will detect these markers automatically and ask if you want to save each chapter separately and create a merged version. - -![Abogen Chapter Marker](https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/chapter_marker.png) - -## `About Metadata Tags` -Similar to chapter markers, it is possible to add metadata tags for `M4B` files. This is useful for audiobook players that support metadata, allowing you to add information like title, author, year, etc. Abogen automatically adds these tags when you process ePUB, PDF or markdown files, but you can also add them manually to your text files. Add metadata tags **at the beginning of your text file** like this: -``` -<> -<> -<> -<> -<> -<> -<> -<> -``` -> Note: `METADATA_COVER_PATH` is used to embed a cover image into the generated M4B file. Abogen automatically extracts the cover from EPUB and PDF files and adds this tag for you. - -## `About Timestamp-based Text Files` -Similar to converting subtitle files to audio, Abogen can automatically detect text files that contain timestamps in `HH:MM:SS`, `HH:MM:SS,ms` or `HH:MM:SS.ms` format. When timestamps are found inside your text file, Abogen will ask if you want to use them for audio timing. This is useful for creating timed narrations, scripts, or transcripts where you need exact control over when each segment is spoken. - -Format your text file like this: -``` -00:00:00 -This is the first segment of text. - -00:00:15 -This is the second segment, starting at 15 seconds. - -00:00:45 -And this is the third segment, starting at 45 seconds. +### Launch the web app (Flask) +```bash +abogen-web ``` -**Important notes:** -- Timestamps must be in `HH:MM:SS`, `HH:MM:SS,ms` or `HH:MM:SS.ms` format (e.g., `00:05:30` for 5 minutes 30 seconds, or `00:05:30.500` for 5 minutes 30.5 seconds) -- Milliseconds are optional and provide precision up to 1/1000th of a second -- Text before the first timestamp (if any) will automatically start at `00:00:00` -- When using timestamps, the subtitle generation mode setting is ignored +Then open http://localhost:8808 and drag in your documents. Jobs run in the background worker and the browser updates automatically. -## `Supported Languages` -``` -# 🇺🇸 'a' => American English, 🇬🇧 'b' => British English -# 🇪🇸 'e' => Spanish es -# 🇫🇷 'f' => French fr-fr -# 🇮🇳 'h' => Hindi hi -# 🇮🇹 'i' => Italian it -# 🇯🇵 'j' => Japanese: pip install misaki[ja] -# 🇧🇷 'p' => Brazilian Portuguese pt-br -# 🇨🇳 'z' => Mandarin Chinese: pip install misaki[zh] -``` -For a complete list of supported languages and voices, refer to Kokoro's [VOICES.md](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md). To listen to sample audio outputs, see [SAMPLES.md](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/SAMPLES.md). +> **Tip:** Keep the terminal open while the server is running. Use `Ctrl+C` to stop it. -> See [How to fix Japanese audio not working?](#japanese-audio-not-working) - -## `MPV Config` -I highly recommend using [MPV](https://mpv.io/installation/) to play your audio files, as it supports displaying subtitles even without a video track. Here's my `mpv.conf`: -``` -# --- MPV Settings --- -save-position-on-quit -keep-open=yes -audio-display=no -# --- Subtitle --- -sub-ass-override=no -sub-margin-y=50 -sub-margin-x=50 -# --- Audio Quality --- -audio-spdif=ac3,dts,eac3,truehd,dts-hd -audio-channels=auto -audio-samplerate=48000 -volume-max=200 -``` - -## `Docker Guide` -If you want to run Abogen in a Docker container: -1) [Download the repository](https://github.com/denizsafak/abogen/archive/refs/heads/main.zip) and extract, or clone it using git. -2) Go to `abogen` folder. You should see `Dockerfile` there. -3) Open your terminal in that directory and run the following commands: +## Container image +A lightweight Dockerfile lives in `abogen/Dockerfile`. ```bash -# Build the Docker image: -docker build --progress plain -t abogen . - -# Note that building the image may take a while. -# After building is complete, run the Docker container: - -# Windows -docker run --name abogen -v %cd%:/shared -p 5800:5800 -p 5900:5900 --gpus all abogen - -# Linux -docker run --name abogen -v $(pwd):/shared -p 5800:5800 -p 5900:5900 --gpus all abogen - -# MacOS -docker run --name abogen -v $(pwd):/shared -p 5800:5800 -p 5900:5900 abogen - -# We expose port 5800 for use by a web browser, 5900 if you want to connect with a VNC client. +docker build -t abogen . +mkdir -p ~/abogen-data/uploads ~/abogen-data/outputs +docker run --rm \ + -p 8808:8808 \ + -v ~/abogen-data:/data \ + --name abogen \ + abogen ``` -Abogen launches automatically inside the container. -- You can access it via a web browser at [http://localhost:5800](http://localhost:5800) or connect to it using a VNC client at `localhost:5900`. -- You can use `/shared` directory to share files between your host and the container. -- For later use, start it with `docker start abogen` and stop it with `docker stop abogen`. -- Pass in `-e WEB_AUDIO="1"` for `docker run` to enable audio. +Browse to http://localhost:8808. Uploaded source files are stored in `/data/uploads` and rendered audio/subtitles appear in `/data/outputs`. -Known issues: -- Audio preview is not working inside container (ALSA error) if using a VNC client. -- `Open cache directory` and `Open configuration directory` options in settings not working. (Tried pcmanfm, did not work with Abogen). +### Container environment variables +| Variable | Default | Purpose | +|----------|---------|---------| +| `ABOGEN_HOST` | `0.0.0.0` | Bind address for the Flask server | +| `ABOGEN_PORT` | `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 | -> Special thanks to [@geo38](https://www.reddit.com/user/geo38/) from Reddit, who provided the Dockerfile and instructions in [this comment](https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/). +Set any of these with `-e VAR=value` when starting the container. -## `🌐 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)** -- [autiobooks](https://github.com/plusuncold/autiobooks): Automatically convert epubs to audiobooks -- [pdf-narrator](https://github.com/mateogon/pdf-narrator): Convert your PDFs and EPUBs into audiobooks effortlessly. -- [epub_to_audiobook](https://github.com/p0n1/epub_to_audiobook): EPUB to audiobook converter, optimized for Audiobookshelf -- [ebook2audiobook](https://github.com/DrewThomasson/ebook2audiobook): Convert ebooks to audiobooks with chapters and metadata using dynamic AI models and voice cloning - -## `Roadmap` -- [ ] Add OCR scan feature for PDF files using docling/teserract. -- [x] Add chapter metadata for .m4a files. (Issue [#9](https://github.com/denizsafak/abogen/issues/9), PR [#10](https://github.com/denizsafak/abogen/pull/10)) -- [ ] Add support for different languages in GUI. -- [x] Add voice formula feature that enables mixing different voice models. (Issue [#1](https://github.com/denizsafak/abogen/issues/1), PR [#5](https://github.com/denizsafak/abogen/pull/5)) -- [ ] Add support for kokoro-onnx (If it's necessary). -- [x] Add dark mode. - -## `Troubleshooting` -If you encounter any issues while running Abogen, try launching it from the command line with: -``` -abogen-cli -``` - -If you installed using the Windows installer `(WINDOWS_INSTALL.bat)`, go to `python_embedded/Scripts` and run: -``` -abogen-cli.exe -``` - -This will start Abogen in command-line mode and display detailed error messages. Please open a new issue on the [Issues](https://github.com/denizsafak/abogen/issues) page with the error message and a description of your problem. - -## `Common Issues & Solutions` - -
-About the name "abogen" - - -> The name **"abogen"** comes from a shortened form of **"audiobook generator"**, which is the purpose of this project. -> -> After releasing the project, I learned from [community feedback](https://news.ycombinator.com/item?id=44853064#44857237) that the prefix *"abo"* can unfortunately be understood as an ethnic slur in certain regions (particularly Australia and New Zealand). This was something I was not aware of when naming the project, as English is not my first language. -> -> I want to make it clear that the name was chosen only for its technical meaning, with **no offensive intent**. I’m grateful to those who kindly pointed this out, as it helps ensure the project remains respectful and welcoming to everyone. - -
- -
-How to fix "CUDA GPU is not available. Using CPU" warning? - - -> This message means PyTorch could not use your GPU and has fallen back to the CPU. On Windows, Abogen only supports NVIDIA GPUs with CUDA. AMD GPUs are not supported on Windows (they are only supported on Linux with ROCm). Abogen will still work on the CPU, but processing will be slower compared to a supported GPU. -> -> If you have a compatible NVIDIA GPU on Windows and still see this warning: -> Open your terminal in the Abogen folder (the folder that contains `python_embedded`) and type: -> ```bash -> python_embedded\python.exe -m pip install --force-reinstall torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128 -> ``` -> -> If this does not resolve the issue and you are using an older NVIDIA GPU that does not support CUDA 12.8, you can try installing an older version of PyTorch that supports your GPU. For example, for CUDA 12.6, run: -> ```bash -> python_embedded\python.exe -m pip install --force-reinstall torch==2.8.0+cu126 torchvision==0.23.0+cu126 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu126 -> ``` -> -> If you have an AMD GPU, you need to use Linux and follow the Linux/ROCm [instructions](#linux). If you want to keep running on CPU, no action is required, but performance will just be reduced. See [#32](https://github.com/denizsafak/abogen/issues/32) for more details. -> -> If you used `uv` to install Abogen, you can uninstall and try reinstalling with another CUDA version: -> ```bash -> # First uninstall Abogen -> uv tool uninstall abogen -> # Try CUDA 12.6 for older drivers -> uv tool install --python 3.12 abogen[cuda126] --extra-index-url https://download.pytorch.org/whl/cu126 --index-strategy unsafe-best-match -> # If that doesn't work, try CUDA 13.0 for newer drivers -> uv tool install --python 3.12 abogen[cuda130] --extra-index-url https://download.pytorch.org/whl/cu130 --index-strategy unsafe-best-match -> ``` - -
- -
-How to fix "WARNING: The script abogen-cli is installed in '/home/username/.local/bin' which is not on PATH" error in Linux? - - -> Run the following command to add Abogen to your PATH: -> ```bash -> echo "export PATH=\"/home/$USER/.local/bin:\$PATH\"" >> ~/.bashrc && source ~/.bashrc -> ``` - -
- -
-How to fix "No matching distribution found" error? - - -> Try installing Abogen on supported Python (3.10 to 3.12) versions. I recommend installing with [uv](https://docs.astral.sh/uv/getting-started/installation/). You can also use [pyenv](https://github.com/pyenv/pyenv) to manage multiple Python versions easily on Linux. Watch this [video](https://www.youtube.com/watch?v=MVyb-nI4KyI) by NetworkChuck for a quick guide. - -
- -
-How to fix "[WinError 1114] A dynamic link library (DLL) initialization routine failed" error? - - -> I faced this error when trying to run Abogen in a virtual Windows machine without GPU support. Here's how I fixed it: -> If you installed Abogen using the Windows installer `(WINDOWS_INSTALL.bat)`, go to Abogen's folder (that contains `python_embedded`), open your terminal there and run: -> ```bash -> python_embedded\python.exe -m pip install --force-reinstall torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128 -> ``` -> If you installed Abogen using pip, open your terminal in the virtual environment and run: -> ```bash -> pip install torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0 --index-url https://download.pytorch.org/whl/cu128 -> ``` - -
- -
-How to fix Japanese audio not working? - - -> Japanese audio may require additional configuration. -> I'm not sure about the exact solution, but it seems to be related to installing additional dependencies for Japanese support in Kokoro. Please check [#56](https://github.com/denizsafak/abogen/issues/56) for more information. - -
- -
-How to uninstall Abogen? - - -> - From the settings menu, go to `Open configuration directory` and delete the directory. -> - From the settings menu, go to `Open cache directory` and delete the directory. -> - If you installed Abogen using pip, type: ->```bash ->pip uninstall abogen # uninstalls abogen ->pip cache purge # removes pip cache ->``` ->- If you installed Abogen using uv, type: ->```bash ->uv tool uninstall abogen # uninstalls abogen ->uv cache clear # removes uv cache ->``` -> - If you installed Abogen using the Windows installer (WINDOWS_INSTALL.bat), just remove the folder that contains Abogen. It installs everything inside `python_embedded` folder, no other directories are created. -> - If you installed espeak-ng, you need to remove it separately. - -
- -## `Contributing` -I welcome contributions! If you have ideas for new features, improvements, or bug fixes, please fork the repository and submit a pull request. - -### For developers and contributors -If you'd like to modify the code and contribute to development, you can [download the repository](https://github.com/denizsafak/abogen/archive/refs/heads/main.zip), extract it and run the following commands to build **or** install the package: -```bash -# Go to the directory where you extracted the repository and run: -pip install -e .[dev] # Installs the package in editable mode with build dependencies -python -m build # Builds the package in dist folder (optional) -abogen # Opens the GUI -``` -> Make sure you are using Python 3.10 to 3.12. You need to create a virtual environment if needed. - -
-Alternative: Using uv (click to expand) +To discover your local UID/GID for matching file permissions inside the container, run: ```bash -# Go to the directory where you extracted the repository and run: -uv venv --python 3.12 # Creates a virtual environment with Python 3.12 -# After activating the virtual environment, run: -uv pip install -e . # Installs the package in editable mode -uv build # Builds the package in dist folder (optional) -abogen # Opens the GUI +id -u +id -g ``` -
+Use those values to populate `ABOGEN_UID` / `ABOGEN_GID` in your `.env` file. -Feel free to explore the code and make any changes you like. +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. -## `Credits` -- Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible. -- Thanks to 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. -- Thanks to creators of [EbookLib](https://github.com/aerkalov/ebooklib), a Python library for reading and writing ePub files, which is used for extracting text from ePub files. -- Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface. -- Icons: [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male), [Adjust](https://icons8.com/icon/21698/adjust) and [Voice Id](https://icons8.com/icon/GskSeVoroQ7u/voice-id) icons by [Icons8](https://icons8.com/). +### 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: -## `License` -This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details. -[Kokoro](https://github.com/hexgrad/kokoro) is licensed under [Apache-2.0](https://github.com/hexgrad/kokoro/blob/main/LICENSE) which allows commercial use, modification, distribution, and private use. +```bash +docker compose up -d --build +``` -## `Star History` -[![Star History Chart](https://api.star-history.com/svg?repos=denizsafak/abogen&type=Date)](https://www.star-history.com/#denizsafak/abogen&Date) +Key build/runtime knobs: -> [!NOTE] -> Abogen supports subtitle generation for all languages. However, word-level subtitle modes (e.g., "1 word", "2 words", "3 words", etc.) are only available for English because [Kokoro provides timestamp tokens only for English text](https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py#L383). For non-English languages, Abogen uses a duration-based fallback that supports sentence-level and comma-based subtitle modes ("Line", "Sentence", "Sentence + Comma"). If you need word-level subtitles for other languages, please request that feature in the [Kokoro project](https://github.com/hexgrad/kokoro). +- `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`). -> Tags: audiobook, kokoro, text-to-speech, TTS, audiobook generator, audiobooks, text to speech, audiobook maker, audiobook creator, audiobook generator, voice-synthesis, text to audio, text to audio converter, text to speech converter, text to speech generator, text to speech software, text to speech app, epub to audio, pdf to audio, markdown to audio, subtitle to audio, srt to audio, ass to audio, vtt to audio, webvtt to audio, content-creation, media-generation +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 +``` + +## GPU acceleration +Abogen detects CUDA automatically. To use an NVIDIA GPU, install the matching PyTorch build before installing Abogen: +```bash +pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128 +pip install abogen +``` + +On Linux with AMD GPUs, install PyTorch/ROCm nightly wheels: +```bash +pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm6.4 +``` +Abogen falls back to CPU rendering if no GPU is available. + +## Using the web UI +1. Upload a document (drag & drop or use the upload button). +2. Choose voice, language, speed, subtitle style, and output format. +3. Click **Create job**. The job immediately appears in the queue. +4. Watch progress and logs update live. Download audio/subtitle assets when complete. +5. Cancel or delete jobs any time. Download logs for troubleshooting. + +Multiple jobs can run sequentially; the worker processes them in order. + +## 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. + +## 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. + +## 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. + +## Configuration reference +Most behaviour is controlled through the UI, but a few environment variables are helpful for automation: +- `ABOGEN_SECRET_KEY` – provide your own random secret when deploying across multiple replicas. +- `ABOGEN_DEBUG` – set to `true` for verbose Flask error output. +- `ABOGEN_SETTINGS_DIR` – change where Abogen stores its JSON settings/configuration files. +- `ABOGEN_TEMP_DIR` – change where temporary uploads and cache files are stored. +- `ABOGEN_OUTPUT_DIR` – change where rendered audio/subtitles are written. +- `ABOGEN_LLM_*` – seed the Settings → LLM panel with defaults for base URL, API key, model, timeout, prompt, and context mode. + +If unset, Abogen picks sensible defaults suitable for local usage. + +You can also create a `.env` file in the project root (see `.env.example`) to configure these paths when running locally. The application loads `.env` automatically on startup. + +## Development workflow +```bash +git clone https://github.com/denizsafak/abogen.git +cd abogen +python -m venv .venv +source .venv/bin/activate +pip install -e . +pip install pytest +``` + +Run the server in development mode: +```bash +export ABOGEN_DEBUG=true +abogen +``` + +Static files live in `abogen/web/static`, templates in `abogen/web/templates`, and the conversion pipeline in `abogen/web/conversion_runner.py`. + +## Tests +```bash +python -m pytest +``` + +Unit tests cover the queue service, web routes, and conversion pipeline helpers. Contributions that add features should include new tests whenever practical. + +## Upgrading from the desktop GUI +The legacy PyQt5 interface is no longer packaged. Existing scripts that call `abogen.main` should switch to the new web entry point (`abogen.webui.app:main`). The new experience works headlessly, plays nicely in Docker, and exposes JSON APIs for automation. + +## Troubleshooting +- Conversion jobs stay pending → ensure the background worker has write access to the upload/output directories. +- GPU not detected → verify the correct PyTorch wheel is installed (`pip show torch`) and drivers match the container/host. +- Subtitle files missing → check the job configuration; subtitles are optional and can be disabled per job. +- Logs are empty → run with `ABOGEN_DEBUG=true` to get verbose Flask error output in the server console. + +If you hit a bug, open an issue describing the input file and the exact log output. + +## Contributing +Pull requests are welcome! Please: +- Keep changes focused and well-tested +- Run `python -m pytest` +- Update documentation when behaviour changes + +Thanks for helping make Abogen a great open-source audiobook generator. diff --git a/abogen/Dockerfile b/abogen/Dockerfile 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/book_handler.py b/abogen/book_handler.py index 4fa63ba..bf565fd 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD import re import base64 from bs4 import BeautifulSoup, NavigableString @@ -13,126 +14,17 @@ from PyQt6.QtWidgets import ( QDialogButtonBox, QSplitter, QWidget, - QCheckBox, - QTreeWidgetItemIterator, - QLabel, - QMenu, -) -from PyQt6.QtCore import ( - Qt, - QThread, - pyqtSignal, - QSize, -) -from abogen.utils import ( - detect_encoding, - get_resource_path, -) -from abogen.book_parser import get_book_parser + """Backwards-compatible re-export of the PyQt book handler. -from abogen.subtitle_utils import ( - clean_text, - calculate_text_length, -) + The actual implementation lives in abogen.pyqt.book_handler. + """ -import os -import logging -import urllib.parse -import textwrap + from __future__ import annotations -# Setup logging -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" -) + from abogen.pyqt.book_handler import * # noqa: F401, F403 + from abogen.pyqt.book_handler import HandlerDialog -_HTML_TAG_PATTERN = re.compile(r"<[^>]+>") -_LEADING_DASH_PATTERN = re.compile(r"^\s*[-–—]\s*") -_LEADING_SIMPLE_DASH_PATTERN = re.compile(r"^\s*-\s*") - - -class HandlerDialog(QDialog): - # Class variables to remember checkbox states between dialog instances - _save_chapters_separately = False - _merge_chapters_at_end = True - _save_as_project = False # New class variable for save_as_project option - - # Cache for processed book content to avoid reprocessing - # Key: (book_path, modification_time, file_type) - # Value: dict with content_texts, content_lengths, doc_content (for epub), markdown_toc (for markdown) - _content_cache = {} - - class _LoaderThread(QThread): - """Minimal QThread that runs a callable and emits an error string on exception.""" - - error = pyqtSignal(str) - - def __init__(self, target_callable): - super().__init__() - self._target = target_callable - - def run(self): - try: - self._target() - except Exception as e: - self.error.emit(str(e)) - - @classmethod - def clear_content_cache(cls, book_path=None): - """Clear the content cache. If book_path is provided, only clear that book's cache.""" - if book_path is None: - cls._content_cache.clear() - logging.info("Cleared all content cache") - else: - keys_to_remove = [ - key for key in cls._content_cache.keys() if key[0] == book_path - ] - for key in keys_to_remove: - del cls._content_cache[key] - if keys_to_remove: - logging.info(f"Cleared content cache for {os.path.basename(book_path)}") - - def __init__(self, book_path, file_type=None, checked_chapters=None, parent=None): - super().__init__(parent) - - # Normalize path - book_path = os.path.normpath(os.path.abspath(book_path)) - self.book_path = book_path - - # Initialize Parser - try: - # Factory handles file type detection if file_type is None - self.parser = get_book_parser(book_path, file_type=file_type) - # Parser loads automatically in init now - except Exception as e: - logging.error(f"Failed to initialize parser for {book_path}: {e}") - raise - - # Extract book name from file path - book_name = os.path.splitext(os.path.basename(book_path))[0] - - # Set window title based on file type and book name - item_type = "Chapters" if self.parser.file_type in ["epub", "markdown"] else "Pages" - self.setWindowTitle(f"Select {item_type} - {book_name}") - self.resize(1200, 900) - self._block_signals = False # Flag to prevent recursive signals - # Configure window: remove help button and allow resizing - self.setWindowFlags( - Qt.WindowType.Window - | Qt.WindowType.WindowCloseButtonHint - | Qt.WindowType.WindowMaximizeButtonHint - ) - self.setWindowModality(Qt.WindowModality.NonModal) - # Initialize save chapters flags from class variables - self.save_chapters_separately = HandlerDialog._save_chapters_separately - self.merge_chapters_at_end = HandlerDialog._merge_chapters_at_end - self.save_as_project = HandlerDialog._save_as_project - - # Initialize metadata dict; will be populated in _preprocess_content by the background loader - self.book_metadata = {} - - # Initialize UI elements that are used in other methods - self.save_chapters_checkbox = None - self.merge_chapters_checkbox = None + __all__ = ["HandlerDialog"] # Build treeview self.treeWidget = QTreeWidget(self) @@ -1652,3 +1544,6 @@ class HandlerDialog(QDialog): menu.exec(self.treeWidget.mapToGlobal(pos)) +======= +__all__ = ["HandlerDialog"] +>>>>>>> main diff --git a/abogen/chunking.py b/abogen/chunking.py new file mode 100644 index 0000000..db914ed --- /dev/null +++ b/abogen/chunking.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Iterable, Iterator, List, Literal, Optional, Tuple +from typing import Pattern + +import re + +from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline +from abogen.normalization_settings import build_apostrophe_config, get_runtime_settings + +ChunkLevel = Literal["paragraph", "sentence"] + +_SENTENCE_SPLIT_REGEX = re.compile(r"(? Dict[str, object]: + return { + "id": self.id, + "chapter_index": self.chapter_index, + "chunk_index": self.chunk_index, + "level": self.level, + "text": self.text, + "speaker_id": self.speaker_id, + "voice": self.voice, + "voice_profile": self.voice_profile, + "voice_formula": self.voice_formula, + "display_text": self.display_text, + } + + +def _iter_paragraphs(text: str) -> Iterator[str]: + for raw_segment in _PARAGRAPH_SPLIT_REGEX.split(text.strip()): + normalized = raw_segment.strip() + if normalized: + yield normalized + + +def _iter_sentences(paragraph: str) -> Iterator[Tuple[str, str]]: + if not paragraph: + return + start = 0 + for match in _SENTENCE_SPLIT_REGEX.finditer(paragraph): + end = match.end() + raw_segment = paragraph[start:end] + candidate = raw_segment.strip() + if candidate: + yield candidate, raw_segment + start = match.end() + tail_raw = paragraph[start:] + tail = tail_raw.strip() + if tail: + yield tail, tail_raw + + +def _normalize_whitespace(value: str) -> str: + return _WHITESPACE_REGEX.sub(" ", value).strip() + + +def _normalize_chunk_text(value: str) -> str: + settings = get_runtime_settings() + config = build_apostrophe_config(settings=settings, base=_PIPELINE_APOSTROPHE_CONFIG) + normalized = normalize_for_pipeline(value, config=config, settings=settings) + return _normalize_whitespace(normalized) + + +def _split_sentences(paragraph: str) -> List[Tuple[str, str]]: + sentences = list(_iter_sentences(paragraph)) + if not sentences: + return [] + + merged: List[Tuple[str, str]] = [] + buffer_norm: List[str] = [] + buffer_raw: List[str] = [] + + for normalized_sentence, raw_sentence in sentences: + if buffer_norm: + buffer_norm.append(normalized_sentence) + buffer_raw.append(raw_sentence) + else: + buffer_norm = [normalized_sentence] + buffer_raw = [raw_sentence] + + if _ABBREVIATION_END_RE.search(normalized_sentence.rstrip()): + continue + + merged.append((" ".join(buffer_norm), "".join(buffer_raw))) + buffer_norm = [] + buffer_raw = [] + + if buffer_norm: + merged.append((" ".join(buffer_norm), "".join(buffer_raw))) + + return merged + + +def chunk_text( + *, + chapter_index: int, + chapter_title: str, + text: str, + level: ChunkLevel, + speaker_id: str = "narrator", + voice: Optional[str] = None, + voice_profile: Optional[str] = None, + voice_formula: Optional[str] = None, + chunk_prefix: Optional[str] = None, +) -> List[Dict[str, object]]: + """Split text into ordered chunk dictionaries.""" + + prefix = chunk_prefix or f"chap{chapter_index:04d}" + chunks: List[Dict[str, object]] = [] + + if level == "paragraph": + paragraphs = list(_iter_paragraphs(text)) or [text.strip()] + for para_index, paragraph in enumerate(paragraphs): + normalized = _normalize_whitespace(paragraph) + if not normalized: + continue + chunk_id = f"{prefix}_p{para_index:04d}" + payload = Chunk( + id=chunk_id, + chapter_index=chapter_index, + chunk_index=len(chunks), + level=level, + text=normalized, + speaker_id=speaker_id, + voice=voice, + voice_profile=voice_profile, + voice_formula=voice_formula, + ).as_dict() + payload["normalized_text"] = _normalize_chunk_text(paragraph) + payload["original_text"] = paragraph + chunks.append(payload) + _attach_display_text(text, chunks) + return chunks + + # Sentence level – flatten paragraphs into individual sentences + sentence_index = 0 + for para_index, paragraph in enumerate(list(_iter_paragraphs(text)) or [text.strip()]): + normalized_para = _normalize_whitespace(paragraph) + if not normalized_para: + continue + sentence_pairs = _split_sentences(paragraph) or [(normalized_para, paragraph)] + for sent_local_index, (normalized_sentence, raw_sentence) in enumerate(sentence_pairs): + normalized_sentence = _normalize_whitespace(normalized_sentence) + if not normalized_sentence: + continue + chunk_id = f"{prefix}_p{para_index:04d}_s{sent_local_index:04d}" + payload = Chunk( + id=chunk_id, + chapter_index=chapter_index, + chunk_index=sentence_index, + level=level, + text=normalized_sentence, + speaker_id=speaker_id, + voice=voice, + voice_profile=voice_profile, + voice_formula=voice_formula, + ).as_dict() + payload["normalized_text"] = _normalize_chunk_text(raw_sentence) + payload["display_text"] = raw_sentence + payload["original_text"] = raw_sentence + chunks.append(payload) + sentence_index += 1 + + _attach_display_text(text, chunks) + return chunks + + +_DISPLAY_PATTERN_CACHE: Dict[str, Pattern[str]] = {} + + +def _build_display_pattern(text: str) -> Pattern[str]: + cached = _DISPLAY_PATTERN_CACHE.get(text) + if cached is not None: + return cached + escaped = re.escape(text) + escaped = escaped.replace(r"\ ", r"\s+") + pattern = re.compile(r"(\s*" + escaped + r"\s*)", re.DOTALL) + _DISPLAY_PATTERN_CACHE[text] = pattern + return pattern + + +def _search_source_span(source: str, normalized: str, start: int) -> Optional[Tuple[int, int]]: + if not normalized: + return None + pattern = _build_display_pattern(normalized) + match = pattern.search(source, start) + if not match: + return None + return match.start(1), match.end(1) + + +def _attach_display_text(source: str, chunks: List[Dict[str, object]]) -> None: + if not source or not chunks: + return + cursor = 0 + for chunk in chunks: + candidate = str(chunk.get("display_text") or chunk.get("text") or "") + if not candidate: + continue + match = _search_source_span(source, candidate, cursor) + if match is None and cursor: + match = _search_source_span(source, candidate, 0) + if match is None: + chunk.setdefault("display_text", candidate) + chunk.setdefault("original_text", chunk.get("display_text") or candidate) + continue + start, end = match + chunk["display_text"] = source[start:end] + chunk["original_text"] = source[start:end] + cursor = end + + +def build_chunks_for_chapters( + chapters: Iterable[Dict[str, object]], + *, + level: ChunkLevel, + speaker_id: str = "narrator", +) -> List[Dict[str, object]]: + """Generate chunk dictionaries for a sequence of chapter payloads.""" + all_chunks: List[Dict[str, object]] = [] + for chapter_index, entry in enumerate(chapters): + if not isinstance(entry, dict): # defensive + continue + text = str(entry.get("text", "") or "").strip() + if not text: + continue + voice = entry.get("voice") + voice_profile = entry.get("voice_profile") + voice_formula = entry.get("voice_formula") + prefix = entry.get("id") or f"chap{chapter_index:04d}" + chapter_chunks = chunk_text( + chapter_index=chapter_index, + chapter_title=str(entry.get("title") or f"Chapter {chapter_index + 1}"), + text=text, + level=level, + speaker_id=speaker_id, + voice=str(voice) if voice else None, + voice_profile=str(voice_profile) if voice_profile else None, + voice_formula=str(voice_formula) if voice_formula else None, + chunk_prefix=str(prefix), + ) + all_chunks.extend(chapter_chunks) + return all_chunks \ No newline at end of file diff --git a/abogen/conversion.py b/abogen/conversion.py index 2195b8a..f615349 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -1,2477 +1,16 @@ -import os -import re -import time -import hashlib # For generating unique cache filenames -from platformdirs import user_desktop_dir -from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer -from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox -import soundfile as sf -from abogen.utils import ( - create_process, - get_user_cache_path, - detect_encoding, -) -from abogen.constants import ( - LANGUAGE_DESCRIPTIONS, - SAMPLE_VOICE_TEXTS, - COLORS, - CHAPTER_OPTIONS_COUNTDOWN, - SUBTITLE_FORMATS, - SUPPORTED_SOUND_FORMATS, - SUPPORTED_SUBTITLE_FORMATS, -) -from abogen.voice_formulas import get_new_voice -import abogen.hf_tracker as hf_tracker -import static_ffmpeg -import threading # for efficient waiting -import subprocess -import platform +"""Backwards-compatible re-export of conversion module. -# Configuration constants -_USER_RESPONSE_TIMEOUT = ( - 0.1 # Timeout in seconds for checking user response/cancellation +The PyQt-based implementation lives in abogen.pyqt.conversion. +The web-based implementation is in abogen.webui.conversion_runner. +""" + +from __future__ import annotations + +# Re-export PyQt conversion classes for backwards compatibility +from abogen.pyqt.conversion import ( # noqa: F401 + ConversionThread, + VoicePreviewThread, + PlayAudioThread, ) -from abogen.subtitle_utils import ( - clean_text, - parse_srt_file, - parse_vtt_file, - detect_timestamps_in_text, - parse_timestamp_text_file, - parse_ass_file, - get_sample_voice_text, - sanitize_name_for_os, - _CHAPTER_MARKER_SEARCH_PATTERN, -) - -class CountdownDialog(QDialog): - """Base dialog with auto-accept countdown functionality""" - - def __init__(self, title, countdown_seconds, parent=None): - super().__init__(parent) - self.setWindowTitle(title) - self.setMinimumWidth(350) - self.setWindowFlags( - self.windowFlags() - & ~Qt.WindowType.WindowCloseButtonHint - & ~Qt.WindowType.WindowContextHelpButtonHint - ) - - self.countdown_seconds = countdown_seconds - self.layout = QVBoxLayout(self) - self._timer = None - self._button_box = None - - def add_countdown_and_buttons(self): - """Add countdown label and OK button - call this after adding custom content""" - self.countdown_label = QLabel( - f"Auto-accepting in {self.countdown_seconds} seconds..." - ) - self.countdown_label.setStyleSheet(f"color: {COLORS['GREEN']};") - self.layout.addWidget(self.countdown_label) - - self._button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) - self._button_box.accepted.connect(self.accept) - self.layout.addWidget(self._button_box) - - self._timer = QTimer(self) - self._timer.timeout.connect(self._on_timer_tick) - self._timer.start(1000) - - def _on_timer_tick(self): - self.countdown_seconds -= 1 - if self.countdown_seconds > 0: - self.countdown_label.setText( - f"Auto-accepting in {self.countdown_seconds} seconds..." - ) - else: - self._timer.stop() - self._button_box.accepted.emit() - - def closeEvent(self, event): - event.ignore() - - def keyPressEvent(self, event): - if event.key() == Qt.Key.Key_Escape: - event.ignore() - else: - super().keyPressEvent(event) - - -class ChapterOptionsDialog(CountdownDialog): - def __init__(self, chapter_count, parent=None): - super().__init__("Chapter Options", CHAPTER_OPTIONS_COUNTDOWN, parent) - - self.layout.addWidget( - QLabel(f"Detected {chapter_count} chapters in the text file.") - ) - self.layout.addWidget(QLabel("How would you like to process these chapters?")) - - self.save_separately_checkbox = QCheckBox("Save each chapter separately") - self.merge_at_end_checkbox = QCheckBox("Create a merged version at the end") - - self.save_separately_checkbox.setChecked(False) - self.merge_at_end_checkbox.setChecked(True) - - self.save_separately_checkbox.stateChanged.connect( - self.update_merge_checkbox_state - ) - - self.layout.addWidget(self.save_separately_checkbox) - self.layout.addWidget(self.merge_at_end_checkbox) - - self.add_countdown_and_buttons() - self.update_merge_checkbox_state() - - def update_merge_checkbox_state(self): - self.merge_at_end_checkbox.setEnabled(self.save_separately_checkbox.isChecked()) - - def get_options(self): - return { - "save_chapters_separately": self.save_separately_checkbox.isChecked(), - "merge_chapters_at_end": self.merge_at_end_checkbox.isChecked() - and self.merge_at_end_checkbox.isEnabled(), - } - - -class TimestampDetectionDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Timestamps Detected") - self.setMinimumWidth(350) - self.use_timestamps_result = True - self.countdown_seconds = CHAPTER_OPTIONS_COUNTDOWN - - layout = QVBoxLayout(self) - - layout.addWidget(QLabel("This file contains timestamps in HH:MM:SS format.")) - layout.addWidget( - QLabel("Do you want to use these timestamps for precise audio timing?") - ) - - yes_label = QLabel( - "• Yes: Generate audio that matches each timestamp (subtitle mode will be ignored)" - ) - yes_label.setStyleSheet(f"color: {COLORS['BLUE_BORDER_HOVER']};") - layout.addWidget(yes_label) - - no_label = QLabel("• No: Ignore timestamps and process as regular text") - no_label.setStyleSheet(f"color: {COLORS['ORANGE']};") - layout.addWidget(no_label) - - # Countdown label - self.countdown_label = QLabel( - f"Auto-accepting in {self.countdown_seconds} seconds..." - ) - self.countdown_label.setStyleSheet(f"color: {COLORS['GREEN']};") - layout.addWidget(self.countdown_label) - - button_box = QDialogButtonBox() - yes_button = button_box.addButton("Yes", QDialogButtonBox.ButtonRole.AcceptRole) - no_button = button_box.addButton("No", QDialogButtonBox.ButtonRole.RejectRole) - - yes_button.clicked.connect(lambda: self._set_result(True)) - no_button.clicked.connect(lambda: self._set_result(False)) - - layout.addWidget(button_box) - - # Timer for countdown - self._timer = QTimer(self) - self._timer.timeout.connect(self._on_timer_tick) - self._timer.start(1000) - - def _on_timer_tick(self): - self.countdown_seconds -= 1 - if self.countdown_seconds > 0: - self.countdown_label.setText( - f"Auto-accepting in {self.countdown_seconds} seconds..." - ) - else: - self._timer.stop() - self._set_result(True) - - def _set_result(self, use_timestamps): - if self._timer: - self._timer.stop() - self.use_timestamps_result = use_timestamps - self.accept() - - def use_timestamps(self): - return self.use_timestamps_result - - -class ConversionThread(QThread): - progress_updated = pyqtSignal(int, str) # Add str for ETR - conversion_finished = pyqtSignal(object, object) # Pass output path as second arg - log_updated = pyqtSignal(object) # Updated signal for log updates - chapters_detected = pyqtSignal(int) # Signal for chapter detection - - # Punctuation constants for unified handling across languages - PUNCTUATION_SENTENCE = ".!?।。!?" - PUNCTUATION_SENTENCE_COMMA = ".!?,।。!?、," - PUNCTUATION_COMMAS = ",,、" - - def _get_split_pattern(self, lang_code, subtitle_mode): - """ - Get the appropriate split pattern based on language and subtitle mode. - - Args: - lang_code: Language code (a, b, e, f, etc.) - subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.) - - Returns: - Split pattern string - """ - # For English, always use newline splitting only - if lang_code in ["a", "b"]: - return "\n" - - # Determine spacing pattern based on language - spacing_pattern = r"\s*" if lang_code in ["z", "j"] else r"\s+" - - # For Chinese/Japanese, when subtitle mode is Disabled or Line, prefer - # punctuation-based splitting instead of plain newline splitting. - if subtitle_mode in ("Disabled", "Line") and lang_code in ["z", "j"]: - return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE, spacing_pattern) - - if subtitle_mode == "Line": - return "\n" - elif subtitle_mode == "Sentence": - return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE, spacing_pattern) - elif subtitle_mode == "Sentence + Comma": - return r"(?<=[{}]){}|\n+".format( - self.PUNCTUATION_SENTENCE_COMMA, spacing_pattern - ) - else: - return r"\n+" # Default to line breaks - - def __init__( - self, - file_name, - lang_code, - speed, - voice, - save_option, - output_folder, - subtitle_mode, - output_format, - np_module, - kpipeline_class, - start_time, - total_char_count, - use_gpu=True, - from_queue=False, - save_base_path=None, - ): # Add use_gpu parameter - super().__init__() - self._chapter_options_event = threading.Event() - self._timestamp_response_event = threading.Event() - self.np = np_module - self.KPipeline = kpipeline_class - self.file_name = file_name - self.lang_code = lang_code - self.speed = speed - self.voice = voice - self.save_option = save_option - self.output_folder = output_folder - self.subtitle_mode = subtitle_mode - self.cancel_requested = False - self.should_cancel = False - self.process = None - self.output_format = output_format - self.from_queue = from_queue - self.start_time = start_time # Store start_time - self.total_char_count = total_char_count # Use passed total character count - self.processed_char_count = 0 # Initialize processed character count - self.display_path = None # Add variable for display path - self.save_base_path = save_base_path # Store the save base path - self.is_direct_text = ( - False # Flag to indicate if input is from textbox rather than file - ) - self.chapter_options_set = False - self.waiting_for_user_input = False - self.use_gpu = use_gpu # Store the GPU setting - self.max_subtitle_words = 50 # Default value, will be overridden from GUI - self.silence_duration = 2.0 # Default value, will be overridden from GUI - self.use_spacy_segmentation = True # Default, will be overridden from GUI - # Set split pattern based on language and subtitle mode - self.split_pattern = self._get_split_pattern(lang_code, subtitle_mode) - - def _stream_audio_in_chunks( - self, segments, process_func, progress_prefix="Processing" - ): - """ - Process audio segments in memory-efficient chunks - - Args: - segments: List of audio segments to process - process_func: Function that takes (segment_bytes, is_last) and processes a chunk - progress_prefix: Prefix for progress messages - - Returns: - Total samples processed - """ - # Calculate total size for progress reporting - total_samples = sum(len(segment) for segment in segments) - samples_processed = 0 - - self.log_updated.emit((f"\n{progress_prefix} segments...", "grey")) - - # Stream each segment individually - for i, segment in enumerate(segments): - try: - # Handle both NumPy arrays and PyTorch tensors - if hasattr(segment, "astype"): - segment_bytes = segment.astype("float32").tobytes() - else: - segment_bytes = segment.cpu().numpy().astype("float32").tobytes() - is_last = i == len(segments) - 1 - - # Update progress periodically - skip if there's only one segment - if (i % 20 == 0 or is_last) and len(segments) > 1: - progress_percent = int((samples_processed / total_samples) * 100) - self.log_updated.emit( - f"{progress_prefix} segment {i+1}/{len(segments)} ({progress_percent}% complete)" - ) - - # Process this segment - process_func(segment_bytes, is_last) - - # Update samples processed - samples_processed += len(segment) - - # Clear segment bytes from memory - del segment_bytes - except Exception as e: - self.log_updated.emit( - (f"Error processing segment {i}: {str(e)}", "red") - ) - raise - - return samples_processed - - def run(self): - print( - f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nFile: {self.file_name}\nSubtitle mode: {self.subtitle_mode}\nOutput format: {self.output_format}\nSave option: {self.save_option}\n" - ) - try: - hf_tracker.set_log_callback(lambda msg: self.log_updated.emit(msg)) - # Show configuration - self.log_updated.emit("Configuration:") - - # Determine input file and processing file - if getattr(self, "from_queue", False): - input_file = self.save_base_path or self.file_name - processing_file = self.file_name - else: - input_file = self.display_path if self.display_path else self.file_name - processing_file = self.file_name - - # Normalize paths for consistent display (fixes Windows path separator issues) - input_file = os.path.normpath(input_file) if input_file else input_file - processing_file = ( - os.path.normpath(processing_file) - if processing_file - else processing_file - ) - - self.log_updated.emit(f"- Input File: {input_file}") - if input_file != processing_file: - self.log_updated.emit(f"- Processing File: {processing_file}") - - # Use file_name for logs if from_queue, otherwise use display_path if available - if getattr(self, "from_queue", False): - base_path = ( - self.save_base_path or self.file_name - ) # Use save_base_path if available - else: - base_path = self.display_path if self.display_path else self.file_name - - # Use file size string passed from GUI - if hasattr(self, "file_size_str"): - self.log_updated.emit(f"- File size: {self.file_size_str}") - - self.log_updated.emit(f"- Total characters: {int(self.total_char_count):,}") - - self.log_updated.emit( - f"- Language: {self.lang_code} ({LANGUAGE_DESCRIPTIONS.get(self.lang_code, 'Unknown')})" - ) - self.log_updated.emit(f"- Voice: {self.voice}") - self.log_updated.emit(f"- Speed: {self.speed}") - self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}") - self.log_updated.emit(f"- Output format: {self.output_format}") - self.log_updated.emit( - f"- Subtitle format: {next((label for value, label in SUBTITLE_FORMATS if value == getattr(self, 'subtitle_format', 'srt')), getattr(self, 'subtitle_format', 'srt'))}" - ) - self.log_updated.emit( - f"- Use spaCy for sentence segmentation: {'Yes' if getattr(self, 'use_spacy_segmentation', False) else 'No'}" - ) - self.log_updated.emit(f"- Save option: {self.save_option}") - if self.replace_single_newlines: - self.log_updated.emit(f"- Replace single newlines: Yes") - - # Check if input is a subtitle file for additional configuration - is_subtitle_input = False - if not self.is_direct_text and self.file_name: - file_ext = os.path.splitext(self.file_name)[1].lower() - if file_ext in [".srt", ".ass", ".vtt"]: - is_subtitle_input = True - - # Display subtitle-specific options if processing subtitle file - if is_subtitle_input: - if getattr(self, "use_silent_gaps", False): - self.log_updated.emit("- Use silent gaps: Yes") - speed_method = getattr(self, "subtitle_speed_method", "tts") - method_label = ( - "TTS Regeneration" - if speed_method == "tts" - else "FFmpeg Time-stretch" - ) - self.log_updated.emit(f"- Speed adjustment method: {method_label}") - - # Display save_chapters_separately flag if it's set - if hasattr(self, "save_chapters_separately"): - self.log_updated.emit( - ( - f"- Save chapters separately: {'Yes' if self.save_chapters_separately else 'No'}" - ) - ) - # Display merge_chapters_at_end flag if save_chapters_separately is True - if self.save_chapters_separately: - merge_at_end = getattr(self, "merge_chapters_at_end", True) - self.log_updated.emit( - f"- Merge chapters at the end: {'Yes' if merge_at_end else 'No'}" - ) - # Display the separate chapters format if it's set - separate_format = getattr(self, "separate_chapters_format", "wav") - self.log_updated.emit( - f"- Separate chapters format: {separate_format}" - ) - - # If merge_at_end is True, display the silence duration - if getattr(self, "merge_chapters_at_end", True): - self.log_updated.emit( - f"- Silence between chapters: {self.silence_duration} seconds" - ) - - if self.save_option == "Choose output folder": - self.log_updated.emit( - f"- Output folder: {self.output_folder or os.getcwd()}" - ) - - self.log_updated.emit(("\nInitializing TTS pipeline...", "grey")) - - # Set device based on use_gpu setting and platform - if self.use_gpu: - if platform.system() == "Darwin" and platform.processor() == "arm": - device = "mps" # Use MPS for Apple Silicon - else: - device = "cuda" # Use CUDA for other platforms - else: - device = "cpu" - - tts = self.KPipeline( - lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device - ) - - # Check if the input is a subtitle file or timestamp text file - is_subtitle_file = False - is_timestamp_text = False - if not self.is_direct_text and self.file_name: - file_ext = os.path.splitext(self.file_name)[1].lower() - if file_ext in [".srt", ".ass", ".vtt"]: - is_subtitle_file = True - self.log_updated.emit( - f"\nDetected subtitle file format: {file_ext}" - ) - elif file_ext == ".txt" and detect_timestamps_in_text(self.file_name): - is_timestamp_text = True - self.log_updated.emit( - ("\nDetected timestamps in text file", "grey") - ) - # Signal to ask user (-1 indicates timestamp detection) - self.chapters_detected.emit(-1) - # Wait for user response using event with timeout for responsive cancellation - while not self._timestamp_response_event.wait( - timeout=_USER_RESPONSE_TIMEOUT - ): - if self.cancel_requested: - self.conversion_finished.emit("Cancelled", None) - return - # Check cancellation one more time after event is set - if self.cancel_requested: - self.conversion_finished.emit("Cancelled", None) - return - if not self._timestamp_response: - is_timestamp_text = False - delattr(self, "_timestamp_response") - self._timestamp_response_event.clear() - - # Process subtitle files separately - if is_subtitle_file or is_timestamp_text: - self._process_subtitle_file(tts, base_path, is_timestamp_text) - return - - if self.is_direct_text: - text = self.file_name # Treat file_name as direct text input - else: - encoding = detect_encoding(self.file_name) - with open( - self.file_name, "r", encoding=encoding, errors="replace" - ) as file: - text = file.read() - - # Clean up text using utility function - text = clean_text(text) - - - # --- Chapter splitting logic --- - # Use pre-compiled pattern for better performance - chapter_splits = list(_CHAPTER_MARKER_SEARCH_PATTERN.finditer(text)) - chapters = [] - if chapter_splits: - # prepend Introduction for content before first marker - first_start = chapter_splits[0].start() - if first_start > 0: - intro_text = text[:first_start].strip() - if intro_text: - chapters.append(("Introduction", intro_text)) - for idx, match in enumerate(chapter_splits): - start = match.end() - end = ( - chapter_splits[idx + 1].start() - if idx + 1 < len(chapter_splits) - else len(text) - ) - chapter_name = match.group(1).strip() - chapter_text = text[start:end].strip() - chapters.append((chapter_name, chapter_text)) - else: - chapters = [("text", text)] - total_chapters = len(chapters) - - # For text files with chapters, prompt user for options if not already set - is_txt_file = not self.is_direct_text and ( - self.file_name.lower().endswith(".txt") - or (self.display_path and self.display_path.lower().endswith(".txt")) - ) - - if ( - is_txt_file - and total_chapters > 1 - and ( - not hasattr(self, "save_chapters_separately") - or not hasattr(self, "merge_chapters_at_end") - ) - and not self.chapter_options_set - ): - - # Emit signal to main thread and wait - self.chapters_detected.emit(total_chapters) - self._chapter_options_event.wait() - if self.cancel_requested: - self.conversion_finished.emit("Cancelled", None) - return - self.chapter_options_set = True - - # Log all detected chapters at the beginning - if total_chapters > 1: - chapter_list = "\n".join( - [f"{i+1}) {c[0]}" for i, c in enumerate(chapters)] - ) - self.log_updated.emit( - (f"\nDetected chapters ({total_chapters}):\n" + chapter_list) - ) - else: - self.log_updated.emit((f"\nProcessing {chapters[0][0]}...", "grey")) - - # If save_chapters_separately is enabled, find a unique suffix ONCE and use for both folder and merged file - save_chapters_separately = getattr(self, "save_chapters_separately", False) - merge_chapters_at_end = getattr(self, "merge_chapters_at_end", True) - - # Ensure merge_chapters_at_end is True if not saving chapters separately - if not save_chapters_separately: - merge_chapters_at_end = True - - chapters_out_dir = None - suffix = "" - - # Use file_name for logs if from_queue, otherwise use display_path if available - if getattr(self, "from_queue", False): - base_path = ( - self.save_base_path or self.file_name - ) # Use save_base_path if available - else: - base_path = self.display_path if self.display_path else self.file_name - - base_name = os.path.splitext(os.path.basename(base_path))[0] - # Sanitize base_name for folder/file creation based on OS - sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True) - - if self.save_option == "Save to Desktop": - parent_dir = user_desktop_dir() - elif self.save_option == "Save next to input file": - parent_dir = os.path.dirname(base_path) - else: - parent_dir = self.output_folder or os.getcwd() - # Ensure the output folder exists, error if it doesn't - if not os.path.exists(parent_dir): - self.log_updated.emit( - ( - f"Output folder does not exist: {parent_dir}", - "red", - ) - ) - # Find a unique suffix for both folder and merged file, always - counter = 1 - allowed_exts = set(SUPPORTED_SOUND_FORMATS + SUPPORTED_SUBTITLE_FORMATS) - while True: - suffix = f"_{counter}" if counter > 1 else "" - chapters_out_dir_candidate = os.path.join( - parent_dir, f"{sanitized_base_name}{suffix}_chapters" - ) - # Only check for files with allowed extensions (extension without dot, case-insensitive) - # Use generator expression to avoid processing all files upfront - file_parts = ( - os.path.splitext(fname) for fname in os.listdir(parent_dir) - ) - clash = any( - name == f"{sanitized_base_name}{suffix}" - and ext[1:].lower() in allowed_exts - for name, ext in file_parts - ) - if not os.path.exists(chapters_out_dir_candidate) and not clash: - break - counter += 1 - if save_chapters_separately and total_chapters > 1: - separate_chapters_format = getattr( - self, "separate_chapters_format", "wav" - ) - chapters_out_dir = chapters_out_dir_candidate - os.makedirs(chapters_out_dir, exist_ok=True) - self.log_updated.emit( - (f"\nChapters output folder: {chapters_out_dir}", "grey") - ) - - # Prepare merged output file for incremental writing ONLY if merge_chapters_at_end is True - if merge_chapters_at_end: - out_dir = parent_dir - base_filepath_no_ext = os.path.join( - out_dir, f"{sanitized_base_name}{suffix}" - ) - merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" - subtitle_entries = [] - current_time = 0.0 - rate = 24000 - subtitle_mode = self.subtitle_mode - self.etr_start_time = time.time() - self.processed_char_count = 0 - current_segment = 0 - chapters_time = [ - {"chapter": chapter[0], "start": 0.0, "end": 0.0} - for chapter in chapters - ] - # SRT numbering fix: use a global counter - merged_srt_index = 1 # SRT numbering for merged file - # Prepare output file/ffmpeg process for merged output - if self.output_format in ["wav", "mp3", "flac"]: - merged_out_file = sf.SoundFile( - merged_out_path, - "w", - samplerate=24000, - channels=1, - format=self.output_format, - ) - ffmpeg_proc = None - elif self.output_format == "m4b": - # Real-time M4B generation using FFmpeg pipe - static_ffmpeg.add_paths() - merged_out_file = None - ffmpeg_proc = None - metadata_options, cover_path = ( - self._extract_and_add_metadata_tags_to_ffmpeg_cmd() - ) - # Prepare ffmpeg command for m4b output - cmd = [ - "ffmpeg", - "-y", - "-thread_queue_size", - "32768", - "-f", - "f32le", - "-ar", - "24000", - "-ac", - "1", - "-i", - "pipe:0", - ] - if cover_path and os.path.exists(cover_path): - cmd.extend( - [ - "-i", - cover_path, - "-map", - "0:a", - "-map", - "1", - "-c:v", - "copy", - "-disposition:v", - "attached_pic", - ] - ) - cmd.extend( - [ - "-c:a", - "aac", - "-q:a", - "2", - "-movflags", - "+faststart+use_metadata_tags", - ] - ) - cmd += metadata_options - cmd.append(merged_out_path) - ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) - elif self.output_format == "opus": - static_ffmpeg.add_paths() - cmd = [ - "ffmpeg", - "-y", - "-thread_queue_size", - "32768", - "-f", - "f32le", - "-ar", - "24000", - "-ac", - "1", - "-i", - "pipe:0", - ] - cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) - cmd.append(merged_out_path) - ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) - merged_out_file = None - else: - self.log_updated.emit( - (f"Unsupported output format: {self.output_format}", "red") - ) - self.conversion_finished.emit( - ("Audio generation failed.", "red"), None - ) - return - # Open merged subtitle file for incremental writing if needed - merged_subtitle_file = None - if self.subtitle_mode != "Disabled": - subtitle_format = getattr(self, "subtitle_format", "srt") - file_extension = "ass" if "ass" in subtitle_format else "srt" - merged_subtitle_path = ( - os.path.splitext(merged_out_path)[0] + f".{file_extension}" - ) - # Default subtitle layout flags/strings so they exist regardless - # of whether ASS-specific handling runs. This prevents runtime - # errors when non-ASS formats (like SRT) are selected. - is_centered = False - is_narrow = False - merged_subtitle_margin = "" - merged_subtitle_alignment_tag = "" - if "ass" in subtitle_format: - merged_subtitle_file = open( - merged_subtitle_path, - "w", - encoding="utf-8", - errors="replace", - ) - # Minimal ASS header - merged_subtitle_file.write("[Script Info]\n") - merged_subtitle_file.write("Title: Generated by Abogen\n") - merged_subtitle_file.write("ScriptType: v4.00+\n\n") - # Add style definitions for karaoke highlighting - if self.subtitle_mode == "Sentence + Highlighting": - merged_subtitle_file.write("[V4+ Styles]\n") - merged_subtitle_file.write( - "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" - ) - merged_subtitle_file.write( - "Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n" - ) - merged_subtitle_file.write("[Events]\n") - merged_subtitle_file.write( - "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" - ) - # Set margin/alignment for ASS - is_centered = subtitle_format in ( - "ass_centered_wide", - "ass_centered_narrow", - ) - is_narrow = subtitle_format in ( - "ass_narrow", - "ass_centered_narrow", - ) - merged_subtitle_margin = "90" if is_narrow else "" - merged_subtitle_alignment_tag = ( - f"{{\\an5}}" if is_centered else "" - ) - else: - merged_subtitle_file = open( - merged_subtitle_path, - "w", - encoding="utf-8", - errors="replace", - ) - else: - merged_subtitle_path = None - merged_subtitle_file = None - else: - # If not merging, set merged_out_file and related variables to None - merged_out_file = None - ffmpeg_proc = None - merged_out_path = None - subtitle_entries = [] - current_time = 0.0 - rate = 24000 - subtitle_mode = self.subtitle_mode - self.etr_start_time = time.time() - self.processed_char_count = 0 - current_segment = 0 - chapters_time = [ - {"chapter": chapter[0], "start": 0.0, "end": 0.0} - for chapter in chapters - ] - srt_index = 1 # SRT numbering fix for chapter-only mode - # Instead of processing the whole text, process by chapter - for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1): - chapter_out_path = None - chapter_out_file = None - chapter_ffmpeg_proc = None - chapter_subtitle_file = None - chapter_subtitle_path = None - if total_chapters > 1: - self.log_updated.emit( - ( - f"\nChapter {chapter_idx}/{total_chapters}: {chapter_name}", - "blue", - ) - ) - chapter_subtitle_entries = [] - chapter_current_time = 0.0 - # Set chapter start time before processing - chapter_time = chapters_time[chapter_idx - 1] - if merge_chapters_at_end: - chapter_time["start"] = current_time - - # Check if the voice is a formula and load it if necessary - if "*" in self.voice: - loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) - else: - loaded_voice = self.voice - # Prepare per-chapter output file if needed - if save_chapters_separately and total_chapters > 1: - # First pass: keep alphanumeric, spaces, hyphens, and underscores - sanitized = re.sub(r"[^\w\s\-]", "", chapter_name) - # Replace multiple spaces/hyphens with single underscore - sanitized = re.sub(r"[\s\-]+", "_", sanitized).strip("_") - # Apply OS-specific sanitization - sanitized = sanitize_name_for_os(sanitized, is_folder=False) - # Limit length (leaving room for the chapter number prefix) - MAX_LEN = 80 - if len(sanitized) > MAX_LEN: - pos = sanitized[:MAX_LEN].rfind("_") - sanitized = sanitized[: pos if pos > 0 else MAX_LEN].rstrip("_") - chapter_filename = f"{chapter_idx:02d}_{sanitized}" - chapter_out_path = os.path.join( - chapters_out_dir, - f"{chapter_filename}.{separate_chapters_format}", - ) - if separate_chapters_format in ["wav", "mp3", "flac"]: - chapter_out_file = sf.SoundFile( - chapter_out_path, - "w", - samplerate=24000, - channels=1, - format=separate_chapters_format, - ) - chapter_ffmpeg_proc = None - elif separate_chapters_format == "opus": - static_ffmpeg.add_paths() - cmd = [ - "ffmpeg", - "-y", - "-thread_queue_size", - "32768", - "-f", - "f32le", - "-ar", - "24000", - "-ac", - "1", - "-i", - "pipe:0", - ] - cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) - cmd.append(chapter_out_path) - chapter_ffmpeg_proc = create_process( - cmd, stdin=subprocess.PIPE, text=False - ) - chapter_out_file = None - else: - self.log_updated.emit( - ( - f"Unsupported chapter format: {separate_chapters_format}", - "red", - ) - ) - continue - # Open chapter subtitle file for incremental writing if needed - chapter_subtitle_file = None - chapter_srt_index = ( - 1 # Initialize SRT numbering for this chapter file - ) - if self.subtitle_mode != "Disabled": - subtitle_format = getattr(self, "subtitle_format", "srt") - file_extension = "ass" if "ass" in subtitle_format else "srt" - chapter_subtitle_path = os.path.join( - chapters_out_dir, f"{chapter_filename}.{file_extension}" - ) - # Ensure these variables exist even when not using ASS so - # later code can safely reference them. - is_centered = False - is_narrow = False - chapter_subtitle_margin = "" - chapter_subtitle_alignment_tag = "" - # Open the chapter subtitle file for writing for both SRT and ASS - chapter_subtitle_file = open( - chapter_subtitle_path, - "w", - encoding="utf-8", - errors="replace", - ) - if "ass" in subtitle_format: - # Minimal ASS header - chapter_subtitle_file.write("[Script Info]\n") - chapter_subtitle_file.write("Title: Generated by Abogen\n") - chapter_subtitle_file.write("ScriptType: v4.00+\n\n") - - # Add style definitions for karaoke highlighting - if self.subtitle_mode == "Sentence + Highlighting": - chapter_subtitle_file.write("[V4+ Styles]\n") - chapter_subtitle_file.write( - "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" - ) - chapter_subtitle_file.write( - "Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n" - ) - - chapter_subtitle_file.write("[Events]\n") - chapter_subtitle_file.write( - "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" - ) - is_centered = subtitle_format in ( - "ass_centered_wide", - "ass_centered_narrow", - ) - is_narrow = subtitle_format in ( - "ass_narrow", - "ass_centered_narrow", - ) - chapter_subtitle_margin = "90" if is_narrow else "" - chapter_subtitle_alignment_tag = ( - f"{{\\an5}}" if is_centered else "" - ) - else: - chapter_subtitle_file = None - else: - chapter_subtitle_path = None - chapter_subtitle_file = None - - # Determine if spaCy segmentation should be used for PRE-TTS segmentation - # Only non-English languages use spaCy for pre-segmentation - # English uses spaCy only for subtitle generation (post-TTS) - # spaCy is disabled when subtitle mode is "Disabled" or "Line" - # spaCy is also disabled when input is a subtitle file - is_subtitle_input = ( - not self.is_direct_text - and self.file_name - and os.path.splitext(self.file_name)[1].lower() - in [".srt", ".ass", ".vtt"] - ) - use_spacy = ( - getattr(self, "use_spacy_segmentation", False) - and self.subtitle_mode not in ["Disabled", "Line"] - and not is_subtitle_input - ) - spacy_sentences = None - active_split_pattern = self.split_pattern - spacing_pattern = r"\s*" if self.lang_code in ["z", "j"] else r"\s+" - - # Pre-load spaCy model for English if it will be needed for subtitle generation - if ( - use_spacy - and self.lang_code in ["a", "b"] - and self.subtitle_mode in ["Sentence", "Sentence + Comma"] - ): - from abogen.spacy_utils import get_spacy_model - - nlp = get_spacy_model( - self.lang_code, - log_callback=lambda msg: self.log_updated.emit(msg), - ) - if nlp: - self.log_updated.emit( - ( - "\nUsing spaCy for sentence segmentation (only for subtitles)...", - "grey", - ) - ) - - if use_spacy and self.lang_code not in ["a", "b"]: - # Non-English: use spaCy for pre-TTS segmentation - self.log_updated.emit( - ("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey") - ) - from abogen.spacy_utils import segment_sentences - - spacy_sentences = segment_sentences( - chapter_text, - self.lang_code, - log_callback=lambda msg: self.log_updated.emit(msg), - ) - if spacy_sentences: - self.log_updated.emit( - ( - f"\nspaCy: Text segmented into {len(spacy_sentences)} sentences...", - "grey", - ) - ) - # For Sentence + Comma mode, still split on commas within spaCy sentences - if self.subtitle_mode == "Sentence + Comma": - active_split_pattern = r"(?<=[{}]){}|\n+".format( - self.PUNCTUATION_COMMAS, spacing_pattern - ) - else: - active_split_pattern = ( - "\n" # Use newline splitting for Sentence mode - ) - else: - self.log_updated.emit( - ("\nspaCy: Fallback to default segmentation...", "grey") - ) - - # Process text - either as spaCy sentences or as single text - text_segments = spacy_sentences if spacy_sentences else [chapter_text] - - # Print active split pattern used by the TTS engine once for this batch - try: - print(f"Using split pattern: {active_split_pattern!r}") - except Exception: - # Print must never break processing - print("Using split pattern: (unprintable)") - - for text_segment in text_segments: - for result in tts( - text_segment, - voice=loaded_voice, - speed=self.speed, - split_pattern=active_split_pattern, - ): - # Print the result for debugging - # print(f"Result: {result}") - if self.cancel_requested: - if chapter_out_file: - chapter_out_file.close() - if merged_out_file: - merged_out_file.close() - self.conversion_finished.emit("Cancelled", None) - return - current_segment += 1 - grapheme_len = len(result.graphemes) - self.processed_char_count += grapheme_len - # Log progress with both character counts and the graphemes content - self.log_updated.emit( - f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}" - ) - - chunk_dur = len(result.audio) / rate - chunk_start = current_time - # Write audio directly to merged file ONLY if merging - if merge_chapters_at_end and merged_out_file: - merged_out_file.write(result.audio) - elif merge_chapters_at_end and ffmpeg_proc: - if hasattr(result.audio, "numpy"): - audio_bytes = ( - result.audio.numpy().astype("float32").tobytes() - ) - else: - audio_bytes = result.audio.astype("float32").tobytes() - ffmpeg_proc.stdin.write(audio_bytes) - if chapter_out_file: - chapter_out_file.write(result.audio) - elif chapter_ffmpeg_proc: - if hasattr(result.audio, "numpy"): - audio_bytes = ( - result.audio.numpy().astype("float32").tobytes() - ) - else: - audio_bytes = result.audio.astype("float32").tobytes() - chapter_ffmpeg_proc.stdin.write(audio_bytes) - # Subtitle logic - if self.subtitle_mode != "Disabled": - tokens_list = getattr(result, "tokens", []) - - # Fallback for languages without token support (non-English) - # Create a single token representing the entire segment duration - if not tokens_list and result.graphemes: - - class FakeToken: - def __init__(self, text, start, end): - self.text = text - self.start_ts = start - self.end_ts = end - self.whitespace = "" - - tokens_list = [ - FakeToken(result.graphemes, 0, chunk_dur) - ] - - tokens_with_timestamps = [] - chapter_tokens_with_timestamps = [] - - # Process every token, regardless of text or timestamps - for tok in tokens_list: - tokens_with_timestamps.append( - { - "start": chunk_start + (tok.start_ts or 0), - "end": chunk_start + (tok.end_ts or 0), - "text": tok.text, - "whitespace": tok.whitespace, - } - ) - if chapter_out_file or chapter_ffmpeg_proc: - chapter_tokens_with_timestamps.append( - { - "start": chapter_current_time - + (tok.start_ts or 0), - "end": chapter_current_time - + (tok.end_ts or 0), - "text": tok.text, - "whitespace": tok.whitespace, - } - ) - # Process tokens according to subtitle mode - # Global subtitle processing ONLY if merging - if merge_chapters_at_end: - # Incremental subtitle writing for merged output - new_entries = [] - self._process_subtitle_tokens( - tokens_with_timestamps, - new_entries, - self.max_subtitle_words, - fallback_end_time=chunk_start + chunk_dur, - ) - if merged_subtitle_file: - subtitle_format = getattr( - self, "subtitle_format", "srt" - ) - if "ass" in subtitle_format: - for start, end, text in new_entries: - start_time = self._ass_time(start) - end_time = self._ass_time(end) - # Use karaoke effect for highlighting mode - effect = ( - "karaoke" - if self.subtitle_mode - == "Sentence + Highlighting" - else "" - ) - merged_subtitle_file.write( - f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n" - ) - else: - for entry in new_entries: - start, end, text = entry - merged_subtitle_file.write( - f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" - ) - merged_srt_index += 1 - # Per-chapter subtitle processing for both file and ffmpeg_proc - if chapter_out_file or chapter_ffmpeg_proc: - new_chapter_entries = [] - self._process_subtitle_tokens( - chapter_tokens_with_timestamps, - new_chapter_entries, - self.max_subtitle_words, - fallback_end_time=chapter_current_time + chunk_dur, - ) - if chapter_subtitle_file: - subtitle_format = getattr( - self, "subtitle_format", "srt" - ) - if "ass" in subtitle_format: - for start, end, text in new_chapter_entries: - start_time = self._ass_time(start) - end_time = self._ass_time(end) - # Use karaoke effect for highlighting mode - effect = ( - "karaoke" - if self.subtitle_mode - == "Sentence + Highlighting" - else "" - ) - chapter_subtitle_file.write( - f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n" - ) - else: - for entry in new_chapter_entries: - start, end, text = entry - chapter_subtitle_file.write( - f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" - ) - chapter_srt_index += 1 - if merge_chapters_at_end: - current_time += chunk_dur - if chapter_out_file or chapter_ffmpeg_proc: - chapter_current_time += chunk_dur - else: - if chapter_out_file or chapter_ffmpeg_proc: - chapter_current_time += chunk_dur - # Calculate percentage based on characters processed - percent = min( - int( - self.processed_char_count / self.total_char_count * 100 - ), - 99, - ) - - # Calculate ETR based on characters processed - etr_str = "Processing..." - chars_done = self.processed_char_count - elapsed = time.time() - self.etr_start_time - - # Calculate ETR if enough data is available - if ( - chars_done > 0 and elapsed > 0.5 - ): # Check elapsed > 0.5 to avoid instability - avg_time_per_char = elapsed / chars_done - remaining = ( - self.total_char_count - self.processed_char_count - ) - if remaining > 0: - secs = avg_time_per_char * remaining - h = int(secs // 3600) - m = int((secs % 3600) // 60) - s = int(secs % 60) - etr_str = f"{h:02d}:{m:02d}:{s:02d}" - - # Update progress more frequently (after each result) - self.progress_updated.emit(percent, etr_str) - - # Add silence between chapters for merged output (except after the last chapter) - if merge_chapters_at_end and chapter_idx < total_chapters: - silence_samples = int( - self.silence_duration * 24000 - ) # Silence duration at 24,000 Hz - silence_audio = self.np.zeros(silence_samples, dtype="float32") - silence_bytes = silence_audio.tobytes() - - if merged_out_file: - merged_out_file.write(silence_audio) - elif ffmpeg_proc: - ffmpeg_proc.stdin.write(silence_bytes) - - # Update timing for the silence - current_time += self.silence_duration - if chapter_out_file or chapter_ffmpeg_proc: - chapter_current_time += self.silence_duration - - # Set chapter end time after processing - if merge_chapters_at_end: - chapter_time["end"] = current_time - # Finalize chapter file for ffmpeg formats - if chapter_out_file or chapter_ffmpeg_proc: - self.log_updated.emit(("\nProcessing chapter audio...", "grey")) - if chapter_ffmpeg_proc: - chapter_ffmpeg_proc.stdin.close() - chapter_ffmpeg_proc.wait() - if chapter_out_file: - chapter_out_file.close() - # Close chapter subtitle file if open - if chapter_subtitle_file: - chapter_subtitle_file.close() - if ( - save_chapters_separately - and total_chapters > 1 - and self.subtitle_mode != "Disabled" - and chapter_subtitle_path - ): - self.log_updated.emit( - ( - f"\nChapter {chapter_idx} saved to: {chapter_out_path}\n\nChapter subtitle saved to: {chapter_subtitle_path}", - "green", - ) - ) - elif chapter_out_path: - self.log_updated.emit( - ( - f"\nChapter {chapter_idx} saved to: {chapter_out_path}", - "green", - ) - ) - # Finalize merged output file ONLY if merging - if merge_chapters_at_end: - self.log_updated.emit(("\nFinalizing audio. Please wait...", "grey")) - if self.output_format in ["wav", "mp3", "flac"]: - merged_out_file.close() - elif self.output_format == "m4b": - ffmpeg_proc.stdin.close() - ffmpeg_proc.wait() - # Add chapters via fast post-processing - if total_chapters > 1: - chapters_info_path = f"{base_filepath_no_ext}_chapters.txt" - with open(chapters_info_path, "w", encoding="utf-8") as f: - f.write(";FFMETADATA1\n") - for chapter in chapters_time: - chapter_title = chapter["chapter"].replace("=", "\\=") - f.write(f"[CHAPTER]\n") - f.write(f"TIMEBASE=1/1000\n") - f.write(f"START={int(chapter['start']*1000)}\n") - f.write(f"END={int(chapter['end']*1000)}\n") - f.write(f"title={chapter_title}\n\n") - # Fast mux chapters into m4b (write to temp file, then replace original) - static_ffmpeg.add_paths() - orig_path = merged_out_path - root, ext = os.path.splitext(orig_path) - tmp_path = root + ".tmp" + ext - metadata_options, cover_path = ( - self._extract_and_add_metadata_tags_to_ffmpeg_cmd() - ) - cmd = [ - "ffmpeg", - "-y", - "-i", - orig_path, - "-i", - chapters_info_path, - ] - if cover_path and os.path.exists(cover_path): - cmd.extend( - [ - "-i", - cover_path, - "-map", - "0:a", - "-map", - "2", - "-c:v", - "copy", - "-disposition:v", - "attached_pic", - ] - ) - else: - cmd.extend(["-map", "0:a"]) - - cmd.extend( - [ - "-map_metadata", - "1", - "-map_chapters", - "1", - "-c:a", - "copy", - ] - ) - cmd += metadata_options - cmd.append(tmp_path) - proc = create_process(cmd) - proc.wait() - os.replace(tmp_path, orig_path) - os.remove(chapters_info_path) - elif self.output_format in ["opus"]: - ffmpeg_proc.stdin.close() - ffmpeg_proc.wait() - self.progress_updated.emit(100, "00:00:00") - # Close merged subtitle file if open - if merged_subtitle_file: - merged_subtitle_file.close() - # Subtitle and final message logic - if merge_chapters_at_end: - if self.subtitle_mode != "Disabled": - self.conversion_finished.emit( - ( - f"\nAudio saved to: {merged_out_path}\n\nSubtitle saved to: {merged_subtitle_path}", - "green", - ), - merged_out_path, - ) - else: - self.conversion_finished.emit( - (f"\nAudio saved to: {merged_out_path}", "green"), - merged_out_path, - ) - else: - # If not merging, report the folder that holds the chapter files - self.progress_updated.emit(100, "00:00:00") - chapters_dir = os.path.abspath(chapters_out_dir or parent_dir) - self.conversion_finished.emit( - (f"\nAll chapters saved to: {chapters_dir}", "green"), - chapters_dir, - ) - except Exception as e: - # Cleanup ffmpeg subprocesses on error - try: - if "ffmpeg_proc" in locals() and ffmpeg_proc: - ffmpeg_proc.stdin.close() - ffmpeg_proc.terminate() - ffmpeg_proc.wait() - except Exception: - pass - try: - if "chapter_ffmpeg_proc" in locals() and chapter_ffmpeg_proc: - chapter_ffmpeg_proc.stdin.close() - chapter_ffmpeg_proc.terminate() - chapter_ffmpeg_proc.wait() - except Exception: - pass - self.log_updated.emit((f"Error occurred: {str(e)}", "red")) - self.conversion_finished.emit(("Audio generation failed.", "red"), None) - - def _process_subtitle_file(self, tts, base_path, is_timestamp_text=False): - """Process subtitle files with precise timing and generate output subtitles.""" - try: - # Parse subtitle file - if is_timestamp_text: - subtitles = parse_timestamp_text_file(self.file_name) - else: - file_ext = os.path.splitext(self.file_name)[1].lower() - if file_ext == ".srt": - subtitles = parse_srt_file(self.file_name) - elif file_ext == ".vtt": - subtitles = parse_vtt_file(self.file_name) - else: - subtitles = parse_ass_file(self.file_name) - - if not subtitles: - self.log_updated.emit(("No valid subtitle entries found.", "red")) - self.conversion_finished.emit( - ("No subtitle entries to process.", "red"), None - ) - return - - self.log_updated.emit( - (f"\nFound {len(subtitles)} subtitle entries", "grey") - ) - - # Setup output paths - base_name = os.path.splitext(os.path.basename(base_path))[0] - sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True) - parent_dir = ( - user_desktop_dir() - if self.save_option == "Save to Desktop" - else ( - os.path.dirname(base_path) - if self.save_option == "Save next to input file" - else self.output_folder or os.getcwd() - ) - ) - - if not os.path.exists(parent_dir): - self.log_updated.emit( - (f"Output folder does not exist: {parent_dir}", "red") - ) - return - - # Find unique filename - counter = 1 - allowed_exts = set(SUPPORTED_SOUND_FORMATS + SUPPORTED_SUBTITLE_FORMATS) - while True: - suffix = f"_{counter}" if counter > 1 else "" - # Use generator expression to avoid processing all files upfront - file_parts = (os.path.splitext(f) for f in os.listdir(parent_dir)) - if not any( - name == f"{sanitized_base_name}{suffix}" - and ext[1:].lower() in allowed_exts - for name, ext in file_parts - ): - break - counter += 1 - - base_filepath_no_ext = os.path.join( - parent_dir, f"{sanitized_base_name}{suffix}" - ) - merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" - rate = 24000 - - # Setup audio output - merged_out_file, ffmpeg_proc = None, None - if self.output_format in ["wav", "mp3", "flac"]: - merged_out_file = sf.SoundFile( - merged_out_path, - "w", - samplerate=rate, - channels=1, - format=self.output_format, - ) - else: - static_ffmpeg.add_paths() - cmd = [ - "ffmpeg", - "-y", - "-thread_queue_size", - "32768", - "-f", - "f32le", - "-ar", - str(rate), - "-ac", - "1", - "-i", - "pipe:0", - ] - if self.output_format == "m4b": - metadata_options, cover_path = ( - self._extract_and_add_metadata_tags_to_ffmpeg_cmd() - ) - if cover_path and os.path.exists(cover_path): - cmd.extend( - [ - "-i", - cover_path, - "-map", - "0:a", - "-map", - "1", - "-c:v", - "copy", - "-disposition:v", - "attached_pic", - ] - ) - cmd.extend( - [ - "-c:a", - "aac", - "-q:a", - "2", - "-movflags", - "+faststart+use_metadata_tags", - ] - ) - cmd.extend(metadata_options) - elif self.output_format == "opus": - cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) - else: - self.log_updated.emit( - (f"Unsupported output format: {self.output_format}", "red") - ) - return - cmd.append(merged_out_path) - ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) - - # Always generate subtitles for subtitle input files - subtitle_file, subtitle_path = None, None - subtitle_format = getattr(self, "subtitle_format", "srt") - file_extension = "ass" if "ass" in subtitle_format else "srt" - subtitle_path = f"{base_filepath_no_ext}.{file_extension}" - subtitle_file = open(subtitle_path, "w", encoding="utf-8", errors="replace") - - if "ass" in subtitle_format: - # Write ASS header - subtitle_file.write( - "[Script Info]\nTitle: Generated by Abogen\nScriptType: v4.00+\n\n" - ) - if self.subtitle_mode == "Sentence + Highlighting": - subtitle_file.write( - "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" - ) - subtitle_file.write( - "Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n" - ) - subtitle_file.write( - "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" - ) - - is_narrow = subtitle_format in ("ass_narrow", "ass_centered_narrow") - is_centered = subtitle_format in ( - "ass_centered_wide", - "ass_centered_narrow", - ) - margin = "90" if is_narrow else "" - alignment = "{\\an5}" if is_centered else "" - - # Load voice - loaded_voice = ( - get_new_voice(tts, self.voice, self.use_gpu) - if "*" in self.voice - else self.voice - ) - - # Calculate initial audio buffer size from timed subtitles only - max_end_time = max( - (end for _, end, _ in subtitles if end is not None), default=0 - ) - audio_buffer = self.np.zeros( - int(max_end_time * rate) + rate, dtype="float32" - ) - - # Process each subtitle and mix into buffer - self.etr_start_time = time.time() - srt_index = 1 - - for idx, (start_time, end_time, text) in enumerate(subtitles, 1): - if self.cancel_requested: - if subtitle_file: - subtitle_file.close() - self.conversion_finished.emit("Cancelled", None) - return - - # Process text and timing - replace_nl = getattr(self, "replace_single_newlines", True) - processed_text = text.replace("\n", " ") if replace_nl else text - use_gaps = getattr(self, "use_silent_gaps", False) - next_start = ( - subtitles[idx][0] - if (use_gaps and idx < len(subtitles)) - else float("inf") - ) - subtitle_duration = None if end_time is None else end_time - start_time - - h1, m1, s1 = ( - int(start_time // 3600), - int(start_time % 3600 // 60), - int(start_time % 60), - ) - ms1 = int((start_time - int(start_time)) * 1000) - is_last = ( - is_timestamp_text - or (use_gaps and idx == len(subtitles)) - or end_time is None - ) - if is_last: - time_str = ( - f"{h1:02d}:{m1:02d}:{s1:02d}" - + (f",{ms1:03d}" if ms1 > 0 else "") - + " - AUTO" - ) - else: - h2, m2, s2 = ( - int(end_time // 3600), - int(end_time % 3600 // 60), - int(end_time % 60), - ) - ms2 = int((end_time - int(end_time)) * 1000) - time_str = ( - f"{h1:02d}:{m1:02d}:{s1:02d}" - + (f",{ms1:03d}" if ms1 > 0 else "") - + " - " - + f"{h2:02d}:{m2:02d}:{s2:02d}" - + (f",{ms2:03d}" if ms2 > 0 else "") - ) - self.log_updated.emit( - f"\n[{idx}/{len(subtitles)}] {time_str}: {processed_text}" - ) - - # Generate TTS audio - tts_results = [ - r - for r in tts( - processed_text, - voice=loaded_voice, - speed=self.speed, - split_pattern=None, - ) - if not self.cancel_requested - ] - audio_chunks = [r.audio for r in tts_results] - - if self.cancel_requested: - if subtitle_file: - subtitle_file.close() - self.conversion_finished.emit("Cancelled", None) - return - - # Concatenate audio and determine duration - full_audio = ( - self.np.concatenate( - [a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks] - ) - if audio_chunks - else self.np.zeros( - int((subtitle_duration or 0) * rate), dtype="float32" - ) - ) - audio_duration = len(full_audio) / rate - - # Use actual audio length for timing - if is_timestamp_text: - end_time = start_time + audio_duration - subtitle_duration = audio_duration - elif use_gaps: - end_time = min(start_time + audio_duration, next_start) - subtitle_duration = end_time - start_time - elif subtitle_duration is None: - subtitle_duration = audio_duration - end_time = start_time + audio_duration - - # Speed up if needed - speedup_threshold = ( - next_start - start_time if use_gaps else subtitle_duration - ) - if audio_duration > speedup_threshold: - speed_factor = audio_duration / speedup_threshold - - if getattr(self, "subtitle_speed_method", "tts") == "ffmpeg": - # FFmpeg time-stretch (faster processing) - self.log_updated.emit( - (f" -> FFmpeg time-stretch: {speed_factor:.2f}x", "grey") - ) - - static_ffmpeg.add_paths() - num_stages = max( - 1, - int( - self.np.ceil( - self.np.log(speed_factor) / self.np.log(2.0) - ) - ), - ) - tempo = speed_factor ** (1.0 / num_stages) - filter_str = ",".join([f"atempo={tempo:.6f}"] * num_stages) - - speed_proc = subprocess.Popen( - [ - "ffmpeg", - "-y", - "-f", - "f32le", - "-ar", - str(rate), - "-ac", - "1", - "-i", - "pipe:0", - "-filter:a", - filter_str, - "-f", - "f32le", - "-ar", - str(rate), - "-ac", - "1", - "pipe:1", - ], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - full_audio = self.np.frombuffer( - speed_proc.communicate(input=full_audio.tobytes())[0], - dtype="float32", - ) - audio_duration = len(full_audio) / rate - else: - # TTS regeneration (better quality) - new_speed = self.speed * speed_factor - self.log_updated.emit( - (f" -> Regenerating at {new_speed:.2f}x speed", "grey") - ) - - tts_results = [ - r - for r in tts( - processed_text, - voice=loaded_voice, - speed=new_speed, - split_pattern=None, - ) - if not self.cancel_requested - ] - audio_chunks = [r.audio for r in tts_results] - - full_audio = ( - self.np.concatenate( - [ - a.numpy() if hasattr(a, "numpy") else a - for a in audio_chunks - ] - ) - if audio_chunks - else self.np.zeros( - int(subtitle_duration * rate), dtype="float32" - ) - ) - audio_duration = len(full_audio) / rate - - # Adjust duration after potential speed changes - if use_gaps: - end_time = min(start_time + audio_duration, next_start) - subtitle_duration = end_time - start_time - elif subtitle_duration is None: - subtitle_duration = audio_duration - end_time = start_time + audio_duration - - # Pad or trim to subtitle duration - target_samples = int(subtitle_duration * rate) - if len(full_audio) < target_samples: - full_audio = self.np.concatenate( - [ - full_audio, - self.np.zeros( - target_samples - len(full_audio), dtype="float32" - ), - ] - ) - elif len(full_audio) > target_samples: - full_audio = full_audio[:target_samples] - - # Mix audio into buffer at the correct position (handles overlaps) - start_sample = int(start_time * rate) - end_sample = start_sample + len(full_audio) - if end_sample > len(audio_buffer): - # Extend buffer if needed - audio_buffer = self.np.concatenate( - [ - audio_buffer, - self.np.zeros( - end_sample - len(audio_buffer), dtype="float32" - ), - ] - ) - - # Mix (add) the audio - this handles overlaps by combining them - audio_buffer[start_sample:end_sample] += full_audio - - # Write subtitle - if subtitle_file: - if "ass" in subtitle_format: - effect = ( - "karaoke" - if self.subtitle_mode == "Sentence + Highlighting" - else "" - ) - ass_text = ( - processed_text - if replace_nl - else processed_text.replace("\n", "\\N") - ) - subtitle_file.write( - f"Dialogue: 0,{self._ass_time(start_time)},{self._ass_time(end_time)},Default,,{margin},{margin},0,{effect},{alignment}{ass_text}\n" - ) - else: - subtitle_file.write( - f"{srt_index}\n{self._srt_time(start_time)} --> {self._srt_time(end_time)}\n{processed_text}\n\n" - ) - srt_index += 1 - - # Update progress - percent = min(int(idx / len(subtitles) * 100), 99) - elapsed = time.time() - self.etr_start_time - etr_str = ( - "Processing..." - if elapsed <= 0.5 - else f"{int(elapsed*(len(subtitles)-idx)/idx)//3600:02d}:{(int(elapsed*(len(subtitles)-idx)/idx)%3600)//60:02d}:{int(elapsed*(len(subtitles)-idx)/idx)%60:02d}" - ) - self.progress_updated.emit(percent, etr_str) - - # Normalize audio buffer to prevent clipping from mixed overlaps - max_amplitude = self.np.abs(audio_buffer).max() - if max_amplitude > 1.0: - self.log_updated.emit( - f"\n -> Normalizing audio (peak: {max_amplitude:.2f})" - ) - audio_buffer = audio_buffer / max_amplitude - - # Write the complete audio buffer - self.log_updated.emit(("\nFinalizing audio. Please wait...", "grey")) - if merged_out_file: - merged_out_file.write(audio_buffer) - merged_out_file.close() - elif ffmpeg_proc: - ffmpeg_proc.stdin.write(audio_buffer.astype("float32").tobytes()) - ffmpeg_proc.stdin.close() - ffmpeg_proc.wait() - - if subtitle_file: - subtitle_file.close() - - self.progress_updated.emit(100, "00:00:00") - result_msg = f"\nAudio saved to: {merged_out_path}" + ( - f"\n\nSubtitle saved to: {subtitle_path}" if subtitle_path else "" - ) - self.conversion_finished.emit((result_msg, "green"), merged_out_path) - - except Exception as e: - try: - if "ffmpeg_proc" in locals() and ffmpeg_proc: - ffmpeg_proc.stdin.close() - ffmpeg_proc.terminate() - ffmpeg_proc.wait() - if "subtitle_file" in locals() and subtitle_file: - subtitle_file.close() - except: - pass - self.log_updated.emit((f"Error processing subtitle file: {str(e)}", "red")) - self.conversion_finished.emit(("Audio generation failed.", "red"), None) - - def set_chapter_options(self, options): - """Set chapter options from the dialog and resume processing""" - self.save_chapters_separately = options["save_chapters_separately"] - self.merge_chapters_at_end = options["merge_chapters_at_end"] - self.waiting_for_user_input = False - self._chapter_options_event.set() - - def set_timestamp_response(self, treat_as_subtitle): - """Set whether to treat timestamp text file as subtitle.""" - self._timestamp_response = treat_as_subtitle - self._timestamp_response_event.set() - - def _extract_and_add_metadata_tags_to_ffmpeg_cmd(self): - """Extract metadata tags from text content and add them to ffmpeg command""" - metadata_options = [] - - # Get the input text (either direct or from file) - text = "" - if self.is_direct_text: - text = self.file_name - else: - try: - encoding = detect_encoding(self.file_name) - with open( - self.file_name, "r", encoding=encoding, errors="replace" - ) as file: - text = file.read() - except Exception as e: - self.log_updated.emit( - f"Warning: Could not read file for metadata extraction: {e}" - ) - return [] - - # Extract metadata tags using regex - title_match = re.search(r"<]*)>>", text) - artist_match = re.search(r"<]*)>>", text) - album_match = re.search(r"<]*)>>", text) - year_match = re.search(r"<]*)>>", text) - album_artist_match = re.search(r"<]*)>>", text) - composer_match = re.search(r"<]*)>>", text) - genre_match = re.search(r"<]*)>>", text) - cover_match = re.search(r"<]*)>>", text) - cover_path = cover_match.group(1) if cover_match else None - - # Use display path or filename as fallback for title - - # Use file_name for logs if from_queue, otherwise use display_path if available - if getattr(self, "from_queue", False): - filename = os.path.splitext(os.path.basename(self.file_name))[0] - else: - filename = os.path.splitext( - os.path.basename( - self.display_path if self.display_path else self.file_name - ) - )[0] - - if title_match: - metadata_options.extend(["-metadata", f"title={title_match.group(1)}"]) - else: - metadata_options.extend(["-metadata", f"title={filename}"]) - - # Add artist metadata - if artist_match: - metadata_options.extend(["-metadata", f"artist={artist_match.group(1)}"]) - else: - metadata_options.extend(["-metadata", f"artist=Unknown"]) - - # Add album metadata - if album_match: - metadata_options.extend(["-metadata", f"album={album_match.group(1)}"]) - else: - metadata_options.extend(["-metadata", f"album={filename}"]) - - # Add year metadata - if year_match: - metadata_options.extend(["-metadata", f"date={year_match.group(1)}"]) - else: - # Use current year if year is not specified - import datetime - - current_year = datetime.datetime.now().year - metadata_options.extend(["-metadata", f"date={current_year}"]) - - # Add album artist metadata - if album_artist_match: - metadata_options.extend( - ["-metadata", f"album_artist={album_artist_match.group(1)}"] - ) - else: - metadata_options.extend(["-metadata", f"album_artist=Unknown"]) - - # Add composer metadata - if composer_match: - metadata_options.extend( - ["-metadata", f"composer={composer_match.group(1)}"] - ) - else: - metadata_options.extend(["-metadata", f"composer=Narrator"]) - - # Add genre metadata - if genre_match: - metadata_options.extend(["-metadata", f"genre={genre_match.group(1)}"]) - else: - metadata_options.extend(["-metadata", f"genre=Audiobook"]) - - # Add these to ffmpeg command - return metadata_options, cover_path - - def _srt_time(self, t): - """Helper function to format time for SRT files""" - h = int(t // 3600) - m = int((t % 3600) // 60) - s = int(t % 60) - ms = int((t - int(t)) * 1000) - return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" - - def _ass_time(self, t): - """Helper function to format time for ASS files""" - h = int(t // 3600) - m = int((t % 3600) // 60) - s = int(t % 60) - cs = int((t - int(t)) * 100) # Centiseconds for ASS format - return f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}" - - def _process_subtitle_tokens( - self, - tokens_with_timestamps, - subtitle_entries, - max_subtitle_words, - fallback_end_time=None, - ): - """Helper function to process subtitle tokens according to the subtitle mode""" - if not tokens_with_timestamps: - return - - processed_tokens = tokens_with_timestamps # Use tokens directly - - # For English with spaCy enabled and sentence-based modes, use spaCy for sentence boundaries - # spaCy is disabled when subtitle mode is "Disabled" or "Line" - use_spacy_for_english = ( - getattr(self, "use_spacy_segmentation", False) - and self.subtitle_mode not in ["Disabled", "Line"] - and self.lang_code in ["a", "b"] - and self.subtitle_mode in ["Sentence", "Sentence + Comma"] - ) - # Use processed_tokens instead of tokens_with_timestamps for the rest of the method - if self.subtitle_mode == "Sentence + Highlighting": - # Sentence-based processing with karaoke highlighting - # Use punctuation without comma - separator = r"[{}]".format(self.PUNCTUATION_SENTENCE) - current_sentence = [] - word_count = 0 - - for token in processed_tokens: # Updated to use processed_tokens - current_sentence.append(token) - word_count += 1 - - # Split sentences based on separator or word count - if ( - re.search(separator, token["text"]) and token["whitespace"] == " " - ) or word_count >= max_subtitle_words: - if current_sentence: - # Create karaoke subtitle entry for this sentence - start_time = current_sentence[0]["start"] - end_time = current_sentence[-1]["end"] - - # Generate karaoke text with background highlighting - karaoke_text = "" - for t in current_sentence: - # Calculate duration in centiseconds - duration = ( - t["end"] - t["start"] - if t["end"] and t["start"] - else 0.5 - ) - duration_cs = int(duration * 100) - # Add karaoke effect - relies on style's SecondaryColour for highlighting - karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}" - - subtitle_entries.append( - (start_time, end_time, karaoke_text.strip()) - ) - current_sentence = [] - word_count = 0 - - # Add any remaining tokens as a sentence - if current_sentence: - start_time = current_sentence[0]["start"] - end_time = current_sentence[-1]["end"] - - # Generate karaoke text for remaining tokens - karaoke_text = "" - for t in current_sentence: - duration = t["end"] - t["start"] if t["end"] and t["start"] else 0.5 - duration_cs = int(duration * 100) - karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}" - subtitle_entries.append((start_time, end_time, karaoke_text.strip())) - - # Fallback for last entry - if subtitle_entries and fallback_end_time is not None: - last_entry = subtitle_entries[-1] - start, end, text = last_entry - if end is None or end <= start or end <= 0: - subtitle_entries[-1] = (start, fallback_end_time, text) - - elif self.subtitle_mode in ["Sentence", "Sentence + Comma", "Line"]: - # Check if we should use spaCy for English sentence boundaries - if use_spacy_for_english and self.subtitle_mode != "Line": - # Use spaCy for English sentence boundary detection (model already loaded) - from abogen.spacy_utils import get_spacy_model - - nlp = get_spacy_model( - self.lang_code - ) # No log_callback since model is already loaded - if nlp: - # Build full text and track character positions to token indices - full_text = "" - char_to_token = [] # Maps character index to token index - for idx, token in enumerate(processed_tokens): - start_char = len(full_text) - text_part = token["text"] + (token.get("whitespace", "") or "") - full_text += text_part - char_to_token.extend([idx] * len(text_part)) - - # Get sentence boundaries from spaCy - doc = nlp(full_text) - sentence_boundaries = [sent.end_char for sent in doc.sents] - - # For "Sentence + Comma" mode, also split on commas - if self.subtitle_mode == "Sentence + Comma": - comma_positions = [ - i + 1 for i, c in enumerate(full_text) if c == "," - ] - sentence_boundaries = sorted( - set(sentence_boundaries + comma_positions) - ) - - # Group tokens by sentence boundaries - current_sentence = [] - word_count = 0 - current_char_pos = 0 - boundary_idx = 0 - - for idx, token in enumerate(processed_tokens): - current_sentence.append(token) - word_count += 1 - text_len = len(token["text"]) + len( - token.get("whitespace", "") or "" - ) - current_char_pos += text_len - - # Check if we've hit a sentence boundary or max words - at_boundary = ( - boundary_idx < len(sentence_boundaries) - and current_char_pos >= sentence_boundaries[boundary_idx] - ) - if at_boundary or word_count >= max_subtitle_words: - if current_sentence: - start_time = current_sentence[0]["start"] - end_time = current_sentence[-1]["end"] - sentence_text = "".join( - t["text"] + (t.get("whitespace", "") or "") - for t in current_sentence - ) - subtitle_entries.append( - (start_time, end_time, sentence_text.strip()) - ) - current_sentence = [] - word_count = 0 - if at_boundary: - boundary_idx += 1 - - # Add remaining tokens - if current_sentence: - start_time = current_sentence[0]["start"] - end_time = current_sentence[-1]["end"] - sentence_text = "".join( - t["text"] + (t.get("whitespace", "") or "") - for t in current_sentence - ) - subtitle_entries.append( - (start_time, end_time, sentence_text.strip()) - ) - - # Fallback for last entry - if subtitle_entries and fallback_end_time is not None: - last_entry = subtitle_entries[-1] - start, end, text = last_entry - if end is None or end <= start or end <= 0: - subtitle_entries[-1] = (start, fallback_end_time, text) - return # Exit early, spaCy processing complete - - # Default regex-based processing (non-English or spaCy unavailable) - # Define separator pattern based on mode - if self.subtitle_mode == "Line": - separator = r"\n" - elif self.subtitle_mode == "Sentence": - # Use punctuation without comma - separator = r"[{}]".format(self.PUNCTUATION_SENTENCE) - else: # Sentence + Comma - # Use punctuation with comma - separator = r"[{}]".format(self.PUNCTUATION_SENTENCE_COMMA) - current_sentence = [] - word_count = 0 - - for token in processed_tokens: # Updated to use processed_tokens - current_sentence.append(token) - word_count += 1 - - # Split sentences based on separator or word count - if ( - re.search(separator, token["text"]) and token["whitespace"] == " " - ) or word_count >= max_subtitle_words: - if current_sentence: - # Create subtitle entry for this sentence - start_time = current_sentence[0]["start"] - end_time = current_sentence[-1]["end"] - - # Simplified text joining logic - sentence_text = "" - for t in current_sentence: - sentence_text += t["text"] + (t.get("whitespace", "") or "") - - subtitle_entries.append( - (start_time, end_time, sentence_text.strip()) - ) - current_sentence = [] - word_count = 0 - - # Add any remaining tokens as a sentence - if current_sentence: - start_time = current_sentence[0]["start"] - end_time = current_sentence[-1]["end"] - - # Simplified text joining logic - sentence_text = "" - for t in current_sentence: - sentence_text += t["text"] + (t.get("whitespace", "") or "") - subtitle_entries.append((start_time, end_time, sentence_text.strip())) - - # Fallback for last entry - if subtitle_entries and fallback_end_time is not None: - last_entry = subtitle_entries[-1] - start, end, text = last_entry - if end is None or end <= start or end <= 0: - subtitle_entries[-1] = (start, fallback_end_time, text) - - else: - # Word count-based grouping - simply count spaces and split after N spaces - try: - word_count = int(self.subtitle_mode.split()[0]) - word_count = min(word_count, max_subtitle_words) - except (ValueError, IndexError): - word_count = 1 - - current_group = [] - space_count = 0 - - for token in processed_tokens: - current_group.append(token) - - # Count spaces after tokens (in the whitespace field) - if token.get("whitespace", "") == " ": - space_count += 1 - - # Split after counting N spaces - if space_count >= word_count: - text = "".join( - t["text"] + (t.get("whitespace", "") or "") - for t in current_group - ) - subtitle_entries.append( - ( - current_group[0]["start"], - current_group[-1]["end"], - text.strip(), - ) - ) - current_group = [] - space_count = 0 - - # Add any remaining tokens - if current_group: - text = "".join( - t["text"] + (t.get("whitespace", "") or "") for t in current_group - ) - subtitle_entries.append( - (current_group[0]["start"], current_group[-1]["end"], text.strip()) - ) - - # Fallback for last entry - if subtitle_entries and fallback_end_time is not None: - last_entry = subtitle_entries[-1] - start, end, text = last_entry - if end is None or end <= start or end <= 0: - subtitle_entries[-1] = (start, fallback_end_time, text) - - def cancel(self): - self.cancel_requested = True - self.should_cancel = True - self.waiting_for_user_input = False - # Terminate subprocess if running - if self.process: - try: - self.process.terminate() - except Exception: - pass - # Terminate ffmpeg subprocesses if running - try: - if hasattr(self, "ffmpeg_proc") and self.ffmpeg_proc: - self.ffmpeg_proc.stdin.close() - self.ffmpeg_proc.terminate() - self.ffmpeg_proc.wait() - except Exception: - pass - try: - if hasattr(self, "chapter_ffmpeg_proc") and self.chapter_ffmpeg_proc: - self.chapter_ffmpeg_proc.stdin.close() - self.chapter_ffmpeg_proc.terminate() - self.chapter_ffmpeg_proc.wait() - except Exception: - pass - - -class VoicePreviewThread(QThread): - finished = pyqtSignal() - error = pyqtSignal(str) - - def __init__( - self, - np_module, - kpipeline_class, - lang_code, - voice, - speed, - use_gpu=False, - parent=None, - ): - super().__init__(parent) - self.np_module = np_module - self.kpipeline_class = kpipeline_class - self.lang_code = lang_code - self.voice = voice - self.speed = speed - self.use_gpu = use_gpu - - # Cache location for preview audio - self.cache_dir = get_user_cache_path("preview_cache") - - # Calculate cache path - self.cache_path = self._get_cache_path() - - def _get_cache_path(self): - """Generate a unique filename for the voice with its parameters""" - # For a voice formula, use a hash of the formula - if "*" in self.voice: - voice_id = ( - f"voice_formula_{hashlib.md5(self.voice.encode()).hexdigest()[:8]}" - ) - else: - voice_id = self.voice - - # Create a unique filename based on voice_id, language, and speed - filename = f"{voice_id}_{self.lang_code}_{self.speed:.2f}.wav" - return os.path.join(self.cache_dir, filename) - - def run(self): - print( - f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n" - ) - - # Generate the preview and save to cache - try: - - # Set device based on use_gpu setting and platform - if self.use_gpu: - if platform.system() == "Darwin" and platform.processor() == "arm": - device = "mps" # Use MPS for Apple Silicon - else: - device = "cuda" # Use CUDA for other platforms - else: - device = "cpu" - - tts = self.kpipeline_class( - lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device - ) - # Enable voice formula support for preview - if "*" in self.voice: - loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) - else: - loaded_voice = self.voice - sample_text = get_sample_voice_text(self.lang_code) - audio_segments = [] - for result in tts( - sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None - ): - audio_segments.append(result.audio) - if audio_segments: - audio = self.np_module.concatenate(audio_segments) - # Save directly to the cache path - sf.write(self.cache_path, audio, 24000) - self.temp_wav = self.cache_path - self.finished.emit() - except Exception as e: - self.error.emit(f"Voice preview error: {str(e)}") - - -class PlayAudioThread(QThread): - finished = pyqtSignal() - error = pyqtSignal(str) - - def __init__(self, wav_path, parent=None): - super().__init__(parent) - self.wav_path = wav_path - self.is_canceled = False - - def run(self): - try: - import pygame - import time as _time - - pygame.mixer.init() - pygame.mixer.music.load(self.wav_path) - pygame.mixer.music.play() - # Wait until playback is finished or canceled - while pygame.mixer.music.get_busy() and not self.is_canceled: - _time.sleep(0.2) - - # Make sure to clean up regardless of how we exited the loop - try: - pygame.mixer.music.stop() - pygame.mixer.music.unload() - pygame.mixer.quit() # Quit the mixer - except Exception: - # Ignore any errors during cleanup - pass - - self.finished.emit() - except Exception as e: - # Handle initialization errors separately to give better error messages - if "mixer not initialized" in str(e): - self.error.emit( - "Audio playback error: The audio system was not properly initialized" - ) - else: - self.error.emit(f"Audio playback error: {str(e)}") - - def stop(self): - """Safely stop playback""" - self.is_canceled = True - # Try to stop pygame if it's running, but catch all exceptions - try: - import pygame - - if pygame.mixer.get_init(): - if pygame.mixer.music.get_busy(): - pygame.mixer.music.stop() - pygame.mixer.music.unload() - except Exception: - # Ignore all errors when stopping since mixer might not be initialized - pass +__all__ = ["ConversionThread", "VoicePreviewThread", "PlayAudioThread"] diff --git a/abogen/debug_tts_samples.py b/abogen/debug_tts_samples.py new file mode 100644 index 0000000..6fa4c52 --- /dev/null +++ b/abogen/debug_tts_samples.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, List, Sequence + + +MARKER_PREFIX = "[[ABOGEN-DBG:" +MARKER_SUFFIX = "]]" + + +@dataclass(frozen=True) +class DebugTTSSample: + code: str + label: str + text: str + + +DEBUG_TTS_SAMPLES: Sequence[DebugTTSSample] = ( + DebugTTSSample( + code="APOS_001", + label="Apostrophes & contractions (1)", + text="It's a beautiful day, isn't it? Let's see what we'll do.", + ), + DebugTTSSample( + code="APOS_002", + label="Apostrophes & contractions (2)", + text="I'm sure you're ready; we'd better go before it's too late.", + ), + DebugTTSSample( + code="APOS_003", + label="Apostrophes & contractions (3)", + text="He'll say it's fine, but I can't promise it'll work.", + ), + DebugTTSSample( + code="APOS_004", + label="Apostrophes & contractions (4)", + text="They've done it, and I'd agree they've earned it.", + ), + DebugTTSSample( + code="APOS_005", + label="Apostrophes & contractions (5)", + text="She's here, we're late, they're waiting, and you're right.", + ), + DebugTTSSample( + code="POS_001", + label="Plural possessives (1)", + text="The dogs' bowls were empty, but the boss's office was quiet.", + ), + DebugTTSSample( + code="POS_002", + label="Plural possessives (2)", + text="The teachers' lounge was closed during the students' exams.", + ), + DebugTTSSample( + code="POS_003", + label="Plural possessives (3)", + text="The actresses' roles changed, and the directors' notes piled up.", + ), + DebugTTSSample( + code="POS_004", + label="Plural possessives (4)", + text="The Joneses' car was parked by the neighbors' fence.", + ), + DebugTTSSample( + code="POS_005", + label="Plural possessives (5)", + text="The bosses' meeting ended before the witnesses' statements began.", + ), + DebugTTSSample( + code="NUM_001", + label="Grouped numbers (1)", + text="There are 1,234 apples, 56 oranges, and 7.89 liters of juice.", + ), + DebugTTSSample( + code="NUM_002", + label="Grouped numbers (2)", + text="The population is 10,000,000 and the area is 123.45 square miles.", + ), + DebugTTSSample( + code="NUM_003", + label="Grouped numbers (3)", + text="Set the timer for 0.5 seconds, then wait 2.0 minutes.", + ), + DebugTTSSample( + code="NUM_004", + label="Grouped numbers (4)", + text="We measured 3.1415 radians and wrote down 2,718.28 as well.", + ), + DebugTTSSample( + code="NUM_005", + label="Grouped numbers (5)", + text="The sequence is 1, 2, 3, 4, 5, and then 13.", + ), + DebugTTSSample( + code="YEAR_001", + label="Years and decades (1)", + text="In 1999, people said the '90s were over.", + ), + DebugTTSSample( + code="YEAR_002", + label="Years and decades (2)", + text="In 2001, the show premiered; by 2010 it was everywhere.", + ), + DebugTTSSample( + code="YEAR_003", + label="Years and decades (3)", + text="The 1980s were loud, and the 1970s were groovy.", + ), + DebugTTSSample( + code="YEAR_004", + label="Years and decades (4)", + text="She loved the '80s, but he preferred the '60s.", + ), + DebugTTSSample( + code="YEAR_005", + label="Years and decades (5)", + text="In 2024, we looked back at 2020 and planned for 2030.", + ), + DebugTTSSample( + code="DATE_001", + label="Dates (1)", + text="On 2023-01-01, we celebrated the new year.", + ), + DebugTTSSample( + code="DATE_002", + label="Dates (2)", + text="The deadline is 1999-12-31 at midnight.", + ), + DebugTTSSample( + code="DATE_003", + label="Dates (3)", + text="Leap day happens on 2024-02-29.", + ), + DebugTTSSample( + code="DATE_004", + label="Dates (4)", + text="Some formats look like 01/02/2003 and can be ambiguous.", + ), + DebugTTSSample( + code="DATE_005", + label="Dates (5)", + text="We met on March 5, 2020 and again on Apr. 7, 2021.", + ), + DebugTTSSample( + code="CUR_001", + label="Currency symbols (1)", + text="The price is $10.50, but it was £8.00 yesterday.", + ), + DebugTTSSample( + code="CUR_002", + label="Currency symbols (2)", + text="Tickets cost €12, and the fine was $0.99.", + ), + DebugTTSSample( + code="CUR_003", + label="Currency symbols (3)", + text="The bill was ¥500 and the refund was $-3.25.", + ), + DebugTTSSample( + code="CUR_004", + label="Currency symbols (4)", + text="He paid £1,234.56 for the instrument.", + ), + DebugTTSSample( + code="CUR_005", + label="Currency symbols (5)", + text="The subscription is $5 per month, or $50 per year.", + ), + DebugTTSSample( + code="TITLE_001", + label="Titles and abbreviations (1)", + text="Dr. Smith lives on Elm St. near the U.S. border.", + ), + DebugTTSSample( + code="TITLE_002", + label="Titles and abbreviations (2)", + text="Mr. and Mrs. Doe met Prof. Adams at 5 p.m.", + ), + DebugTTSSample( + code="TITLE_003", + label="Titles and abbreviations (3)", + text="Gen. Smith spoke to Sgt. Rivera on Main St.", + ), + DebugTTSSample( + code="TITLE_004", + label="Titles and abbreviations (4)", + text="The report came from the U.K. office, not the U.S.A. team.", + ), + DebugTTSSample( + code="TITLE_005", + label="Titles and abbreviations (5)", + text="St. John's is different from St. Louis.", + ), + DebugTTSSample( + code="PUNC_001", + label="Terminal punctuation (1)", + text="This sentence ends without punctuation", + ), + DebugTTSSample( + code="PUNC_002", + label="Terminal punctuation (2)", + text="An ellipsis is already present...", + ), + DebugTTSSample( + code="PUNC_003", + label="Terminal punctuation (3)", + text="A question without a mark", + ), + DebugTTSSample( + code="PUNC_004", + label="Terminal punctuation (4)", + text="An exclamation without a bang", + ), + DebugTTSSample( + code="PUNC_005", + label="Terminal punctuation (5)", + text='A quote ends here"', + ), + DebugTTSSample( + code="QUOTE_001", + label="ALL CAPS inside quotes (1)", + text='He shouted, "THIS IS IMPORTANT!" and then whispered, "ok."', + ), + DebugTTSSample( + code="QUOTE_002", + label="ALL CAPS inside quotes (2)", + text='She said, "NO WAY", but he replied, "maybe".', + ), + DebugTTSSample( + code="QUOTE_003", + label="ALL CAPS inside quotes (3)", + text='The sign read "DO NOT ENTER" and the note read "pls knock".', + ), + DebugTTSSample( + code="QUOTE_004", + label="ALL CAPS inside quotes (4)", + text='He muttered, "OK", then yelled, "STOP!"', + ), + DebugTTSSample( + code="QUOTE_005", + label="ALL CAPS inside quotes (5)", + text='They chanted, "USA!" and someone wrote "idk".', + ), + DebugTTSSample( + code="FOOT_001", + label="Footnote indicators (1)", + text="This is a sentence with a footnote[1] and another[12].", + ), + DebugTTSSample( + code="FOOT_002", + label="Footnote indicators (2)", + text="Some books use multiple footnotes like this[2][3] in a row.", + ), + DebugTTSSample( + code="FOOT_003", + label="Footnote indicators (3)", + text="A footnote can appear mid-sentence[4] and continue afterward.", + ), + DebugTTSSample( + code="FOOT_004", + label="Footnote indicators (4)", + text="Edge cases include [0] or very large indices like [1234].", + ), + DebugTTSSample( + code="FOOT_005", + label="Footnote indicators (5)", + text="Sometimes a footnote follows punctuation.[5] Sometimes it doesn't[6]", + ), +) + + +def marker_for(code: str) -> str: + return f"{MARKER_PREFIX}{code}{MARKER_SUFFIX}" + + +def build_debug_epub(dest_path: Path, *, title: str = "abogen debug samples") -> Path: + """Create a tiny EPUB containing all debug samples. + + The text includes stable marker codes so developers can report failures + precisely. + """ + + dest_path = Path(dest_path) + dest_path.parent.mkdir(parents=True, exist_ok=True) + + chapter_lines: List[str] = [ + "", + "", + "", + "", + f" {title}", + " ", + "", + "", + f"

{title}

", + "

Each paragraph begins with a stable debug code marker.

", + ] + + for sample in DEBUG_TTS_SAMPLES: + safe_label = sample.label.replace("&", "and") + chapter_lines.append(f"

{safe_label}

") + chapter_lines.append( + "

" + marker_for(sample.code) + " " + sample.text + "

" + ) + + chapter_lines += ["", ""] + chapter_xhtml = "\n".join(chapter_lines) + + container_xml = """ + + + + + +""" + + content_opf = """ + + + abogen-debug-samples + abogen debug samples + en + + + + + + + + + +""" + + nav_xhtml = """ + + + + Navigation + + + + + + +""" + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + (tmp_path / "mimetype").write_text("application/epub+zip", encoding="utf-8") + meta_inf = tmp_path / "META-INF" + meta_inf.mkdir(parents=True, exist_ok=True) + (meta_inf / "container.xml").write_text(container_xml, encoding="utf-8") + oebps = tmp_path / "OEBPS" + oebps.mkdir(parents=True, exist_ok=True) + (oebps / "content.opf").write_text(content_opf, encoding="utf-8") + (oebps / "chapter.xhtml").write_text(chapter_xhtml, encoding="utf-8") + (oebps / "nav.xhtml").write_text(nav_xhtml, encoding="utf-8") + + # Per EPUB spec: mimetype must be the first entry and stored (no compression). + with zipfile.ZipFile(dest_path, "w") as zf: + zf.write(tmp_path / "mimetype", "mimetype", compress_type=zipfile.ZIP_STORED) + for source in (meta_inf / "container.xml", oebps / "content.opf", oebps / "chapter.xhtml", oebps / "nav.xhtml"): + arcname = str(source.relative_to(tmp_path)).replace("\\", "/") + zf.write(source, arcname, compress_type=zipfile.ZIP_DEFLATED) + + return dest_path + + +def iter_expected_codes() -> Iterable[str]: + for sample in DEBUG_TTS_SAMPLES: + yield sample.code diff --git a/abogen/entity_analysis.py b/abogen/entity_analysis.py new file mode 100644 index 0000000..34ef2d2 --- /dev/null +++ b/abogen/entity_analysis.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import hashlib +import os +import re +import threading +import time +from collections import Counter +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple + +try: # pragma: no cover - fallback when spaCy not available during tests + import spacy # type: ignore[import-not-found] +except Exception: # pragma: no cover - spaCy optional during runtime bootstrap + spacy = None + +_Language = Any # type: ignore[misc,assignment] +Doc = Any # type: ignore[misc,assignment] +Span = Any # type: ignore[misc,assignment] + + +_TITLE_PREFIXES = ( + "mr", + "mrs", + "ms", + "miss", + "dr", + "prof", + "sir", + "madam", + "lady", + "lord", + "capt", + "captain", + "col", + "colonel", + "maj", + "major", + "sgt", + "sergeant", + "rev", + "father", + "mother", + "brother", + "sister", +) + +_STOP_LABELS = { + "the", + "that", + "this", + "those", + "these", + "there", + "here", + "then", + "and", + "but", + "or", + "nor", + "so", + "yet", + "dr", + "mr", + "mrs", + "ms", + "miss", + "sir", + "madam", + "lady", + "lord", +} + +_EXCLUDED_NER_LABELS = { + "CARDINAL", + "DATE", + "ORDINAL", + "PERCENT", + "TIME", + "LAW", + "MONEY", + "QUANTITY", +} + +_TITLE_PATTERN = re.compile(r"^(?:" + "|".join(re.escape(prefix) for prefix in _TITLE_PREFIXES) + r")\.?\s+", re.IGNORECASE) +_POSSESSIVE_PATTERN = re.compile(r"(?:'s|’s|\u2019s)$", re.IGNORECASE) +_NON_WORD_PATTERN = re.compile(r"[^\w\s'-]+") +_MULTI_SPACE_PATTERN = re.compile(r"\s+") +_SUFFIX_PATTERN = re.compile( + r",?\s+(?:jr|sr|ii|iii|iv|v|vi|md|phd|esq|esquire|dds|dvm)\.?$", + re.IGNORECASE, +) + + +@dataclass(slots=True) +class EntityRecord: + key: Tuple[str, str] + label: str + kind: str + category: str + count: int = 0 + samples: List[Dict[str, Any]] = field(default_factory=list) + chapter_indices: set[int] = field(default_factory=set) + forms: Counter = field(default_factory=Counter) + first_position: Optional[Tuple[int, int]] = None + + def register(self, *, chapter_index: int, position: int, text: str, sentence: Optional[str]) -> None: + self.count += 1 + self.chapter_indices.add(chapter_index) + self.forms[text] += 1 + if self.first_position is None: + self.first_position = (chapter_index, position) + if sentence and len(self.samples) < 5: + payload = { + "excerpt": sentence.strip(), + "chapter_index": chapter_index, + } + if payload not in self.samples: + self.samples.append(payload) + + def as_dict(self, ordinal: int) -> Dict[str, Any]: + chapter_indices = sorted(self.chapter_indices) + first_chapter = chapter_indices[0] if chapter_indices else None + return { + "id": f"{self.category}_{ordinal}", + "label": self.label, + "normalized": self.key[1], + "category": self.category, + "kind": self.kind, + "count": self.count, + "samples": list(self.samples), + "chapter_indices": chapter_indices, + "first_chapter": first_chapter, + "forms": self.forms.most_common(6), + } + + +@dataclass(slots=True) +class EntityExtractionResult: + summary: Dict[str, Any] + cache_key: str + elapsed: float + errors: List[str] + + +class EntityModelError(RuntimeError): + pass + + +_MODEL_CACHE: Dict[str, Any] = {} +_MODEL_LOCK = threading.RLock() + + +def _resolve_model_name(language: str) -> str: + override = os.environ.get("ABOGEN_SPACY_MODEL") + if override: + return override.strip() + lowered = language.strip().lower() + if lowered.startswith("en"): + return "en_core_web_sm" + return "en_core_web_sm" + + +def _load_model(language: str) -> Any: + if spacy is None: + raise EntityModelError("spaCy is not available. Install spaCy to enable entity extraction.") + + model_name = _resolve_model_name(language) + cache_key = model_name.lower() + with _MODEL_LOCK: + if cache_key in _MODEL_CACHE: + return _MODEL_CACHE[cache_key] + try: + nlp = spacy.load(model_name) # type: ignore[arg-type] + except OSError as exc: # pragma: no cover - external dependency failure + raise EntityModelError( + f"spaCy model '{model_name}' is not installed. Download it with " + "`python -m spacy download en_core_web_sm`." + ) from exc + nlp.max_length = max(nlp.max_length, 2_000_000) + _MODEL_CACHE[cache_key] = nlp + return nlp + + +def _normalize_label(text: str) -> str: + if not text: + return "" + stripped = text.strip().strip("\"'`“”’") + if not stripped: + return "" + stripped = _TITLE_PATTERN.sub("", stripped) + stripped = _SUFFIX_PATTERN.sub("", stripped) + stripped = _POSSESSIVE_PATTERN.sub("", stripped) + stripped = _NON_WORD_PATTERN.sub(" ", stripped) + stripped = _MULTI_SPACE_PATTERN.sub(" ", stripped) + stripped = stripped.strip() + if not stripped or stripped.lower() in _STOP_LABELS: + return "" + parts = stripped.split() + if not parts: + return "" + if len(parts) == 1 and len(parts[0]) <= 1: + return "" + # Normalise casing: preserve uppercase abbreviations, otherwise title case. + normalized_parts = [] + for index, part in enumerate(parts): + if part.isupper(): + normalized_parts.append(part) + elif part[:1].isupper(): + normalized_parts.append(part[:1].upper() + part[1:]) + elif index == 0: + normalized_parts.append(part[:1].upper() + part[1:]) + else: + normalized_parts.append(part) + normalized = " ".join(normalized_parts).strip() + if normalized.lower() in _STOP_LABELS: + return "" + return normalized + + +def _token_key(value: str) -> str: + return _MULTI_SPACE_PATTERN.sub(" ", value.lower().strip()).strip() + + +def _iter_named_entities(doc: Any) -> Iterable[Any]: # type: ignore[override] + for ent in getattr(doc, "ents", ()): + if ent.label_ == "": + continue + yield ent + + +def _extract_propn_tokens(doc: Any) -> Iterable[Any]: # type: ignore[override] + seen: set[Tuple[int, int]] = set() + for ent in getattr(doc, "ents", ()): # guard multi-token spans + seen.add((ent.start, ent.end)) + for token in doc: + if token.pos_ != "PROPN": + continue + span_key = (token.i, token.i + 1) + if span_key in seen: + continue + if token.is_stop: + continue + text = token.text.strip() + if not text: + continue + if token.ent_type_: + continue + yield doc[token.i : token.i + 1] + + +def _empty_result(cache_key: str, error: Optional[str] = None) -> EntityExtractionResult: + payload = { + "people": [], + "entities": [], + "index": {"tokens": []}, + "stats": { + "tokens": 0, + "chapters": 0, + "processed": False, + }, + "model": None, + } + errors = [error] if error else [] + return EntityExtractionResult(summary=payload, cache_key=cache_key, elapsed=0.0, errors=errors) + + +def extract_entities( + chapters: Iterable[Mapping[str, Any]], + *, + language: str = "en", +) -> EntityExtractionResult: + start = time.perf_counter() + normalized_language = language or "en" + combined_hasher = hashlib.sha1() + chapter_texts: List[Tuple[int, str]] = [] + for idx, chapter in enumerate(chapters): + text = chapter.get("text") if isinstance(chapter, Mapping) else None + text_value = str(text or "") + original_index = idx + if isinstance(chapter, Mapping): + try: + original_index = int(chapter.get("index", idx)) + except (TypeError, ValueError): + original_index = idx + chapter_texts.append((original_index, text_value)) + if text_value: + combined_hasher.update(text_value.encode("utf-8", "ignore")) + combined_hasher.update(str(original_index).encode("utf-8", "ignore")) + cache_key = combined_hasher.hexdigest() + + if not chapter_texts: + return _empty_result(cache_key) + + try: + nlp = _load_model(normalized_language) + except EntityModelError as exc: + return _empty_result(cache_key, str(exc)) + + records: Dict[Tuple[str, str], EntityRecord] = {} + tokens_for_index: Dict[str, Dict[str, Any]] = {} + processed_tokens = 0 + + for chapter_index, text in chapter_texts: + trimmed = text.strip() + if not trimmed: + continue + if len(trimmed) + 1024 > nlp.max_length: + nlp.max_length = len(trimmed) + 1024 + doc = nlp(trimmed) + + def _register_span(span: Any, category_hint: Optional[str] = None) -> None: + nonlocal processed_tokens + if category_hint is None and span.label_ in _EXCLUDED_NER_LABELS: + return + cleaned = _normalize_label(span.text) + if not cleaned: + return + key = _token_key(cleaned) + if not key: + return + category = category_hint or ("people" if span.label_ == "PERSON" else "entities") + record_key = (category, key) + record = records.get(record_key) + if record is None: + record = EntityRecord( + key=record_key, + label=cleaned, + kind=span.label_ or ("PROPN" if category == "entities" else "PERSON"), + category=category, + ) + records[record_key] = record + sentence = span.sent.text if hasattr(span, "sent") and span.sent is not None else None + record.register( + chapter_index=chapter_index, + position=span.start, + text=span.text, + sentence=sentence, + ) + processed_tokens += 1 + index_entry = tokens_for_index.get(key) + if index_entry is None: + index_entry = { + "token": record.label, + "normalized": key, + "category": category, + "count": 0, + "samples": [], + } + tokens_for_index[key] = index_entry + index_entry["count"] += 1 + if sentence and len(index_entry["samples"]) < 3: + if sentence not in index_entry["samples"]: + index_entry["samples"].append(sentence) + + for ent in _iter_named_entities(doc): + _register_span(ent) + + for span in _extract_propn_tokens(doc): + _register_span(span, category_hint="entities") + + elapsed = time.perf_counter() - start + + people_records = [record for record in records.values() if record.category == "people"] + people_keys = {record.key[1] for record in people_records} + entity_records = [ + record + for record in records.values() + if record.category == "entities" + and record.key[1] not in people_keys + and record.kind != "PERSON" + ] + + people_records.sort(key=lambda rec: (-rec.count, rec.label)) + entity_records.sort(key=lambda rec: (-rec.count, rec.label)) + + people_payload = [record.as_dict(index + 1) for index, record in enumerate(people_records)] + entity_payload = [record.as_dict(index + 1) for index, record in enumerate(entity_records)] + + index_payload = sorted(tokens_for_index.values(), key=lambda item: (-item["count"], item["token"])) + + summary = { + "people": people_payload, + "entities": entity_payload, + "index": {"tokens": index_payload}, + "stats": { + "tokens": processed_tokens, + "chapters": len(chapter_texts), + "processed": True, + "people": len(people_payload), + "entities": len(entity_payload), + }, + "model": { + "name": getattr(nlp, "meta", {}).get("name", "unknown"), + "version": getattr(nlp, "meta", {}).get("version", "unknown"), + "lang": getattr(nlp, "meta", {}).get("lang", normalized_language), + }, + } + + return EntityExtractionResult(summary=summary, cache_key=cache_key, elapsed=elapsed, errors=[]) + + +def search_tokens(index: Mapping[str, Any], query: str, *, limit: int = 15) -> List[Dict[str, Any]]: + tokens = index.get("tokens") if isinstance(index, Mapping) else None + if not isinstance(tokens, list) or not query: + return [] + normalized = query.strip().lower() + if not normalized: + return tokens[:limit] + results: List[Dict[str, Any]] = [] + for entry in tokens: + token_label = str(entry.get("token", "")) + normalized_label = token_label.lower() + if normalized in normalized_label or normalized in str(entry.get("normalized", "")): + results.append(entry) + if len(results) >= limit: + break + return results + + +def merge_override(summary: Mapping[str, Any], overrides: Mapping[str, Mapping[str, Any]]) -> Dict[str, Any]: + if not isinstance(summary, Mapping): + return {"people": [], "entities": []} + merged_summary: Dict[str, Any] = dict(summary) + for key in ("people", "entities"): + items = summary.get(key) + if not isinstance(items, list): + continue + merged_items: List[Dict[str, Any]] = [] + for entry in items: + if not isinstance(entry, Mapping): + continue + normalized = _token_key(str(entry.get("normalized") or entry.get("label") or "")) + merged = dict(entry) + if normalized and normalized in overrides: + merged_override = dict(overrides[normalized]) + merged["override"] = merged_override + merged_items.append(merged) + merged_summary[key] = merged_items + return merged_summary + + +def normalize_token(token: str) -> str: + return _token_key(_normalize_label(token)) + + +def normalize_manual_override_token(token: str) -> str: + if not token: + return "" + stripped = token.strip().strip("\"'`“”’") + if not stripped: + return "" + return _MULTI_SPACE_PATTERN.sub(" ", stripped.lower()).strip() diff --git a/abogen/epub3/__init__.py b/abogen/epub3/__init__.py new file mode 100644 index 0000000..7683764 --- /dev/null +++ b/abogen/epub3/__init__.py @@ -0,0 +1,3 @@ +from .exporter import EPUB3PackageBuilder, build_epub3_package + +__all__ = ["EPUB3PackageBuilder", "build_epub3_package"] diff --git a/abogen/epub3/exporter.py b/abogen/epub3/exporter.py new file mode 100644 index 0000000..834c783 --- /dev/null +++ b/abogen/epub3/exporter.py @@ -0,0 +1,910 @@ +from __future__ import annotations + +import html +import re +import shutil +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any, Dict, Iterable, List, Optional, Pattern, Sequence, Tuple +import zipfile + +from abogen.text_extractor import ExtractedChapter, ExtractionResult + + +@dataclass(slots=True) +class ChunkOverlay: + id: str + text: str + original_text: Optional[str] + start: Optional[float] + end: Optional[float] + speaker_id: str + voice: Optional[str] + level: Optional[str] = None + group_id: Optional[str] = None + + +@dataclass(slots=True) +class ChapterDocument: + index: int # zero-based + title: str + xhtml_name: str + smil_name: str + chunks: List[ChunkOverlay] + start: Optional[float] + end: Optional[float] + + +class EPUB3PackageBuilder: + """Constructs an EPUB 3 package with media overlays.""" + + def __init__( + self, + *, + output_path: Path, + book_id: str, + extraction: ExtractionResult, + metadata_tags: Dict[str, Any], + chapter_markers: Sequence[Dict[str, Any]], + chunk_markers: Sequence[Dict[str, Any]], + chunks: Iterable[Dict[str, Any]], + audio_path: Path, + speaker_mode: str = "single", + cover_image_path: Optional[Path] = None, + cover_image_mime: Optional[str] = None, + ) -> None: + self.output_path = output_path + self.book_id = book_id or str(uuid.uuid4()) + self.extraction = extraction + self.metadata_tags = _normalize_metadata(metadata_tags) + self.chapter_markers = list(chapter_markers or []) + self.chunk_markers = list(chunk_markers or []) + self.chunks = list(chunks or []) + self.audio_path = audio_path + self.speaker_mode = speaker_mode or "single" + self.cover_image_path = cover_image_path if cover_image_path and cover_image_path.exists() else None + self.cover_image_mime = cover_image_mime + + self._combined_metadata = _combine_metadata(extraction.metadata, self.metadata_tags) + self._title = self._combined_metadata.get("title") or self._fallback_title() + self._authors = _split_authors(self._combined_metadata) + self._language = self._determine_language() + self._publisher = self._combined_metadata.get("publisher") or "" + self._description = self._combined_metadata.get("comment") + self._duration = _calculate_total_duration(self.chunk_markers, self.chapter_markers) + self._modified = _utc_now_iso() + + def build(self) -> Path: + if not self.audio_path or not self.audio_path.exists(): + raise FileNotFoundError(f"Audio asset missing: {self.audio_path}") + + chapter_documents = self._build_chapter_documents() + + with TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + oebps = root / "OEBPS" + text_dir = oebps / "text" + smil_dir = oebps / "smil" + audio_dir = oebps / "audio" + image_dir = oebps / "images" + stylesheet_dir = oebps / "styles" + + for directory in (oebps, text_dir, smil_dir, audio_dir, stylesheet_dir): + directory.mkdir(parents=True, exist_ok=True) + if self.cover_image_path: + image_dir.mkdir(parents=True, exist_ok=True) + + _write_mimetype(root) + _write_container_xml(root) + + audio_filename = self.audio_path.name + embedded_audio = audio_dir / audio_filename + shutil.copy2(self.audio_path, embedded_audio) + + if self.cover_image_path: + shutil.copy2(self.cover_image_path, image_dir / self.cover_image_path.name) + + stylesheet_path = stylesheet_dir / "style.css" + stylesheet_path.write_text(_DEFAULT_STYLESHEET, encoding="utf-8") + + for chapter in chapter_documents: + chapter_path = text_dir / chapter.xhtml_name + chapter_path.write_text( + self._render_chapter_xhtml(chapter), + encoding="utf-8", + ) + smil_path = smil_dir / chapter.smil_name + smil_path.write_text( + self._render_chapter_smil(chapter, f"audio/{audio_filename}"), + encoding="utf-8", + ) + + nav_path = oebps / "nav.xhtml" + nav_path.write_text(self._render_nav(chapter_documents), encoding="utf-8") + + opf_path = oebps / "content.opf" + opf_path.write_text( + self._render_opf( + chapter_documents, + audio_filename, + has_cover=self.cover_image_path is not None, + stylesheet_path=stylesheet_path.relative_to(oebps), + ), + encoding="utf-8", + ) + + self.output_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(self.output_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + # Ensure mimetype is the first entry and stored without compression + mimetype_path = root / "mimetype" + info = zipfile.ZipInfo("mimetype") + info.compress_type = zipfile.ZIP_STORED + archive.writestr(info, mimetype_path.read_bytes()) + + for file_path in sorted(root.rglob("*")): + if file_path == mimetype_path or file_path.is_dir(): + continue + archive.write(file_path, file_path.relative_to(root)) + + return self.output_path + + # ------------------------------------------------------------------ + def _build_chapter_documents(self) -> List[ChapterDocument]: + chunk_lookup = _build_chunk_lookup(self.chunks) + markers_by_chapter = _group_markers_by_chapter(self.chunk_markers) + chapter_meta = {int(entry.get("index", idx + 1)) - 1: dict(entry) for idx, entry in enumerate(self.chapter_markers)} + + documents: List[ChapterDocument] = [] + for chapter_index, chapter in enumerate(self.extraction.chapters): + markers = markers_by_chapter.get(chapter_index, []) + if not markers and chunk_lookup.by_chapter.get(chapter_index): + markers = [ + { + "id": item.get("id"), + "chapter_index": chapter_index, + "chunk_index": item.get("chunk_index"), + "start": None, + "end": None, + "speaker_id": item.get("speaker_id", "narrator"), + "voice": item.get("voice"), + } + for item in chunk_lookup.by_chapter.get(chapter_index, []) + ] + + if not markers: + markers = [ + { + "id": f"chap{chapter_index:04d}_auto0000", + "chapter_index": chapter_index, + "chunk_index": 0, + "start": chapter_meta.get(chapter_index, {}).get("start"), + "end": chapter_meta.get(chapter_index, {}).get("end"), + "speaker_id": "narrator", + "voice": None, + } + ] + + overlays = self._build_overlays_for_chapter( + chapter_index, + markers, + chunk_lookup, + ) + + xhtml_name = f"chapter_{chapter_index + 1:04d}.xhtml" + smil_name = f"chapter_{chapter_index + 1:04d}.smil" + + chapter_start = chapter_meta.get(chapter_index, {}).get("start") + chapter_end = chapter_meta.get(chapter_index, {}).get("end") + + documents.append( + ChapterDocument( + index=chapter_index, + title=chapter.title or f"Chapter {chapter_index + 1}", + xhtml_name=xhtml_name, + smil_name=smil_name, + chunks=overlays, + start=chapter_start, + end=chapter_end, + ) + ) + + return documents + + def _build_overlays_for_chapter( + self, + chapter_index: int, + markers: Sequence[Dict[str, Any]], + chunk_lookup: "ChunkLookup", + ) -> List[ChunkOverlay]: + overlays: List[ChunkOverlay] = [] + used_ids: set[str] = set() + + chapter_chunks = list(chunk_lookup.by_chapter.get(chapter_index, [])) + chapter_chunks.sort(key=lambda entry: _safe_int(entry.get("chunk_index"))) + + for position, marker in enumerate(markers): + chunk_id = marker.get("id") + chunk_entry = None + if chunk_id and chunk_id in chunk_lookup.by_id: + chunk_entry = chunk_lookup.by_id[chunk_id] + else: + candidate_index = _safe_int(marker.get("chunk_index")) + chunk_entry = _find_chunk_by_index(chapter_chunks, candidate_index) + if chunk_entry is None and chapter_chunks and position < len(chapter_chunks): + chunk_entry = chapter_chunks[position] + + level = None + if chunk_entry is None: + text = self.extraction.chapters[chapter_index].text + speaker_id = str(marker.get("speaker_id") or "narrator") + voice = marker.get("voice") + else: + display_text = chunk_entry.get("display_text") + text = str(chunk_entry.get("text") or "") + speaker_id = str(chunk_entry.get("speaker_id") or marker.get("speaker_id") or "narrator") + voice = chunk_entry.get("voice") or chunk_entry.get("resolved_voice") or marker.get("voice") + level = chunk_entry.get("level") or None + if chunk_entry is None: + level = None + + normalized_id = _normalize_chunk_id(chunk_id) if chunk_id else None + if not normalized_id: + normalized_id = f"chap{chapter_index:04d}_chunk{position:04d}" + while normalized_id in used_ids: + normalized_id = f"{normalized_id}_dup" + used_ids.add(normalized_id) + + raw_group_key = chunk_entry.get("id") if chunk_entry else chunk_id + group_id = _derive_group_id(raw_group_key, level) + normalized_group_id = _normalize_chunk_id(group_id) if group_id else None + + original_text = None + if chunk_entry is not None: + original_text = chunk_entry.get("original_text") or chunk_entry.get("display_text") + + overlays.append( + ChunkOverlay( + id=normalized_id, + text=text or self.extraction.chapters[chapter_index].text, + original_text=str(original_text) if original_text is not None else None, + start=_safe_float(marker.get("start")), + end=_safe_float(marker.get("end")), + speaker_id=speaker_id, + voice=str(voice) if voice else None, + level=str(level) if level else None, + group_id=normalized_group_id, + ) + ) + + chapter_text = "" + if 0 <= chapter_index < len(self.extraction.chapters): + chapter_entry = self.extraction.chapters[chapter_index] + chapter_text = getattr(chapter_entry, "text", "") or "" + + _restore_original_chunk_text(chapter_text, overlays) + + return overlays + + def _render_chapter_xhtml(self, chapter: ChapterDocument) -> str: + language = html.escape(self._language or "en") + title = html.escape(chapter.title) + grouped_chunks = _group_chunks_for_render(chapter.chunks) + chunk_html = "\n".join( + _render_chunk_group_html(group_id, items) for group_id, items in grouped_chunks + ) + if not chunk_html: + chunk_html = "

" + original_block = "" + if chapter.chunks: + original_text = "".join((chunk.original_text if chunk.original_text is not None else (chunk.text or "")) for chunk in chapter.chunks) + if original_text: + safe_original = html.escape(original_text) + original_block = ( + " " + ) + + return ( + "\n" + "\n" + " \n" + " {title}\n" + " \n" + " \n" + " \n" + " \n" + "
\n" + "

{title}

\n" + " {chunks}\n" + "{original_block}" + "
\n" + " \n" + "\n" + ).format( + lang=language, + title=title, + index=chapter.index + 1, + chunks=chunk_html, + original_block=("" if not original_block else f"{original_block}\n"), + ) + + def _render_chapter_smil(self, chapter: ChapterDocument, audio_href: str) -> str: + par_lines = [] + for chunk in chapter.chunks: + par_lines.append( + " \n" + " \n" + " ".format( + chunk_id=html.escape(chunk.id), + xhtml=html.escape(chapter.xhtml_name), + audio=html.escape(audio_href), + start=_format_smil_time(chunk.start), + end=_format_smil_time(chunk.end), + ) + ) + + return ( + "\n" + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "{pars}\n" + " \n" + " \n" + "\n" + ).format( + title=html.escape(chapter.title), + book_id=html.escape(self.book_id), + index=chapter.index + 1, + xhtml=html.escape(chapter.xhtml_name), + pars="\n".join(par_lines) if par_lines else " ", + ) + + def _render_nav(self, chapters: Sequence[ChapterDocument]) -> str: + items = [] + for chapter in chapters: + href = f"text/{chapter.xhtml_name}" + items.append( + "
  • {title}
  • ".format( + href=html.escape(href), + title=html.escape(chapter.title), + ) + ) + + return ( + "\n" + "\n" + " \n" + " Navigation\n" + " \n" + " \n" + " \n" + " \n" + " \n" + "\n" + ).format( + lang=html.escape(self._language or "en"), + title=html.escape(self._title), + items="\n".join(items) if items else "
  • Chapter 1
  • ", + ) + + def _render_opf( + self, + chapters: Sequence[ChapterDocument], + audio_filename: str, + *, + has_cover: bool, + stylesheet_path: Path, + ) -> str: + manifest_items = [] + spine_refs = [] + for chapter in chapters: + item_id = f"chap{chapter.index + 1:04d}" + overlay_id = f"mo-{chapter.index + 1:04d}" + manifest_items.append( + " ".format( + item_id=item_id, + href=html.escape(chapter.xhtml_name), + overlay_id=overlay_id, + ) + ) + manifest_items.append( + " ".format( + overlay_id=overlay_id, + smil=html.escape(chapter.smil_name), + ) + ) + spine_refs.append(f" ") + + audio_item_id = "primary-audio" + manifest_items.append( + " ".format( + item_id=audio_item_id, + href=html.escape(audio_filename), + mime=_detect_audio_mime(audio_filename), + ) + ) + + manifest_items.append( + " " + ) + + manifest_items.append( + " ".format( + href=html.escape(str(stylesheet_path).replace("\\", "/")), + ) + ) + + if has_cover and self.cover_image_path: + cover_id = "cover-image" + manifest_items.append( + " ".format( + item_id=cover_id, + href=html.escape(self.cover_image_path.name), + mime=self.cover_image_mime or _detect_image_mime(self.cover_image_path.suffix), + ) + ) + + metadata_elements = _render_metadata_xml( + self._title, + self._authors, + self._language, + self.book_id, + duration=self._duration, + publisher=self._publisher, + description=self._description, + speaker_mode=self.speaker_mode, + modified=self._modified, + ) + + return ( + "\n" + "\n" + " \n" + "{metadata}\n" + " \n" + " \n" + "{manifest}\n" + " \n" + " \n" + "{spine}\n" + " \n" + "\n" + ).format( + metadata="\n".join(metadata_elements), + manifest="\n".join(manifest_items), + spine="\n".join(spine_refs) if spine_refs else " ", + ) + + def _fallback_title(self) -> str: + if self.extraction.chapters: + first_title = self.extraction.chapters[0].title + if first_title: + return first_title + return "Generated Audiobook" + + def _determine_language(self) -> str: + language = self._combined_metadata.get("language") + if language: + return language + return "en" + + +def build_epub3_package( + *, + output_path: Path, + book_id: str, + extraction: ExtractionResult, + metadata_tags: Dict[str, Any], + chapter_markers: Sequence[Dict[str, Any]], + chunk_markers: Sequence[Dict[str, Any]], + chunks: Iterable[Dict[str, Any]], + audio_path: Path, + speaker_mode: str = "single", + cover_image_path: Optional[Path] = None, + cover_image_mime: Optional[str] = None, +) -> Path: + builder = EPUB3PackageBuilder( + output_path=output_path, + book_id=book_id, + extraction=extraction, + metadata_tags=metadata_tags, + chapter_markers=chapter_markers, + chunk_markers=chunk_markers, + chunks=chunks, + audio_path=audio_path, + speaker_mode=speaker_mode, + cover_image_path=cover_image_path, + cover_image_mime=cover_image_mime, + ) + return builder.build() + + +# --------------------------------------------------------------------------- +# Helpers + + +@dataclass +class ChunkLookup: + by_id: Dict[str, Dict[str, Any]] + by_chapter: Dict[int, List[Dict[str, Any]]] + + +def _normalize_metadata(metadata: Optional[Dict[str, Any]]) -> Dict[str, str]: + normalized: Dict[str, str] = {} + for key, value in (metadata or {}).items(): + if value is None: + continue + normalized[str(key).lower()] = str(value) + return normalized + + +def _combine_metadata(*sources: Dict[str, Any]) -> Dict[str, str]: + combined: Dict[str, str] = {} + for source in sources: + for key, value in (source or {}).items(): + if value is None: + continue + combined[str(key).lower()] = str(value) + return combined + + +def _split_authors(metadata: Dict[str, str]) -> List[str]: + candidates = [] + for key in ("artist", "author", "authors", "album_artist", "creator"): + value = metadata.get(key) + if value: + candidates.extend(part.strip() for part in value.replace(";", ",").split(",")) + return [author for author in candidates if author] + + +def _calculate_total_duration( + chunk_markers: Sequence[Dict[str, Any]], + chapter_markers: Sequence[Dict[str, Any]], +) -> Optional[float]: + candidates: List[float] = [] + for marker in chunk_markers or []: + end_value = _safe_float(marker.get("end")) + if end_value is not None: + candidates.append(end_value) + for marker in chapter_markers or []: + end_value = _safe_float(marker.get("end")) + if end_value is not None: + candidates.append(end_value) + if not candidates: + return None + return max(candidates) + + +def _write_mimetype(root: Path) -> None: + (root / "mimetype").write_text("application/epub+zip", encoding="utf-8") + + +def _write_container_xml(root: Path) -> None: + meta_inf = root / "META-INF" + meta_inf.mkdir(parents=True, exist_ok=True) + container = meta_inf / "container.xml" + container.write_text( + ( + "\n" + "\n" + " \n" + " \n" + " \n" + "\n" + ), + encoding="utf-8", + ) + + +def _build_chunk_lookup(chunks: Iterable[Dict[str, Any]]) -> ChunkLookup: + by_id: Dict[str, Dict[str, Any]] = {} + by_chapter: Dict[int, List[Dict[str, Any]]] = {} + for entry in chunks or []: + if not isinstance(entry, dict): + continue + chunk_id = entry.get("id") + if chunk_id: + by_id[str(chunk_id)] = dict(entry) + chapter_index = _safe_int(entry.get("chapter_index")) + by_chapter.setdefault(chapter_index, []).append(dict(entry)) + return ChunkLookup(by_id=by_id, by_chapter=by_chapter) + + +def _group_markers_by_chapter(markers: Iterable[Dict[str, Any]]) -> Dict[int, List[Dict[str, Any]]]: + grouped: Dict[int, List[Dict[str, Any]]] = {} + for entry in markers or []: + if not isinstance(entry, dict): + continue + chapter_index = _safe_int(entry.get("chapter_index")) + grouped.setdefault(chapter_index, []).append(dict(entry)) + for chapter_index, items in grouped.items(): + items.sort(key=lambda payload: (_safe_int(payload.get("chunk_index")), _safe_float(payload.get("start")) or 0.0)) + return grouped + + +def _find_chunk_by_index( + chapter_chunks: Sequence[Dict[str, Any]], + chunk_index: Optional[int], +) -> Optional[Dict[str, Any]]: + if chunk_index is None: + return None + for entry in chapter_chunks: + if _safe_int(entry.get("chunk_index")) == chunk_index: + return entry + return None + + +def _normalize_chunk_id(chunk_id: Optional[Any]) -> Optional[str]: + if chunk_id is None: + return None + text = str(chunk_id).strip() + if not text: + return None + safe = "".join(ch if ch.isalnum() or ch in {"_", "-"} else "_" for ch in text) + return safe[:120] + + +def _derive_group_id(chunk_id: Optional[Any], level: Optional[Any]) -> Optional[str]: + if chunk_id is None: + return None + text = str(chunk_id).strip() + if not text: + return None + if str(level or "").lower() == "sentence": + match = re.match(r"(.+?)_s\d+(?:_.*)?$", text) + if match: + return match.group(1) + return text + + +def _group_chunks_for_render(chunks: Sequence[ChunkOverlay]) -> List[Tuple[Optional[str], List[ChunkOverlay]]]: + groups: List[Tuple[Optional[str], List[ChunkOverlay]]] = [] + current_key: Optional[str] = None + current_items: List[ChunkOverlay] = [] + + for chunk in chunks: + key = chunk.group_id or chunk.id + if current_items and key != current_key: + groups.append((current_key, current_items)) + current_items = [] + if not current_items: + current_key = key + current_items.append(chunk) + + if current_items: + groups.append((current_key, current_items)) + + return groups + + +def _render_chunk_inline(chunk: ChunkOverlay) -> str: + escaped_id = html.escape(chunk.id) + speaker_attr = f" data-speaker=\"{html.escape(chunk.speaker_id)}\"" if chunk.speaker_id else "" + voice_attr = f" data-voice=\"{html.escape(chunk.voice)}\"" if chunk.voice else "" + level_attr = f" data-level=\"{html.escape(chunk.level)}\"" if chunk.level else "" + raw_text = chunk.text or "" + escaped_text = html.escape(raw_text) + if not escaped_text: + escaped_text = " " + return ( + f"" + f"{escaped_text}" + "" + ) + + +def _render_chunk_group_html(group_id: Optional[str], chunks: Sequence[ChunkOverlay]) -> str: + if not chunks: + return "" + group_attr = f" data-group=\"{html.escape(group_id)}\"" if group_id else "" + inline_html = "".join(_render_chunk_inline(chunk) for chunk in chunks) + if not inline_html: + inline_html = " " + return f"

    {inline_html}

    " + + +def _format_smil_time(value: Optional[float]) -> str: + if value is None or value < 0: + value = 0.0 + total_ms = int(round(value * 1000)) + hours, remainder = divmod(total_ms, 3600_000) + minutes, remainder = divmod(remainder, 60_000) + seconds, milliseconds = divmod(remainder, 1000) + return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{milliseconds:03d}" + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _safe_float(value: Any) -> Optional[float]: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _restore_original_chunk_text(chapter_text: str, overlays: List[ChunkOverlay]) -> None: + if not chapter_text or not overlays: + return + + cursor = 0 + for chunk in overlays: + if chunk.original_text is not None: + prepared = _prepare_display_text(chunk.original_text) + chunk.text = prepared + continue + candidate = chunk.text or "" + if not candidate: + continue + match = _search_original_span(chapter_text, candidate, cursor) + if match is None and cursor: + match = _search_original_span(chapter_text, candidate, 0) + if match is None: + if chunk.original_text is None: + chunk.original_text = chunk.text + chunk.text = _prepare_display_text(chunk.text or "") + continue + start, end = match + segment = chapter_text[start:end] + chunk.original_text = segment + chunk.text = _prepare_display_text(segment) + cursor = end + + +def _prepare_display_text(value: str) -> str: + if not value: + return "" + cleaned = re.sub(r"(?:[ \t]*\r?\n)+\Z", "", value) + return cleaned if cleaned else "" + + +def _search_original_span(source: str, normalized: str, start: int) -> Optional[Tuple[int, int]]: + if not normalized: + return None + pattern = _build_chunk_pattern(normalized) + match = pattern.search(source, start) + if not match: + return None + return match.start(1), match.end(1) + + +_CHUNK_REGEX_CACHE: Dict[str, Pattern[str]] = {} + + +def _build_chunk_pattern(text: str) -> Pattern[str]: + cached = _CHUNK_REGEX_CACHE.get(text) + if cached is not None: + return cached + escaped = re.escape(text) + escaped = escaped.replace(r"\ ", r"\s+") + pattern = re.compile(r"(\s*" + escaped + r"\s*)", re.DOTALL) + _CHUNK_REGEX_CACHE[text] = pattern + return pattern + + +def _render_metadata_xml( + title: str, + authors: Sequence[str], + language: str, + book_id: str, + *, + duration: Optional[float], + publisher: Optional[str], + description: Optional[str], + speaker_mode: Optional[str], + modified: Optional[str], +) -> List[str]: + elements = [ + f" {html.escape(book_id)}", + f" {html.escape(title)}", + f" {html.escape(language or 'en')}", + ] + + for author in authors or ["Unknown"]: + elements.append(f" {html.escape(author)}") + + if publisher: + elements.append(f" {html.escape(publisher)}") + + if description: + elements.append(f" {html.escape(description)}") + + if duration is not None: + elements.append(f" {_format_iso_duration(duration)}") + + if speaker_mode: + elements.append( + " {}".format( + html.escape(str(speaker_mode)) + ) + ) + + if modified: + elements.append(f" {html.escape(modified)}") + return elements + + +def _format_iso_duration(value: float) -> str: + total_seconds = int(value) + remainder = value - total_seconds + hours, remainder_seconds = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder_seconds, 60) + seconds_with_fraction = seconds + remainder + if seconds_with_fraction.is_integer(): + seconds_text = f"{int(seconds_with_fraction)}" + else: + seconds_text = f"{seconds_with_fraction:.3f}".rstrip("0").rstrip(".") + return f"PT{hours}H{minutes}M{seconds_text}S" + + +def _detect_audio_mime(audio_filename: str) -> str: + suffix = Path(audio_filename).suffix.lower() + return { + ".mp3": "audio/mpeg", + ".m4a": "audio/mp4", + ".m4b": "audio/mp4", + ".aac": "audio/aac", + ".wav": "audio/wav", + ".flac": "audio/flac", + ".ogg": "audio/ogg", + ".opus": "audio/ogg", + }.get(suffix, "audio/mpeg") + + +def _detect_image_mime(suffix: str) -> str: + normalized = suffix.lower() + return { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + }.get(normalized, "image/jpeg") + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +_DEFAULT_STYLESHEET = """ +body { + font-family: 'Georgia', serif; + line-height: 1.6; + margin: 1.5em; +} + +h1 { + font-size: 1.5em; + margin-bottom: 0.5em; +} + +.chunk-group { + margin: 0.5em 0; +} + +.chunk-group .chunk { + white-space: pre-wrap; +} +""" diff --git a/abogen/gui.py b/abogen/gui.py index 95a779a..a023b17 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -1,4049 +1,11 @@ -import os -import time -import sys -import tempfile -import platform -import base64 -import re -from abogen.queue_manager_gui import QueueManager -from abogen.queued_item import QueuedItem -import abogen.hf_tracker as hf_tracker -import hashlib # Added for cache path generation -from PyQt6.QtWidgets import ( - QApplication, - QWidget, - QVBoxLayout, - QHBoxLayout, - QPushButton, - QComboBox, - QTextEdit, - QLabel, - QSlider, - QMessageBox, - QFileDialog, - QProgressBar, - QFrame, - QStyleFactory, - QInputDialog, - QFileIconProvider, - QSizePolicy, - QDialog, - QCheckBox, - QMenu, -) -from PyQt6.QtGui import QAction, QActionGroup -from PyQt6.QtCore import ( - Qt, - QUrl, - QPoint, - QFileInfo, - QThread, - pyqtSignal, - QObject, - QBuffer, - QIODevice, - QSize, - QTimer, - QEvent, - QProcess, -) -from PyQt6.QtGui import ( - QTextCursor, - QDesktopServices, - QIcon, - QPixmap, - QPainter, - QPolygon, - QColor, - QMovie, - QPalette, -) -from abogen.utils import ( - load_config, - save_config, - get_gpu_acceleration, - prevent_sleep_start, - prevent_sleep_end, - get_resource_path, - get_user_cache_path, - LoadPipelineThread, -) +"""Backwards-compatible re-export of the PyQt GUI. -from abogen.subtitle_utils import ( - clean_text, - calculate_text_length, -) - -from abogen.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread -from abogen.book_handler import HandlerDialog -from abogen.constants import ( - PROGRAM_NAME, - VERSION, - GITHUB_URL, - PROGRAM_DESCRIPTION, - LANGUAGE_DESCRIPTIONS, - VOICES_INTERNAL, - SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, - COLORS, - SUBTITLE_FORMATS, -) -import threading -from abogen.voice_formula_gui import VoiceFormulaDialog -from abogen.voice_profiles import load_profiles - -# Import ctypes for Windows-specific taskbar icon -if platform.system() == "Windows": - import ctypes - - -class DarkTitleBarEventFilter(QObject): - def __init__(self, is_windows, get_dark_mode_func, set_title_bar_dark_mode_func): - super().__init__() - self.is_windows = is_windows - self.get_dark_mode = get_dark_mode_func - self.set_title_bar_dark_mode = set_title_bar_dark_mode_func - - def eventFilter(self, obj, event): - if event.type() == QEvent.Type.Show: - # Only apply to QWidget windows - if isinstance(obj, QWidget) and obj.isWindow(): - if self.is_windows and self.get_dark_mode(): - self.set_title_bar_dark_mode(obj, True) - return super().eventFilter(obj, event) - - -class ShowWarningSignalEmitter(QObject): # New class to handle signal emission - show_warning_signal = pyqtSignal(str, str) - - def emit(self, title, message): - self.show_warning_signal.emit(title, message) - - -class ThreadSafeLogSignal(QObject): - log_signal = pyqtSignal(object) - - def __init__(self, parent=None): - super().__init__(parent) - - def emit_log(self, message): - self.log_signal.emit(message) - - -class IconProvider(QFileIconProvider): - def icon(self, fileInfo): - return super().icon(fileInfo) - - -LOG_COLOR_MAP = { - True: COLORS["GREEN"], - False: COLORS["RED"], - "red": COLORS["RED"], - "green": COLORS["GREEN"], - "orange": COLORS["ORANGE"], - "blue": COLORS["BLUE"], - "grey": COLORS["LIGHT_DISABLED"], - None: COLORS["LIGHT_DISABLED"], -} - - -class InputBox(QLabel): - # Define CSS styles as class constants - STYLE_DEFAULT = f"border:2px dashed #aaa; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG']}; min-height:100px;" - STYLE_DEFAULT_HOVER = f"background:{COLORS['BLUE_BG_HOVER']}; border-color:{COLORS['BLUE_BORDER_HOVER']};" - - STYLE_ACTIVE = f"border:2px dashed {COLORS['GREEN']}; border-radius:5px; padding:20px; background:{COLORS['GREEN_BG']}; min-height:100px;" - STYLE_ACTIVE_HOVER = ( - f"background:{COLORS['GREEN_BG_HOVER']}; border-color:{COLORS['GREEN_BORDER']};" - ) - - STYLE_ERROR = f"border:2px dashed {COLORS['RED']}; border-radius:5px; padding:20px; background:{COLORS['RED_BG']}; min-height:100px; color:{COLORS['RED']};" - STYLE_ERROR_HOVER = ( - f"background:{COLORS['RED_BG_HOVER']}; border-color:{COLORS['RED']};" - ) - - def __init__(self, parent=None): - super().__init__(parent) - self.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setAcceptDrops(True) - self.setText( - "Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf, .md, .srt, .ass, .vtt)" - ) - self.setStyleSheet( - f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" - ) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - self.setCursor(Qt.CursorShape.PointingHandCursor) - - # Add clear button - self.clear_btn = QPushButton("✕", self) - self.clear_btn.setFixedSize(28, 28) - self.clear_btn.hide() - self.clear_btn.clicked.connect(self.clear_input) - - # Add Chapters button - self.chapters_btn = QPushButton("Chapters", self) - self.chapters_btn.setStyleSheet("QPushButton { padding: 6px 10px; }") - self.chapters_btn.hide() - self.chapters_btn.clicked.connect(self.on_chapters_clicked) - - # Add Textbox button with no padding - self.textbox_btn = QPushButton("Textbox", self) - self.textbox_btn.setStyleSheet("QPushButton { padding: 6px 10px; }") - self.textbox_btn.setToolTip("Input text directly instead of using a file") - self.textbox_btn.clicked.connect(self.on_textbox_clicked) - - # Add Edit button matching the textbox button - self.edit_btn = QPushButton("Edit", self) - self.edit_btn.setStyleSheet("QPushButton { padding: 6px 10px; }") - self.edit_btn.setToolTip("Edit the current text file") - self.edit_btn.clicked.connect(self.on_edit_clicked) - self.edit_btn.hide() - - # Add Go to folder button - self.go_to_folder_btn = QPushButton("Go to folder", self) - self.go_to_folder_btn.setStyleSheet("QPushButton { padding: 6px 10px; }") - self.go_to_folder_btn.setToolTip( - "Open the folder that contains the converted file" - ) - self.go_to_folder_btn.clicked.connect(self.on_go_to_folder_clicked) - self.go_to_folder_btn.hide() - - def resizeEvent(self, event): - super().resizeEvent(event) - margin = 12 - self.clear_btn.move(self.width() - self.clear_btn.width() - margin, margin) - self.chapters_btn.move( - margin, self.height() - self.chapters_btn.height() - margin - ) - # Position textbox button at top left - self.textbox_btn.move(margin, margin) - self.edit_btn.move(margin, margin) - # Position go to folder button at bottom right with correct margins - self.go_to_folder_btn.move( - self.width() - self.go_to_folder_btn.width() - margin, - self.height() - self.go_to_folder_btn.height() - margin, - ) - - def set_file_info(self, file_path): - # get icon without resizing using custom provider - provider = IconProvider() - qicon = provider.icon(QFileInfo(file_path)) - size = QSize(32, 32) - pixmap = qicon.pixmap(size) - # convert to base64 PNG - buffer = QBuffer() - buffer.open(QIODevice.OpenModeFlag.WriteOnly) - pixmap.save(buffer, "PNG") - img_data = base64.b64encode(buffer.data()).decode() - - size_str = self._human_readable_size(os.path.getsize(file_path)) - name = os.path.basename(file_path) - char_count = 0 - window = self.window() - cache = getattr(window, "_char_count_cache", None) - - def parse_size(size_str): - # Use regex to extract the numeric part - match = re.match(r"([\d.]+)", size_str) - if match: - return float(match.group(1)) - raise ValueError(f"Invalid size format: {size_str}") - - # Format numbers with commas - def format_num(n): - try: - if isinstance(n, str): - size = int(parse_size(n)) - return f"{size:,}" - else: - return f"{n:,}" - except Exception: - return str(n) - - doc_extensions = (".epub", ".pdf", ".md", ".markdown", ".srt", ".ass", ".vtt") - char_source_path = file_path - cached_char_count = None - - if file_path.lower().endswith(doc_extensions): - selected_file_path = getattr(window, "selected_file", None) - if selected_file_path and os.path.exists(selected_file_path): - char_source_path = selected_file_path - else: - char_source_path = None - - if cache is not None: - cached_char_count = cache.get(file_path) - if ( - cached_char_count is None - and char_source_path - and char_source_path != file_path - ): - cached_char_count = cache.get(char_source_path) - - if cached_char_count is not None: - char_count = cached_char_count - elif char_source_path: - try: - with open( - char_source_path, "r", encoding="utf-8", errors="ignore" - ) as f: - text = f.read() - cleaned_text = clean_text(text) - char_count = calculate_text_length(cleaned_text) - except Exception: - char_count = "N/A" - else: - char_count = "N/A" - - if cache is not None and isinstance(char_count, int): - cache[file_path] = char_count - if char_source_path and char_source_path != file_path: - cache[char_source_path] = char_count - - # Store numeric char_count on window - try: - window.char_count = int(char_count) - except Exception: - window.char_count = 0 - # embed icon at native size with word-wrap for the filename - self.setText( - f'
    {name}
    Size: {size_str}
    Characters: {format_num(char_count)}' - ) - # Set fixed width to force wrapping - self.setWordWrap(True) - self.setStyleSheet( - f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }}" - ) - self.clear_btn.show() - is_document = window.selected_file_type in ["epub", "pdf", "md", "markdown"] - self.chapters_btn.setVisible(is_document) - if is_document: - chapter_count = len(window.selected_chapters) - file_type = window.selected_file_type - # Adjust button text based on file type - if file_type == "epub" or file_type == "md" or file_type == "markdown": - self.chapters_btn.setText(f"Chapters ({chapter_count})") - else: # PDF - always use Pages - self.chapters_btn.setText(f"Pages ({chapter_count})") - - # Hide textbox and show edit only for .txt, .srt, .ass, .vtt files - self.textbox_btn.hide() - # Show edit button for txt/subtitle files directly - # Or for epub/pdf files that have generated a temp txt file - should_show_edit = file_path.lower().endswith((".txt", ".srt", ".ass", ".vtt")) - - # For epub/pdf files, show edit if we have a selected_file (temp txt) - if ( - window.selected_file_type - in ["epub", "pdf", "md", "markdown", "md", "markdown"] - and window.selected_file - ): - should_show_edit = True - - self.edit_btn.setVisible(should_show_edit) - self.go_to_folder_btn.show() - - # Disable subtitle generation for subtitle input files - is_subtitle_input = file_path.lower().endswith((".srt", ".ass", ".vtt")) - if hasattr(window, "subtitle_combo"): - window.subtitle_combo.setEnabled(not is_subtitle_input) - - # Enable add to queue button only when file is accepted (input box is green) - self.resizeEvent(None) - if hasattr(window, "btn_add_to_queue"): - window.btn_add_to_queue.setEnabled(True) - - self.chapters_btn.adjustSize() - # Reset the input_box_cleared_by_queue flag after setting file info - if hasattr(window, "input_box_cleared_by_queue"): - window.input_box_cleared_by_queue = False - - def set_error(self, message): - self.setText(message) - self.setStyleSheet( - f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }}" - ) - # Show textbox button in error state as well - self.textbox_btn.show() - # Disable add to queue button on error - if hasattr(self.window(), "btn_add_to_queue"): - self.window().btn_add_to_queue.setEnabled(False) - - def clear_input(self): - self.window().selected_file = None - self.window().displayed_file_path = ( - None # Reset the displayed file path when clearing input - ) - # Reset book handler attributes - self.window().save_chapters_separately = None - self.window().merge_chapters_at_end = None - self.setText( - "Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf, .md, .srt, .ass, .vtt)" - ) - self.setStyleSheet( - f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" - ) - self.clear_btn.hide() - self.chapters_btn.hide() - self.chapters_btn.setText("Chapters") # Reset text - # Show textbox and hide edit when input is cleared - self.textbox_btn.show() - self.edit_btn.hide() - self.go_to_folder_btn.hide() - - # Re-enable subtitle and replace newlines controls when cleared - window = self.window() - if hasattr(window, "subtitle_combo"): - # Only enable if language supports it - current_lang = getattr(window, "selected_lang", "a") - window.subtitle_combo.setEnabled( - current_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION - ) - if hasattr(window, "replace_newlines_combo"): - window.replace_newlines_combo.setEnabled(True) - - # Disable add to queue button when input is cleared - if hasattr(window, "btn_add_to_queue"): - window.btn_add_to_queue.setEnabled(False) - # Reset the input_box_cleared_by_queue flag after setting file info - if hasattr(self.window(), "input_box_cleared_by_queue"): - self.window().input_box_cleared_by_queue = True - - def _human_readable_size(self, size, decimal_places=2): - for unit in ["B", "KB", "MB", "GB", "TB"]: - if size < 1024.0: - return f"{size:.{decimal_places}f} {unit}" - size /= 1024.0 - return f"{size:.{decimal_places}f} PB" - - def mousePressEvent(self, event): - if event.button() == Qt.MouseButton.LeftButton: - self.window().open_file_dialog() - - def dragEnterEvent(self, event): - if event.mimeData().hasUrls(): - urls = event.mimeData().urls() - if urls: - ext = urls[0].toLocalFile().lower() - if ( - ext.endswith(".txt") - or ext.endswith(".epub") - or ext.endswith(".pdf") - or ext.endswith((".md", ".markdown")) - or ext.endswith((".srt", ".ass", ".vtt")) - ): - event.acceptProposedAction() - # Set hover style based on current state - if self.styleSheet().find(self.STYLE_ACTIVE) != -1: - self.setStyleSheet( - f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }} {self.STYLE_ACTIVE_HOVER}" - ) - elif self.styleSheet().find(self.STYLE_ERROR) != -1: - self.setStyleSheet( - f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }} {self.STYLE_ERROR_HOVER}" - ) - else: - self.setStyleSheet( - f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }} {self.STYLE_DEFAULT_HOVER}" - ) - return - event.ignore() - - def dragLeaveEvent(self, event): - # Restore the style based on current state - if self.styleSheet().find(self.STYLE_ACTIVE) != -1: - self.setStyleSheet( - f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }}" - ) - elif self.styleSheet().find(self.STYLE_ERROR) != -1: - self.setStyleSheet( - f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }}" - ) - else: - self.setStyleSheet( - f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" - ) - event.accept() - - def dropEvent(self, event): - if event.mimeData().hasUrls(): - urls = event.mimeData().urls() - if not urls: - event.ignore() - return - file_path = urls[0].toLocalFile() - win = self.window() - if file_path.lower().endswith(".txt"): - win.selected_file, win.selected_file_type = file_path, "txt" - win.displayed_file_path = ( - file_path # Set the displayed file path for text files - ) - self.set_file_info(file_path) - event.acceptProposedAction() - elif ( - file_path.lower().endswith(".epub") - or file_path.lower().endswith(".pdf") - or file_path.lower().endswith((".md", ".markdown")) - or file_path.lower().endswith((".srt", ".ass", ".vtt")) - ): - # Determine file type - if file_path.lower().endswith(".epub"): - file_type = "epub" - elif file_path.lower().endswith(".pdf"): - file_type = "pdf" - elif file_path.lower().endswith((".srt", ".ass", ".vtt")): - # For subtitle files, treat them like txt files (direct processing) - win.selected_file, win.selected_file_type = file_path, "txt" - win.displayed_file_path = file_path - self.set_file_info(file_path) - event.acceptProposedAction() - return - else: - file_type = "markdown" - - # Just store the file path but don't set the file info yet - win.selected_file_type = file_type - win.selected_book_path = file_path - win.open_book_file( - file_path # This will handle the dialog and setting file info - ) - event.acceptProposedAction() - else: - self.set_error( - "Please drop a .txt, .epub, .pdf, .md, .srt, .ass, or .vtt file." - ) - event.ignore() - else: - event.ignore() - - def on_chapters_clicked(self): - win = self.window() - if ( - win.selected_file_type in ["epub", "pdf", "md", "markdown"] - and win.selected_book_path - ): - # Call open_book_file which shows the dialog and updates selected_chapters - if win.open_book_file(win.selected_book_path): - # Refresh the info label and button text after dialog closes - self.set_file_info(win.selected_book_path) - - def on_textbox_clicked(self): - self.window().open_textbox_dialog() - - def on_edit_clicked(self): - win = self.window() - # For PDFs and EPUBs, use the temporary text file - if ( - win.selected_file_type in ["epub", "pdf", "md", "markdown"] - and win.selected_file - ): - # Use the temporary .txt file that was generated - win.open_textbox_dialog(win.selected_file) - else: - # For regular txt files - win.open_textbox_dialog() - - def on_go_to_folder_clicked(self): - win = self.window() - # win.selected_file holds the path to the text that is converted. - file_to_check = win.selected_file - - # If this is a converted document (epub/pdf/markdown) that was written to the - # user's cache directory, show a menu letting the user jump to either the - # processed (cached .txt) file or the original input file (epub/pdf/md). - try: - cache_dir = get_user_cache_path() - except Exception: - cache_dir = None - - is_cached_doc = False - if ( - file_to_check - and os.path.exists(file_to_check) - and os.path.isfile(file_to_check) - and cache_dir - ): - # Consider it cached when the file is under the cache directory and is a .txt - if file_to_check.endswith(".txt") and os.path.commonpath( - [os.path.abspath(file_to_check), os.path.abspath(cache_dir)] - ) == os.path.abspath(cache_dir): - # Only treat as document-cache when original type was a document - if getattr(win, "selected_file_type", None) in [ - "epub", - "pdf", - "md", - "markdown", - ]: - is_cached_doc = True - - if is_cached_doc: - menu = QMenu(self) - act_processed = QAction("Go to processed file", self) - - def open_processed(): - folder_path = os.path.dirname(file_to_check) - QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) - - act_processed.triggered.connect(open_processed) - menu.addAction(act_processed) - - act_input = QAction("Go to input file", self) - # Prefer displayed_file_path (original input path) then selected_book_path - input_path = getattr(win, "displayed_file_path", None) or getattr( - win, "selected_book_path", None - ) - if input_path and os.path.exists(input_path): - - def open_input(): - folder_path = os.path.dirname(input_path) - QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) - - act_input.triggered.connect(open_input) - else: - act_input.setEnabled(False) - - menu.addAction(act_input) - # Show the menu anchored to the button - menu.exec( - self.go_to_folder_btn.mapToGlobal( - QPoint(0, self.go_to_folder_btn.height()) - ) - ) - else: - if ( - file_to_check - and os.path.exists(file_to_check) - and os.path.isfile(file_to_check) - ): - folder_path = os.path.dirname(file_to_check) - QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) - else: - QMessageBox.warning(win, "Error", "Converted file not found.") - - -class TextboxDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Enter Text") - self.setWindowFlags( - Qt.WindowType.Window - | Qt.WindowType.WindowCloseButtonHint - | Qt.WindowType.WindowMaximizeButtonHint - ) - self.resize(700, 500) - - layout = QVBoxLayout(self) - - # Instructions - instructions = QLabel( - "Enter or paste the text you want to convert to audio:", self - ) - layout.addWidget(instructions) - - # Text edit area - self.text_edit = QTextEdit(self) - self.text_edit.setAcceptRichText(False) - self.text_edit.setPlaceholderText("Type or paste your text here...") - layout.addWidget(self.text_edit) - - # Character count label - self.char_count_label = QLabel("Characters: 0", self) - layout.addWidget(self.char_count_label) - - # Connect text changed signal to update character count - self.text_edit.textChanged.connect(self.update_char_count) - - # Buttons - button_layout = QHBoxLayout() - - self.save_as_button = QPushButton("Save as text", self) - self.save_as_button.clicked.connect(self.save_as_text) - self.save_as_button.setToolTip("Save the current text to a file") - - self.insert_chapter_btn = QPushButton("Insert Chapter Marker", self) - self.insert_chapter_btn.setToolTip("Insert a chapter marker at the cursor") - self.insert_chapter_btn.clicked.connect(self.insert_chapter_marker) - button_layout.addWidget(self.insert_chapter_btn) - - self.cancel_button = QPushButton("Cancel", self) - self.cancel_button.clicked.connect(self.reject) - - self.ok_button = QPushButton("OK", self) - self.ok_button.setDefault(True) - self.ok_button.clicked.connect(self.handle_ok) - - button_layout.addWidget(self.save_as_button) - button_layout.addWidget(self.cancel_button) - button_layout.addWidget(self.ok_button) - layout.addLayout(button_layout) - - # Store the original text to detect changes - self.original_text = "" - - def update_char_count(self): - text = self.text_edit.toPlainText() - count = calculate_text_length(text) - self.char_count_label.setText(f"Characters: {count:,}") - - def get_text(self): - return self.text_edit.toPlainText() - - def handle_ok(self): - text = self.text_edit.toPlainText() - # Check if text is empty based on character count - if calculate_text_length(text) == 0: - QMessageBox.warning(self, "Textbox Error", "Text cannot be empty.") - return - - # If the text hasn't changed, treat as cancel - if text == self.original_text: - self.reject() - else: - # Check if we need to warn about overwriting a non-temporary file - if hasattr(self, "is_non_cache_file") and self.is_non_cache_file: - msg_box = QMessageBox(self) - msg_box.setIcon(QMessageBox.Icon.Warning) - msg_box.setWindowTitle("File Overwrite Warning") - msg_box.setText( - f"You are about to overwrite the original file:\n{self.non_cache_file_path}" - ) - msg_box.setInformativeText("Do you want to continue?") - msg_box.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - ) - msg_box.setDefaultButton(QMessageBox.StandardButton.No) - - if msg_box.exec() != QMessageBox.StandardButton.Yes: - # User canceled, don't close the dialog - return - - self.accept() - - def save_as_text(self): - """Save the text content to a file chosen by the user""" - try: - text = self.text_edit.toPlainText() - if not text.strip(): - QMessageBox.warning(self, "Save Error", "There is no text to save.") - return - - # Get default filename from original file if editing - initial_path = "" - if hasattr(self, "non_cache_file_path") and self.non_cache_file_path: - initial_path = self.non_cache_file_path - - # For EPUB and PDF files, use the displayed_file_path from the main window - # This gives a better filename instead of the cache file path - main_window = self.parent() - if ( - hasattr(main_window, "displayed_file_path") - and main_window.displayed_file_path - ): - if main_window.selected_file_type in ["epub", "pdf", "md", "markdown"]: - # Use the base name of the displayed file but change extension to .txt - base_name = os.path.splitext(main_window.displayed_file_path)[0] - initial_path = base_name + ".txt" - - file_path, _ = QFileDialog.getSaveFileName( - self, "Save Text As", initial_path, "Text Files (*.txt);;All Files (*)" - ) - - if file_path: - # Add .txt extension if not specified and no other extension exists - if not os.path.splitext(file_path)[1]: - file_path += ".txt" - - with open(file_path, "w", encoding="utf-8") as f: - f.write(text) - - except Exception as e: - QMessageBox.critical(self, "Save Error", f"Could not save file:\n{e}") - - def insert_chapter_marker(self): - # Insert a fixed chapter marker without prompting - cursor = self.text_edit.textCursor() - cursor.insertText("\n<>\n") - self.text_edit.setTextCursor(cursor) - self.update_char_count() - self.text_edit.setFocus() - - -def migrate_subtitle_format(config): - """Convert old subtitle_format values to new internal keys.""" - old_to_new = { - "srt": "srt", - "ass (wide)": "ass_wide", - "ass (narrow)": "ass_narrow", - "ass (centered wide)": "ass_centered_wide", - "ass (centered narrow)": "ass_centered_narrow", - } - val = config.get("subtitle_format") - if val in old_to_new: - config["subtitle_format"] = old_to_new[val] - save_config(config) - - -class abogen(QWidget): - def __init__(self): - super().__init__() - self.config = load_config() - self.apply_theme(self.config.get("theme", "system")) - migrate_subtitle_format(self.config) - self.check_updates = self.config.get("check_updates", True) - self.save_option = self.config.get("save_option", "Save next to input file") - self.selected_output_folder = self.config.get("selected_output_folder", None) - self.selected_file = self.selected_file_type = self.selected_book_path = None - self.displayed_file_path = ( - None # Add new variable to track the displayed file path - ) - # Max log lines - self.log_window_max_lines = self.config.get("log_window_max_lines", 2000) - self.selected_chapters = set() - self.last_opened_book_path = None # Track the last opened book path - self.last_output_path = None - self.char_count = 0 - self._char_count_cache = {} - # Only one of selected_profile_name or selected_voice should be set - self.selected_profile_name = self.config.get("selected_profile_name") - self.selected_voice = None - self.selected_lang = None - self.mixed_voice_state = None - if self.selected_profile_name: - self.selected_voice = None - self.selected_lang = None - else: - self.selected_voice = self.config.get("selected_voice", "af_heart") - self.selected_lang = self.selected_voice[0] if self.selected_voice else None - self.is_converting = False - self.subtitle_mode = self.config.get("subtitle_mode", "Sentence") - self.max_subtitle_words = self.config.get( - "max_subtitle_words", 50 - ) # Default max words per subtitle - self.silence_duration = self.config.get( - "silence_duration", 2.0 - ) # Default silence duration - self.selected_format = self.config.get("selected_format", "wav") - self.separate_chapters_format = self.config.get( - "separate_chapters_format", "wav" - ) # Format for individual chapter files - self.use_gpu = self.config.get( - "use_gpu", True # Load GPU setting with default True - ) - self.replace_single_newlines = self.config.get("replace_single_newlines", True) - self.use_silent_gaps = self.config.get("use_silent_gaps", True) - self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts") - self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", True) - self._pending_close_event = None - self.gpu_ok = False # Initialize GPU availability status - - # Create thread-safe logging mechanism - self.log_signal = ThreadSafeLogSignal() - self.log_signal.log_signal.connect(self._update_log_main_thread) - - # Create warning signal emitter - self.warning_signal_emitter = ShowWarningSignalEmitter() - self.warning_signal_emitter.show_warning_signal.connect( - self.show_model_download_warning - ) - hf_tracker.set_show_warning_signal_emitter(self.warning_signal_emitter) - - # Set application icon - icon_path = get_resource_path("abogen.assets", "icon.ico") - if icon_path: - self.setWindowIcon(QIcon(icon_path)) - # Set taskbar icon for Windows - if platform.system() == "Windows": - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("abogen") - - # Queued items list - self.queued_items = [] - self.current_queue_index = 0 - - self.initUI() - self.speed_slider.setValue(int(self.config.get("speed", 1.00) * 100)) - self.update_speed_label() - # Set initial selection: prefer profile, else voice - idx = -1 - if self.selected_profile_name: - idx = self.voice_combo.findData(f"profile:{self.selected_profile_name}") - elif self.selected_voice: - idx = self.voice_combo.findData(self.selected_voice) - if idx >= 0: - self.voice_combo.setCurrentIndex(idx) - # If a profile is selected at startup, load voices and language - if self.selected_profile_name: - from abogen.voice_profiles import load_profiles - - entry = load_profiles().get(self.selected_profile_name, {}) - if isinstance(entry, dict): - self.mixed_voice_state = entry.get("voices", []) - self.selected_lang = entry.get("language") - else: - self.mixed_voice_state = entry - self.selected_lang = entry[0][0] if entry and entry[0] else None - if self.save_option == "Choose output folder" and self.selected_output_folder: - self.save_path_label.setText(self.selected_output_folder) - self.save_path_row_widget.show() - self.subtitle_combo.setCurrentText(self.subtitle_mode) - # Enable/disable subtitle options based on selected language (profile or voice) - enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION - self.subtitle_combo.setEnabled(enable) - self.subtitle_format_combo.setEnabled(enable) - # loading gif for preview button - loading_gif_path = get_resource_path("abogen.assets", "loading.gif") - if loading_gif_path: - self.loading_movie = QMovie(loading_gif_path) - self.loading_movie.frameChanged.connect( - lambda: self.btn_preview.setIcon( - QIcon(self.loading_movie.currentPixmap()) - ) - ) - - # Check for updates at startup if enabled - if self.check_updates: - QTimer.singleShot(1000, self.check_for_updates_startup) - - # Set hf_tracker callbacks - hf_tracker.set_log_callback(self.update_log) - - def initUI(self): - self.setWindowTitle(f"{PROGRAM_NAME} v{VERSION}") - screen = QApplication.primaryScreen().geometry() - width, height = 500, 800 - x = (screen.width() - width) // 2 - # If desired height is larger than screen, fit to screen height - if height > screen.height() - 65: - height = screen.height() - 100 # Leave a margin for window borders - y = max((screen.height() - height) // 2, 0) - self.setGeometry(x, y, width, height) - outer_layout = QVBoxLayout() - outer_layout.setContentsMargins(15, 15, 15, 15) - container = QWidget(self) - container_layout = QVBoxLayout(container) - container_layout.setContentsMargins(0, 0, 0, 0) - container_layout.setSpacing(15) - self.input_box = InputBox(self) - container_layout.addWidget(self.input_box, 1) - # Manage queue button, start queue button - self.queue_row_widget = QWidget(self) # Make queue_row a QWidget - queue_row = QHBoxLayout(self.queue_row_widget) - queue_row.setContentsMargins(0, 0, 0, 0) - self.btn_add_to_queue = QPushButton("Add to Queue", self) - self.btn_add_to_queue.setFixedHeight(40) - self.btn_add_to_queue.setEnabled(False) - self.btn_add_to_queue.clicked.connect(self.add_to_queue) - queue_row.addWidget(self.btn_add_to_queue) - self.btn_manage_queue = QPushButton("Manage Queue", self) - self.btn_manage_queue.setFixedHeight(40) - self.btn_manage_queue.setEnabled(True) - self.btn_manage_queue.clicked.connect(self.manage_queue) - queue_row.addWidget(self.btn_manage_queue) - self.btn_clear_queue = QPushButton("Clear Queue", self) - self.btn_clear_queue.setFixedHeight(40) - self.btn_clear_queue.setEnabled(False) - self.btn_clear_queue.clicked.connect(self.clear_queue) - queue_row.addWidget(self.btn_clear_queue) - container_layout.addWidget(self.queue_row_widget) - self.log_text = QTextEdit(self) - self.log_text.setReadOnly(True) - self.log_text.setUndoRedoEnabled(False) - self.log_text.setFrameStyle(QFrame.Shape.NoFrame) - self.log_text.setStyleSheet("QTextEdit { border: none; }") - self.log_text.hide() - container_layout.addWidget(self.log_text, 1) - controls_layout = QVBoxLayout() - controls_layout.setContentsMargins(0, 10, 0, 0) - controls_layout.setSpacing(15) - # Speed controls - speed_layout = QVBoxLayout() - speed_layout.setSpacing(2) - speed_layout.addWidget(QLabel("Speed:", self)) - self.speed_slider = QSlider(Qt.Orientation.Horizontal, self) - self.speed_slider.setMinimum(10) - self.speed_slider.setMaximum(200) - self.speed_slider.setValue(100) - self.speed_slider.setTickPosition(QSlider.TickPosition.TicksBelow) - self.speed_slider.setTickInterval(5) - self.speed_slider.setSingleStep(5) - speed_layout.addWidget(self.speed_slider) - self.speed_label = QLabel("1.0", self) - speed_layout.addWidget(self.speed_label) - controls_layout.addLayout(speed_layout) - self.speed_slider.valueChanged.connect(self.update_speed_label) - # Voice selection - voice_layout = QHBoxLayout() - voice_layout.setSpacing(7) - voice_label = QLabel("Select voice:", self) - voice_layout.addWidget(voice_label) - self.voice_combo = QComboBox(self) - self.voice_combo.currentIndexChanged.connect(self.on_voice_combo_changed) - self.voice_combo.setStyleSheet( - "QComboBox { min-height: 20px; padding: 6px 12px; }" - ) - self.voice_combo.setToolTip( - "The first character represents the language:\n" - '"a" => American English\n"b" => British English\n"e" => Spanish\n"f" => French\n"h" => Hindi\n"i" => Italian\n"j" => Japanese\n"p" => Brazilian Portuguese\n"z" => Mandarin Chinese\nThe second character represents the gender:\n"m" => Male\n"f" => Female' - ) - self.voice_combo.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - voice_layout.addWidget(self.voice_combo) - # Voice formula button - self.btn_voice_formula_mixer = QPushButton(self) - mixer_icon_path = get_resource_path("abogen.assets", "voice_mixer.png") - self.btn_voice_formula_mixer.setIcon(QIcon(mixer_icon_path)) - self.btn_voice_formula_mixer.setToolTip("Mix and match voices") - self.btn_voice_formula_mixer.setFixedSize(40, 36) - self.btn_voice_formula_mixer.setStyleSheet("QPushButton { padding: 6px 12px; }") - self.btn_voice_formula_mixer.clicked.connect(self.show_voice_formula_dialog) - voice_layout.addWidget(self.btn_voice_formula_mixer) - - # Play/Stop icons - def make_icon(color, shape): - pix = QPixmap(20, 20) - pix.fill(Qt.GlobalColor.transparent) - p = QPainter(pix) - p.setRenderHint(QPainter.RenderHint.Antialiasing) - p.setBrush(QColor(*color)) - p.setPen(Qt.PenStyle.NoPen) - if shape == "play": - pts = [ - pix.rect().topLeft() + QPoint(4, 2), - pix.rect().bottomLeft() + QPoint(4, -2), - pix.rect().center() + QPoint(6, 0), - ] - p.drawPolygon(QPolygon(pts)) - else: - p.drawRect(5, 5, 10, 10) - p.end() - return QIcon(pix) - - self.play_icon = make_icon((40, 160, 40), "play") - self.stop_icon = make_icon((200, 60, 60), "stop") - self.btn_preview = QPushButton(self) - self.btn_preview.setIcon(self.play_icon) - self.btn_preview.setIconSize(QPixmap(20, 20).size()) - self.btn_preview.setToolTip("Preview selected voice") - self.btn_preview.setFixedSize(40, 36) - self.btn_preview.setStyleSheet("QPushButton { padding: 6px 12px; }") - self.btn_preview.clicked.connect(self.preview_voice) - voice_layout.addWidget(self.btn_preview) - self.preview_playing = False - self.play_audio_thread = None # Keep track of audio playing thread - controls_layout.addLayout(voice_layout) - - # Generate subtitles - subtitle_layout = QHBoxLayout() - subtitle_layout.setSpacing(7) - subtitle_label = QLabel("Generate subtitles:", self) - subtitle_layout.addWidget(subtitle_label) - self.subtitle_combo = QComboBox(self) - self.subtitle_combo.setToolTip( - "Choose how subtitles will be generated:\n" - "Disabled: No subtitles will be generated.\n" - "Line: Subtitles will be generated for each line.\n" - "Sentence: Subtitles will be generated for each sentence.\n" - "Sentence + Comma: Subtitles will be generated for each sentence and comma.\n" - "Sentence + Highlighting: Subtitles with word-by-word karaoke highlighting.\n" - "1+ word: Subtitles will be generated for each word(s).\n\n" - "Supported languages for subtitle generation:\n" - + "\n".join( - f'"{lang}" => {LANGUAGE_DESCRIPTIONS.get(lang, lang)}' - for lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION - ) - ) - subtitle_options = [ - "Disabled", - "Line", - "Sentence", - "Sentence + Comma", - "Sentence + Highlighting", - ] + [f"{i} word" if i == 1 else f"{i} words" for i in range(1, 11)] - self.subtitle_combo.addItems(subtitle_options) - self.subtitle_combo.setStyleSheet( - "QComboBox { min-height: 20px; padding: 6px 12px; }" - ) - self.subtitle_combo.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - self.subtitle_combo.setCurrentText(self.subtitle_mode) - self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed) - subtitle_layout.addWidget(self.subtitle_combo) - controls_layout.addLayout(subtitle_layout) - - # Output voice format - format_layout = QHBoxLayout() - format_layout.setSpacing(7) - format_label = QLabel("Output voice format:", self) - format_layout.addWidget(format_label) - self.format_combo = QComboBox(self) - self.format_combo.setStyleSheet( - "QComboBox { min-height: 20px; padding: 6px 12px; }" - ) - self.format_combo.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - # Add items with display labels and underlying keys - for key, label in [ - ("wav", "wav"), - ("flac", "flac"), - ("mp3", "mp3"), - ("opus", "opus (best compression)"), - ("m4b", "m4b (with chapters)"), - ]: - self.format_combo.addItem(label, key) - # Initialize selection by matching saved key - idx = self.format_combo.findData(self.selected_format) - if idx >= 0: - self.format_combo.setCurrentIndex(idx) - # Map selection back to key on change - self.format_combo.currentIndexChanged.connect( - lambda i: self.on_format_changed(self.format_combo.itemData(i)) - ) - format_layout.addWidget(self.format_combo) - controls_layout.addLayout(format_layout) - - # Output subtitle format - subtitle_format_layout = QHBoxLayout() - subtitle_format_layout.setSpacing(7) - subtitle_format_label = QLabel("Output subtitle format:", self) - subtitle_format_layout.addWidget(subtitle_format_label) - self.subtitle_format_combo = QComboBox(self) - self.subtitle_format_combo.setStyleSheet( - "QComboBox { min-height: 20px; padding: 6px 12px; }" - ) - self.subtitle_format_combo.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - for value, text in SUBTITLE_FORMATS: - self.subtitle_format_combo.addItem(text, value) - subtitle_format = self.config.get("subtitle_format", "ass_centered_narrow") - idx = self.subtitle_format_combo.findData(subtitle_format) - if idx >= 0: - self.subtitle_format_combo.setCurrentIndex(idx) - self.subtitle_format_combo.currentIndexChanged.connect( - lambda i: self.set_subtitle_format(self.subtitle_format_combo.itemData(i)) - ) - subtitle_format_layout.addWidget(self.subtitle_format_combo) - # If subtitle mode requires highlighting, SRT is not supported. Disable SRT item - # and auto-switch to a compatible ASS format if SRT is currently selected. - try: - if ( - hasattr(self, "subtitle_mode") - and self.subtitle_mode == "Sentence + Highlighting" - ): - idx_srt = self.subtitle_format_combo.findData("srt") - if idx_srt >= 0: - item = self.subtitle_format_combo.model().item(idx_srt) - if item is not None: - item.setEnabled(False) - # If current selection is SRT, switch to centered narrow ASS - if self.subtitle_format_combo.currentData() == "srt": - new_idx = self.subtitle_format_combo.findData("ass_centered_narrow") - if new_idx >= 0: - self.subtitle_format_combo.setCurrentIndex(new_idx) - # Persist the change - self.set_subtitle_format( - self.subtitle_format_combo.itemData(new_idx) - ) - except Exception: - # Fail-safe: don't crash UI if model manipulation isn't supported on some platforms - pass - - # Enable/disable subtitle options based on selected language (profile or voice) - self.update_subtitle_options_availability() - - controls_layout.addLayout(subtitle_format_layout) - - # Replace single newlines dropdown (acts like checkbox) - replace_newlines_layout = QHBoxLayout() - replace_newlines_layout.setSpacing(7) - replace_newlines_label = QLabel("Replace single newlines:", self) - replace_newlines_layout.addWidget(replace_newlines_label) - self.replace_newlines_combo = QComboBox(self) - self.replace_newlines_combo.addItems(["Disabled", "Enabled"]) - self.replace_newlines_combo.setToolTip( - "Replace single newlines in the input text with spaces before processing." - ) - self.replace_newlines_combo.setStyleSheet( - "QComboBox { min-height: 20px; padding: 6px 12px; }" - ) - self.replace_newlines_combo.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - # Set initial value based on config - self.replace_newlines_combo.setCurrentIndex( - 1 if self.replace_single_newlines else 0 - ) - self.replace_newlines_combo.currentIndexChanged.connect( - lambda idx: self.toggle_replace_single_newlines(idx == 1) - ) - replace_newlines_layout.addWidget(self.replace_newlines_combo) - controls_layout.addLayout(replace_newlines_layout) - - # Save location - save_layout = QHBoxLayout() - save_layout.setSpacing(7) - save_label = QLabel("Save location:", self) - save_layout.addWidget(save_label) - self.save_combo = QComboBox(self) - save_options = [ - "Save next to input file", - "Save to Desktop", - "Choose output folder", - ] - self.save_combo.addItems(save_options) - self.save_combo.setStyleSheet( - "QComboBox { min-height: 20px; padding: 6px 12px; }" - ) - self.save_combo.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed - ) - self.save_combo.setCurrentText(self.save_option) - self.save_combo.currentTextChanged.connect(self.on_save_option_changed) - save_layout.addWidget(self.save_combo) - controls_layout.addLayout(save_layout) - - # Save path label - self.save_path_row_widget = QWidget(self) - save_path_row = QHBoxLayout(self.save_path_row_widget) - save_path_row.setSpacing(7) - save_path_row.setContentsMargins(0, 0, 0, 0) - selected_folder_label = QLabel("Selected folder:", self.save_path_row_widget) - save_path_row.addWidget(selected_folder_label) - self.save_path_label = QLabel("", self.save_path_row_widget) - self.save_path_label.setStyleSheet(f"QLabel {{ color: {COLORS['GREEN']}; }}") - self.save_path_label.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred - ) - save_path_row.addWidget(self.save_path_label) - self.save_path_row_widget.hide() # Hide the whole row by default - controls_layout.addWidget(self.save_path_row_widget) - - # GPU Acceleration Checkbox with Settings button - gpu_layout = QHBoxLayout() - gpu_checkbox_layout = QVBoxLayout() - self.gpu_checkbox = QCheckBox("Use GPU Acceleration (if available)", self) - self.gpu_checkbox.setChecked(self.use_gpu) - self.gpu_checkbox.setToolTip( - "Uncheck to force using CPU even if a compatible GPU is detected." - ) - self.gpu_checkbox.stateChanged.connect(self.on_gpu_setting_changed) - gpu_checkbox_layout.addWidget(self.gpu_checkbox) - gpu_layout.addLayout(gpu_checkbox_layout) - - # Set initial enabled state for subtitle format combo - if self.subtitle_mode == "Disabled": - self.subtitle_format_combo.setEnabled(False) - else: - self.subtitle_format_combo.setEnabled(True) - - # Settings button with icon - settings_icon_path = get_resource_path("abogen.assets", "settings.svg") - self.settings_btn = QPushButton(self) - if settings_icon_path and os.path.exists(settings_icon_path): - self.settings_btn.setIcon(QIcon(settings_icon_path)) - else: - # Fallback text if icon not found - self.settings_btn.setText("⚙") - self.settings_btn.setToolTip("Settings") - self.settings_btn.setFixedSize(36, 36) - self.settings_btn.clicked.connect(self.show_settings_menu) - gpu_layout.addWidget(self.settings_btn) - - controls_layout.addLayout(gpu_layout) - - # Start button - self.btn_start = QPushButton("Start", self) - self.btn_start.setFixedHeight(60) - self.btn_start.clicked.connect(self.start_conversion) - controls_layout.addWidget(self.btn_start) - # Add controls to a container widget - self.controls_widget = QWidget() - self.controls_widget.setLayout(controls_layout) - self.controls_widget.setSizePolicy( - QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed - ) - container_layout.addWidget(self.controls_widget) - # Progress bar - self.progress_bar = QProgressBar(self) - self.progress_bar.setValue(0) - self.progress_bar.hide() - container_layout.addWidget(self.progress_bar) - # ETR Label - self.etr_label = QLabel("Estimated time remaining: Calculating...", self) - self.etr_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.etr_label.hide() - container_layout.addWidget(self.etr_label) - # Cancel button - self.btn_cancel = QPushButton("Cancel", self) - self.btn_cancel.setFixedHeight(60) - self.btn_cancel.clicked.connect(self.cancel_conversion) - self.btn_cancel.hide() - container_layout.addWidget(self.btn_cancel) - # Finish buttons - self.finish_widget = QWidget() - finish_layout = QVBoxLayout() - finish_layout.setContentsMargins(0, 0, 0, 0) - finish_layout.setSpacing(10) - self.open_file_btn = None # Store reference to open file button - - # Create buttons with their functions - finish_buttons = [ - ("Open file", self.open_file, "Open the output file."), - ( - "Go to folder", - self.go_to_file, - "Open the folder containing the output file.", - ), - ("New Conversion", self.reset_ui, "Start a new conversion."), - ("Go back", self.go_back_ui, "Return to the previous screen."), - ] - - for text, func, tip in finish_buttons: - btn = QPushButton(text, self) - btn.setFixedHeight(35) - btn.setToolTip(tip) - btn.clicked.connect(func) - finish_layout.addWidget(btn) - # Identify the Open file button by its function reference - if func == self.open_file: - self.open_file_btn = btn # Save reference to the open file button - - self.finish_widget.setLayout(finish_layout) - self.finish_widget.hide() - container_layout.addWidget(self.finish_widget) - outer_layout.addWidget(container) - self.setLayout(outer_layout) - self.populate_profiles_in_voice_combo() - - # Initialize flag to track if input box was cleared by queue - self.input_box_cleared_by_queue = False - - def open_file_dialog(self): - if self.is_converting: - return - try: - file_path, _ = QFileDialog.getOpenFileName( - self, - "Select File", - "", - "Supported Files (*.txt *.epub *.pdf *.md *.srt *.ass *.vtt)", - ) - if not file_path: - return - if ( - file_path.lower().endswith(".epub") - or file_path.lower().endswith(".pdf") - or file_path.lower().endswith((".md", ".markdown")) - ): - # Determine file type - if file_path.lower().endswith(".epub"): - self.selected_file_type = "epub" - elif file_path.lower().endswith(".pdf"): - self.selected_file_type = "pdf" - else: - self.selected_file_type = "markdown" - - self.selected_book_path = file_path - # Don't set file info immediately, open_book_file will handle it after dialog is accepted - if not self.open_book_file(file_path): - return - elif file_path.lower().endswith((".srt", ".ass", ".vtt")): - # Handle subtitle files like text files - self.selected_file, self.selected_file_type = file_path, "txt" - self.displayed_file_path = file_path - self.input_box.set_file_info(file_path) - else: - self.selected_file, self.selected_file_type = file_path, "txt" - self.displayed_file_path = ( - file_path # Set the displayed file path for text files - ) - self.input_box.set_file_info(file_path) - except Exception as e: - self._show_error_message_box( - "File Dialog Error", f"Could not open file dialog:\n{e}" - ) - - def open_book_file(self, book_path): - # Clear selected chapters if this is a different book than the last one - if ( - not hasattr(self, "last_opened_book_path") - or self.last_opened_book_path != book_path - ): - self.selected_chapters = set() - self.last_opened_book_path = book_path - - # HandlerDialog uses internal caching to avoid reprocessing the same book - dialog = HandlerDialog( - book_path, - file_type=getattr(self, "selected_file_type", None), - checked_chapters=self.selected_chapters, - parent=self, - ) - dialog.setWindowModality(Qt.WindowModality.NonModal) - dialog.setModal(False) - dialog.show() # We'll handle the dialog result asynchronously - - def on_dialog_finished(result): - if result != QDialog.DialogCode.Accepted: - return False - chapters_text, all_checked_hrefs = dialog.get_selected_text() - if not all_checked_hrefs: - # Determine file type for error message - if book_path.lower().endswith(".pdf"): - file_type = "pdf" - item_type = "pages" - elif book_path.lower().endswith((".md", ".markdown")): - file_type = "markdown" - item_type = "chapters" - else: - file_type = "epub" - item_type = "chapters" - - error_msg = f"No {item_type} selected." - self._show_error_message_box(f"{file_type.upper()} Error", error_msg) - return False - self.selected_chapters = all_checked_hrefs - self.save_chapters_separately = dialog.get_save_chapters_separately() - self.merge_chapters_at_end = dialog.get_merge_chapters_at_end() - self.save_as_project = dialog.get_save_as_project() - - # Store if the PDF has bookmarks for button text display - if book_path.lower().endswith(".pdf"): - self.pdf_has_bookmarks = getattr(dialog, "has_pdf_bookmarks", False) - - cleaned_text = clean_text(chapters_text) - computed_char_count = calculate_text_length(cleaned_text) - self.char_count = computed_char_count - if isinstance(getattr(self, "_char_count_cache", None), dict): - self._char_count_cache[book_path] = computed_char_count - - # Use "abogen" prefix for cache files - # Extract base name without extension - base_name = os.path.splitext(os.path.basename(book_path))[0] - - if self.save_as_project: - # Get project directory from user - project_dir = QFileDialog.getExistingDirectory( - self, "Select Project Folder", "", QFileDialog.Option.ShowDirsOnly - ) - if not project_dir: - # User cancelled, fallback to cache - self.save_as_project = False - cache_dir = get_user_cache_path() - else: - # Create project folder structure - project_name = f"{base_name}_project" - project_dir = os.path.join(project_dir, project_name) - cache_dir = os.path.join(project_dir, "text") - os.makedirs(cache_dir, exist_ok=True) - - # Save metadata if available - meta_dir = os.path.join(project_dir, "metadata") - os.makedirs( - meta_dir, exist_ok=True - ) # Save book metadata if available - if hasattr(dialog, "book_metadata"): - meta_path = os.path.join(meta_dir, "book_info.txt") - with open(meta_path, "w", encoding="utf-8") as f: - # Clean HTML tags from metadata - title = re.sub( - r"<[^>]+>", - "", - str(dialog.book_metadata.get("title", "Unknown")), - ) - publisher = re.sub( - r"<[^>]+>", - "", - str(dialog.book_metadata.get("publisher", "Unknown")), - ) - authors = [ - re.sub(r"<[^>]+>", "", str(author)) - for author in dialog.book_metadata.get( - "authors", ["Unknown"] - ) - ] - publication_year = re.sub( - r"<[^>]+>", - "", - str( - dialog.book_metadata.get( - "publication_year", "Unknown" - ) - ), - ) - - f.write(f"Title: {title}\n") - f.write(f"Authors: {', '.join(authors)}\n") - f.write(f"Publisher: {publisher}\n") - f.write(f"Publication Year: {publication_year}\n") - if dialog.book_metadata.get("description"): - description = re.sub( - r"<[^>]+>", - "", - str(dialog.book_metadata.get("description")), - ) - f.write(f"\nDescription:\n{description}\n") - - # Save cover image if available - if dialog.book_metadata.get("cover_image"): - cover_path = os.path.join(meta_dir, "cover.png") - with open(cover_path, "wb") as f: - f.write(dialog.book_metadata["cover_image"]) - else: - cache_dir = get_user_cache_path() - - fd, tmp = tempfile.mkstemp( - prefix=f"{base_name}_", suffix=".txt", dir=cache_dir - ) - os.close(fd) - with open(tmp, "w", encoding="utf-8") as f: - f.write(chapters_text) - self.selected_file = tmp - self.selected_book_path = book_path - self.displayed_file_path = book_path - if isinstance(getattr(self, "_char_count_cache", None), dict): - self._char_count_cache[tmp] = computed_char_count - # Only set file info if dialog was accepted - self.input_box.set_file_info(book_path) - return True - - dialog.finished.connect(on_dialog_finished) - return True - - def open_textbox_dialog(self, file_path=None): - """Shows dialog for direct text input or editing and processes the entered text""" - if self.is_converting: - return - - editing = False - is_cache_file = False - # If path is explicitly provided, use it - if file_path and os.path.exists(file_path): - editing = True - edit_file = file_path - # Check if this is a cache file - is_cache_file = get_user_cache_path() in file_path - # Otherwise use selected_file if it's a txt file - elif ( - self.selected_file_type == "txt" - and self.selected_file - and os.path.exists(self.selected_file) - ): - editing = True - edit_file = self.selected_file - # Check if this is a cache file - is_cache_file = get_user_cache_path() in self.selected_file - - dialog = TextboxDialog(self) - if editing: - try: - with open(edit_file, "r", encoding="utf-8", errors="ignore") as f: - dialog.text_edit.setText(f.read()) - dialog.update_char_count() - dialog.original_text = ( - dialog.text_edit.toPlainText() - ) # Store original text - - # If editing a non-cache file, alert the user - if not is_cache_file: - dialog.is_non_cache_file = True - dialog.non_cache_file_path = edit_file - except Exception: - pass - if dialog.exec() == QDialog.DialogCode.Accepted: - text = dialog.get_text() - if not text.strip(): - self._show_error_message_box("Textbox Error", "Text cannot be empty.") - return - try: - if editing: - with open(edit_file, "w", encoding="utf-8") as f: - f.write(text) - # Update the display path to the edited file - self.displayed_file_path = edit_file - self.input_box.set_file_info(edit_file) - # Hide chapters button since we're using custom text now - self.input_box.chapters_btn.hide() - else: - cache_dir = get_user_cache_path() - fd, tmp = tempfile.mkstemp( - prefix="abogen_", suffix=".txt", dir=cache_dir - ) - os.close(fd) - with open(tmp, "w", encoding="utf-8") as f: - f.write(text) - self.selected_file = tmp - self.selected_file_type = "txt" - self.displayed_file_path = None - self.input_box.set_file_info(tmp) - # Hide chapters button since we're using custom text now - self.input_box.chapters_btn.hide() - if hasattr(self, "conversion_thread"): - self.conversion_thread.is_direct_text = True - except Exception as e: - self._show_error_message_box( - "Textbox Error", f"Could not process text input:\n{e}" - ) - - def update_speed_label(self): - s = self.speed_slider.value() / 100.0 - self.speed_label.setText(f"{s}") - self.config["speed"] = s - save_config(self.config) - - def update_subtitle_options_availability(self): - """ - Update the enabled state of subtitle options based on the selected language. - For non-English languages, only sentence-based and line-based modes are supported. - """ - # Check if current file is a subtitle file - is_subtitle_input = False - if self.selected_file and self.selected_file.lower().endswith( - (".srt", ".ass", ".vtt") - ): - is_subtitle_input = True - - if self.selected_lang not in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION: - self.subtitle_combo.setEnabled(False) - self.subtitle_format_combo.setEnabled(False) - return - - # Only enable subtitle_combo if it's NOT a subtitle input - self.subtitle_combo.setEnabled(not is_subtitle_input) - self.subtitle_format_combo.setEnabled(True) - - is_english = self.selected_lang in ["a", "b"] - - # Items to keep enabled for non-English - allowed_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma"] - - model = self.subtitle_combo.model() - for i in range(self.subtitle_combo.count()): - text = self.subtitle_combo.itemText(i) - item = model.item(i) - if not item: - continue - - if is_english: - item.setEnabled(True) - else: - if text in allowed_modes: - item.setEnabled(True) - else: - item.setEnabled(False) - - # If current selection is disabled, switch to a valid one - current_text = self.subtitle_combo.currentText() - current_idx = self.subtitle_combo.currentIndex() - current_item = model.item(current_idx) - - if current_item and not current_item.isEnabled(): - # Switch to "Sentence" if available, else "Disabled" - sentence_idx = self.subtitle_combo.findText("Sentence") - if sentence_idx >= 0: - self.subtitle_combo.setCurrentIndex(sentence_idx) - else: - self.subtitle_combo.setCurrentIndex(0) # Disabled - - self.subtitle_mode = self.subtitle_combo.currentText() - - def on_voice_changed(self, index): - voice = self.voice_combo.itemData(index) - self.selected_voice, self.selected_lang = voice, voice[0] - self.config["selected_voice"] = voice - save_config(self.config) - # Enable/disable subtitle options based on language - self.update_subtitle_options_availability() - - def on_voice_combo_changed(self, index): - data = self.voice_combo.itemData(index) - if isinstance(data, str) and data.startswith("profile:"): - pname = data.split(":", 1)[1] - self.selected_profile_name = pname - from abogen.voice_profiles import load_profiles - - entry = load_profiles().get(pname, {}) - # set mixed voices and language - if isinstance(entry, dict): - self.mixed_voice_state = entry.get("voices", []) - self.selected_lang = entry.get("language") - else: - self.mixed_voice_state = entry - self.selected_lang = entry[0][0] if entry and entry[0] else None - self.selected_voice = None - self.config["selected_profile_name"] = pname - self.config.pop("selected_voice", None) - save_config(self.config) - # enable subtitles based on profile language - self.update_subtitle_options_availability() - else: - self.mixed_voice_state = None - self.selected_profile_name = None - self.selected_voice, self.selected_lang = data, data[0] - self.config["selected_voice"] = data - if "selected_profile_name" in self.config: - del self.config["selected_profile_name"] - save_config(self.config) - self.update_subtitle_options_availability() - - def update_subtitle_combo_for_profile(self, profile_name): - from abogen.voice_profiles import load_profiles - - entry = load_profiles().get(profile_name, {}) - lang = entry.get("language") if isinstance(entry, dict) else None - enable = lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION - self.subtitle_combo.setEnabled(enable) - self.subtitle_format_combo.setEnabled(enable) - - def populate_profiles_in_voice_combo(self): - # preserve current voice or profile - current = self.voice_combo.currentData() - self.voice_combo.blockSignals(True) - self.voice_combo.clear() - # re-add profiles - profile_icon = QIcon(get_resource_path("abogen.assets", "profile.png")) - for pname in load_profiles().keys(): - self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}") - # re-add voices - for v in VOICES_INTERNAL: - icon = QIcon() - flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png") - if flag_path and os.path.exists(flag_path): - icon = QIcon(flag_path) - self.voice_combo.addItem(icon, f"{v}", v) - # restore selection - idx = -1 - if self.selected_profile_name: - idx = self.voice_combo.findData(f"profile:{self.selected_profile_name}") - elif current: - idx = self.voice_combo.findData(current) - if idx >= 0: - self.voice_combo.setCurrentIndex(idx) - # Also update subtitle combo for selected profile - data = self.voice_combo.itemData(idx) - if isinstance(data, str) and data.startswith("profile:"): - pname = data.split(":", 1)[1] - self.update_subtitle_combo_for_profile(pname) - self.voice_combo.blockSignals(False) - # If no profiles exist, clear selected_profile_name from config - if not load_profiles(): - if "selected_profile_name" in self.config: - del self.config["selected_profile_name"] - save_config(self.config) - - def convert_input_box_to_log(self): - self.input_box.hide() - self.log_text.show() - self.log_text.clear() - QApplication.processEvents() - - def restore_input_box(self): - self.log_text.hide() - self.input_box.show() - - def update_log(self, message): - # Use signal-based approach for thread-safe logging - if QThread.currentThread() != QApplication.instance().thread(): - # We're in a background thread, emit signal for the main thread - self.log_signal.emit_log(message) - return - - # Direct update if already on main thread - self._update_log_main_thread(message) - - def _update_log_main_thread(self, message): - txt = self.log_text - sb = txt.verticalScrollBar() - at_bottom = sb.value() == sb.maximum() - - cursor = txt.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.End) - - fmt = cursor.charFormat() - if isinstance(message, tuple): - text, spec = message - fmt.setForeground(QColor(LOG_COLOR_MAP.get(spec, COLORS["LIGHT_DISABLED"]))) - else: - text = str(message) - fmt.clearForeground() - cursor.setCharFormat(fmt) - cursor.insertText(text + "\n") - - doc = txt.document() - excess = doc.blockCount() - self.log_window_max_lines - if excess > 0: - start = doc.findBlockByNumber(0).position() - end = doc.findBlockByNumber(excess).position() - trim_cursor = QTextCursor(doc) - trim_cursor.setPosition(start) - trim_cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor) - trim_cursor.removeSelectedText() - - if at_bottom: - sb.setValue(sb.maximum()) - - def _get_queue_progress_format(self, value=None): - """Return the progress bar format string for queue mode.""" - if ( - hasattr(self, "queued_items") - and self.queued_items - and hasattr(self, "current_queue_index") - ): - N = self.current_queue_index + 1 - M = len(self.queued_items) - percent = value if value is not None else self.progress_bar.value() - return f"{percent}% ({N}/{M})" - else: - percent = value if value is not None else self.progress_bar.value() - return f"{percent}%" - - def update_progress(self, value, etr_str): # Add etr_str parameter - # Ensure progress doesn't exceed 99% - if value >= 100: - value = 99 - self.progress_bar.setValue(value) - # Show queue progress if in queue mode - if ( - hasattr(self, "queued_items") - and self.queued_items - and hasattr(self, "current_queue_index") - ): - N = self.current_queue_index + 1 - M = len(self.queued_items) - self.progress_bar.setFormat(f"{value}% ({N}/{M})") - else: - self.progress_bar.setFormat(f"{value}%") - self.etr_label.setText( - f"Estimated time remaining: {etr_str}" - ) # Update ETR label - self.etr_label.show() # Show only when estimate is ready - - # Disable cancel button if progress is >= 98% - if value >= 98: - self.btn_cancel.setEnabled(False) - - self.progress_bar.repaint() - QApplication.processEvents() - - def enable_disable_queue_buttons(self): - enabled = bool(self.queued_items) - self.btn_clear_queue.setEnabled(enabled) - # Update Manage Queue button text with count - if enabled: - self.btn_manage_queue.setText(f"Manage Queue ({len(self.queued_items)})") - self.btn_manage_queue.setStyleSheet( - f"QPushButton {{ color: {COLORS['GREEN']}; }}" - ) - else: - self.btn_manage_queue.setText("Manage Queue") - self.btn_manage_queue.setStyleSheet("") - # Change main Start button to 'Start queue' if queue has items - if enabled: - self.btn_start.setText(f"Start queue ({len(self.queued_items)})") - try: - self.btn_start.clicked.disconnect() - except Exception: - pass - self.btn_start.clicked.connect(self.start_queue) - else: - self.btn_start.setText("Start") - try: - self.btn_start.clicked.disconnect() - except Exception: - pass - self.btn_start.clicked.connect(self.start_conversion) - - def enqueue(self, item: QueuedItem): - self.queued_items.append(item) - # self.update_log((f"Enqueued: {item.file_name}", True)) - # enable start queue button, manage queue button - self.enable_disable_queue_buttons() - - def get_queue(self): - return self.queued_items - - def add_to_queue(self): - # For epub/pdf, always use the converted txt file (selected_file) - if self.selected_file_type in ["epub", "pdf", "md", "markdown"]: - file_to_queue = self.selected_file - # Use the original file path for save location - save_base_path = ( - self.displayed_file_path if self.displayed_file_path else file_to_queue - ) - else: - file_to_queue = ( - self.displayed_file_path - if self.displayed_file_path - else self.selected_file - ) - save_base_path = file_to_queue # For non-EPUB, it's the same - - if not file_to_queue: - self.input_box.set_error("Please add a file.") - return - actual_subtitle_mode = self.get_actual_subtitle_mode() - voice_formula = self.get_voice_formula() - selected_lang = self.get_selected_lang(voice_formula) - - item_queue = QueuedItem( - file_name=file_to_queue, - lang_code=selected_lang, - speed=self.speed_slider.value() / 100.0, - voice=voice_formula, - save_option=self.save_option, - output_folder=self.selected_output_folder, - subtitle_mode=actual_subtitle_mode, - output_format=self.selected_format, - total_char_count=self.char_count, - replace_single_newlines=self.replace_single_newlines, - use_silent_gaps=self.use_silent_gaps, - subtitle_speed_method=self.subtitle_speed_method, - save_base_path=save_base_path, - save_chapters_separately=getattr(self, "save_chapters_separately", None), - merge_chapters_at_end=getattr(self, "merge_chapters_at_end", None), - ) - - # Prevent adding duplicate items to the queue - for queued_item in self.queued_items: - if ( - queued_item.file_name == item_queue.file_name - and queued_item.lang_code == item_queue.lang_code - and queued_item.speed == item_queue.speed - and queued_item.voice == item_queue.voice - and queued_item.save_option == item_queue.save_option - and queued_item.output_folder == item_queue.output_folder - and queued_item.subtitle_mode == item_queue.subtitle_mode - and queued_item.output_format == item_queue.output_format - and getattr(queued_item, "replace_single_newlines", True) - == item_queue.replace_single_newlines - and getattr(queued_item, "save_base_path", None) - == item_queue.save_base_path - and getattr(queued_item, "save_chapters_separately", None) - == item_queue.save_chapters_separately - and getattr(queued_item, "merge_chapters_at_end", None) - == item_queue.merge_chapters_at_end - ): - QMessageBox.warning( - self, "Duplicate Item", "This item is already in the queue." - ) - return - - self.enqueue(item_queue) - # Clear input after adding to queue - self.input_box.clear_input() - self.input_box_cleared_by_queue = True # Set flag - self.enable_disable_queue_buttons() - - def clear_queue(self): - # Warn user if more than 1 item in the queue before clearing - if len(self.queued_items) > 1: - reply = QMessageBox.question( - self, - "Confirm Clear Queue", - f"Are you sure you want to clear {len(self.queued_items)} items from the queue?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if reply != QMessageBox.StandardButton.Yes: - return - self.queued_items = [] - self.enable_disable_queue_buttons() - - def manage_queue(self): - # show a dialog to manage the queue - dialog = QueueManager(self, self.queued_items) - if dialog.exec() == QDialog.DialogCode.Accepted: - self.queued_items = dialog.get_queue() - - # Reload config to capture the new "Override" setting - # The QueueManager writes to disk, so we must refresh our local copy - self.config = load_config() - - # re-enable/disable buttons based on queue state - self.enable_disable_queue_buttons() - - def start_queue(self): - self.current_queue_index = 0 # Start from the first item - # Set progress bar to 0% (1/M) immediately - if self.queued_items: - self.progress_bar.setValue(0) - self.progress_bar.setFormat(f"0% (1/{len(self.queued_items)})") - self.progress_bar.show() - self.start_next_queued_item() - - def start_next_queued_item(self): - if self.current_queue_index < len(self.queued_items): - queued_item = self.queued_items[self.current_queue_index] - - self.selected_file = queued_item.file_name - self.char_count = queued_item.total_char_count - - # Restore the original file path for save location (Important for EPUB/PDF) - self.displayed_file_path = ( - queued_item.save_base_path or queued_item.file_name - ) - - # Restore chapter options (Structure specific, must be preserved) - self.save_chapters_separately = getattr( - queued_item, "save_chapters_separately", None - ) - self.merge_chapters_at_end = getattr( - queued_item, "merge_chapters_at_end", None - ) - - # CHECK GLOBAL OVERRIDE SETTING - if not self.config.get("queue_override_settings", False): - self.selected_lang = queued_item.lang_code - self.speed_slider.setValue(int(queued_item.speed * 100)) - - # Load the specific voice string - self.selected_voice = queued_item.voice - # Clear complex GUI states so the specific voice string is used - self.mixed_voice_state = None - self.selected_profile_name = None - - self.save_option = queued_item.save_option - self.selected_output_folder = queued_item.output_folder - self.subtitle_mode = queued_item.subtitle_mode - self.selected_format = queued_item.output_format - self.replace_single_newlines = getattr( - queued_item, "replace_single_newlines", True - ) - self.use_silent_gaps = getattr(queued_item, "use_silent_gaps", False) - self.subtitle_speed_method = getattr( - queued_item, "subtitle_speed_method", "tts" - ) - - # This ensures that if conversion.py (or utils) reads from config/disk - # instead of using passed arguments, it sees the correct queue values. - self.config["replace_single_newlines"] = self.replace_single_newlines - self.config["subtitle_mode"] = self.subtitle_mode - self.config["selected_format"] = self.selected_format - self.config["use_silent_gaps"] = self.use_silent_gaps - self.config["subtitle_speed_method"] = self.subtitle_speed_method - - # Sync Voice/Profile in config - self.config["selected_voice"] = self.selected_voice - if "selected_profile_name" in self.config: - del self.config["selected_profile_name"] - - # Note: Speed is already synced via self.speed_slider.setValue() -> update_speed_label() - save_config(self.config) - - self.start_conversion(from_queue=True) - else: - # Queue finished, reset index - self.current_queue_index = 0 - - def queue_item_conversion_finished(self): - # Called after each conversion finishes - self.current_queue_index += 1 - if self.current_queue_index < len(self.queued_items): - self.start_next_queued_item() - else: - self.current_queue_index = 0 # Reset for next time - - def get_voice_formula(self) -> str: - if self.mixed_voice_state: - formula_components = [ - f"{name}*{weight}" for name, weight in self.mixed_voice_state - ] - return " + ".join(filter(None, formula_components)) - else: - return self.selected_voice - - def get_selected_lang(self, voice_formula) -> str: - if self.selected_profile_name: - from abogen.voice_profiles import load_profiles - - entry = load_profiles().get(self.selected_profile_name, {}) - selected_lang = entry.get("language") - else: - selected_lang = self.selected_voice[0] if self.selected_voice else None - # fallback: extract from formula if missing - if not selected_lang: - m = re.search(r"\b([a-z])", voice_formula) - selected_lang = m.group(1) if m else None - return selected_lang - - def get_actual_subtitle_mode(self) -> str: - return "Disabled" if not self.subtitle_combo.isEnabled() else self.subtitle_mode - - def start_conversion(self, from_queue=False): - if not self.selected_file: - self.input_box.set_error("Please add a file.") - return - - # Ensure we honor the currently selected save option when not running from queue - if not from_queue: - current_option = self.save_combo.currentText() - self.save_option = current_option - self.config["save_option"] = current_option - # If user is not choosing a specific folder, clear any residual folder - if current_option != "Choose output folder": - self.selected_output_folder = None - self.config["selected_output_folder"] = None - save_config(self.config) - - prevent_sleep_start() - self.is_converting = True - self.convert_input_box_to_log() - self.progress_bar.setValue(0) - # Show queue progress if in queue mode - if ( - from_queue - and hasattr(self, "queued_items") - and self.queued_items - and hasattr(self, "current_queue_index") - ): - N = self.current_queue_index + 1 - M = len(self.queued_items) - self.progress_bar.setFormat(f"0% ({N}/{M})") - else: - self.progress_bar.setFormat("%p%") # Reset format initially - self.etr_label.hide() # Hide ETR label initially - self.controls_widget.hide() - self.queue_row_widget.hide() # Hide queue row when process starts - self.progress_bar.show() - self.btn_cancel.show() - QApplication.processEvents() - self.btn_cancel.setEnabled(False) - self.start_time = time.time() - self.finish_widget.hide() - speed = self.speed_slider.value() / 100.0 - - # Get the display file path for logs - display_path = ( - self.displayed_file_path if self.displayed_file_path else self.selected_file - ) - - # Get file size string - try: - file_size_str = self.input_box._human_readable_size( - os.path.getsize(self.selected_file) - ) - except Exception: - file_size_str = "Unknown" - - # pipeline_loaded_callback remains unchanged - def pipeline_loaded_callback(np_module, kpipeline_class, error): - if error: - self.update_log((f"Error loading numpy or KPipeline: {error}", "red")) - prevent_sleep_end() - return - - self.btn_cancel.setEnabled(True) - - # Override subtitle_mode to "Disabled" if subtitle_combo is disabled - actual_subtitle_mode = self.get_actual_subtitle_mode() - - # if voice formula is not None, use the selected voice - voice_formula = self.get_voice_formula() - # determine selected language: use profile setting if profile selected, else voice code - selected_lang = self.get_selected_lang(voice_formula) - - self.conversion_thread = ConversionThread( - self.selected_file, - selected_lang, - speed, - voice_formula, - self.save_option, - self.selected_output_folder, - subtitle_mode=actual_subtitle_mode, - output_format=self.selected_format, - np_module=np_module, - kpipeline_class=kpipeline_class, - start_time=self.start_time, - total_char_count=self.char_count, - use_gpu=self.gpu_ok, - from_queue=from_queue, - save_base_path=self.displayed_file_path, # Pass the save base path (original file for EPUB) - ) # Use gpu_ok status - # Pass the displayed file path to the log_updated signal handler in ConversionThread - self.conversion_thread.display_path = display_path - # Pass the file size string - self.conversion_thread.file_size_str = file_size_str - # Pass max_subtitle_words from config - self.conversion_thread.max_subtitle_words = self.max_subtitle_words - # Pass silence_duration from config - self.conversion_thread.silence_duration = self.silence_duration - # Pass replace_single_newlines setting - self.conversion_thread.replace_single_newlines = ( - self.replace_single_newlines - ) - # Pass use_silent_gaps setting - self.conversion_thread.use_silent_gaps = self.use_silent_gaps - # Pass subtitle_speed_method setting - self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method - # Pass use_spacy_segmentation setting - self.conversion_thread.use_spacy_segmentation = self.use_spacy_segmentation - # Pass separate_chapters_format setting - self.conversion_thread.separate_chapters_format = ( - self.separate_chapters_format - ) - # Pass subtitle format setting - self.conversion_thread.subtitle_format = self.config.get( - "subtitle_format", "ass_centered_narrow" - ) - # Pass chapter count for EPUB or PDF files - if self.selected_file_type in ["epub", "pdf", "md", "markdown"] and hasattr( - self, "selected_chapters" - ): - self.conversion_thread.chapter_count = len(self.selected_chapters) - # Pass save_chapters_separately flag if available - self.conversion_thread.save_chapters_separately = getattr( - self, "save_chapters_separately", False - ) - # Pass merge_chapters_at_end flag if available - self.conversion_thread.merge_chapters_at_end = getattr( - self, "merge_chapters_at_end", True - ) - self.conversion_thread.progress_updated.connect(self.update_progress) - self.conversion_thread.log_updated.connect(self.update_log) - self.conversion_thread.conversion_finished.connect( - self.on_conversion_finished - ) - - # Connect chapters_detected signal - self.conversion_thread.chapters_detected.connect( - self.show_chapter_options_dialog - ) - - self.conversion_thread.start() - QApplication.processEvents() - - # Run GPU acceleration and module loading in a background thread - def gpu_and_load(): - self.update_log("Checking GPU acceleration...") - # Pass the use_gpu setting from the checkbox - gpu_msg, gpu_ok = get_gpu_acceleration(self.gpu_checkbox.isChecked()) - # Store gpu_ok status to use when creating the conversion thread - self.gpu_ok = gpu_ok - self.update_log((gpu_msg, gpu_ok)) - self.update_log("Loading modules...") - load_thread = LoadPipelineThread(pipeline_loaded_callback) - load_thread.start() - - threading.Thread(target=gpu_and_load, daemon=True).start() - - def show_queue_summary(self): - """Show a summary dialog after queue finishes.""" - if not self.queued_items: - return - - # Check if override was active (this determines which settings were ACTUALLY used) - override_active = self.config.get("queue_override_settings", False) - - # If override is ON, capture the global settings that were used for processing - if override_active: - g_voice = self.get_voice_formula() - g_lang = self.get_selected_lang(g_voice) - g_speed = self.speed_slider.value() / 100.0 - g_sub_mode = self.get_actual_subtitle_mode() - g_format = self.selected_format - g_newlines = self.replace_single_newlines - g_silent_gaps = self.use_silent_gaps - g_speed_method = self.subtitle_speed_method - - # Build HTML summary (Default Styling) - summary_html = "" - - header_text = "Queue finished" - if override_active: - header_text += " (Global Settings Applied)" - - summary_html += ( - f"

    {header_text}

    " - f"Processed {len(self.queued_items)} items:

    " - ) - - for idx, item in enumerate(self.queued_items, 1): - # Resolve Effective Settings - if override_active: - eff_lang = g_lang - eff_voice = g_voice - eff_speed = g_speed - eff_sub_mode = g_sub_mode - eff_format = g_format - eff_newlines = g_newlines - eff_silent = g_silent_gaps - eff_method = g_speed_method - else: - eff_lang = item.lang_code - eff_voice = item.voice - eff_speed = item.speed - eff_sub_mode = item.subtitle_mode - eff_format = item.output_format - eff_newlines = getattr(item, "replace_single_newlines", True) - eff_silent = getattr(item, "use_silent_gaps", False) - eff_method = getattr(item, "subtitle_speed_method", "tts") - - # Retrieve File-Specific Data (Never Overridden) - eff_chars = item.total_char_count - eff_input = item.file_name - eff_output = getattr(item, "output_path", "Unknown") - eff_save_sep = getattr(item, "save_chapters_separately", None) - eff_merge = getattr(item, "merge_chapters_at_end", None) - - # --- Construct Display Block --- - summary_html += ( - f"{idx}) {os.path.basename(eff_input)}
    " - f"Language: {eff_lang}
    " - f"Voice: {eff_voice}
    " - f"Speed: {eff_speed}
    " - f"Characters: {eff_chars}
    " - f"Format: {eff_format}
    " - f"Subtitle Mode: {eff_sub_mode}
    " - f"Method: {eff_method}
    " - f"Silent Gaps: {eff_silent}
    " - f"Repl. Newlines: {eff_newlines}
    " - ) - - # Book/Chapter specific options - if eff_save_sep is not None: - summary_html += f"Split Chapters: {eff_save_sep}
    " - if eff_save_sep and eff_merge is not None: - summary_html += f"Merge End: {eff_merge}
    " - - summary_html += ( - f"Input: {eff_input}
    " - f"Output: {eff_output}

    " - ) - - summary_html += "" - - dialog = QDialog(self) - dialog.setWindowTitle("Queue Summary") - # Allow resizing - dialog.resize(550, 650) - - layout = QVBoxLayout(dialog) - text_edit = QTextEdit(dialog) - text_edit.setReadOnly(True) - text_edit.setHtml(summary_html) - layout.addWidget(text_edit) - - close_btn = QPushButton("Close", dialog) - close_btn.setFixedHeight(36) - close_btn.clicked.connect(dialog.accept) - layout.addWidget(close_btn) - - dialog.setLayout(layout) - dialog.setMinimumSize(400, 300) - dialog.setSizeGripEnabled(True) - dialog.exec() - - def on_conversion_finished(self, message, output_path): - prevent_sleep_end() - if message == "Cancelled": - self.etr_label.hide() # Hide ETR label - self.progress_bar.hide() - self.btn_cancel.hide() - self.is_converting = False - self.controls_widget.show() - self.finish_widget.hide() - self.restore_input_box() - display_path = ( - self.displayed_file_path - if self.displayed_file_path - else self.selected_file - ) - # Only repopulate if not cleared by queue - if not getattr(self, "input_box_cleared_by_queue", False): - if display_path and os.path.exists(display_path): - self.input_box.set_file_info(display_path) - else: - self.input_box.clear_input() - else: - self.input_box.clear_input() - return - - self.update_log(message) - if output_path: - self.last_output_path = output_path - # Store output_path in the current queued item if in queue mode - if self.queued_items and self.current_queue_index < len(self.queued_items): - self.queued_items[self.current_queue_index].output_path = output_path - - self.etr_label.hide() # Hide ETR label - self.progress_bar.setValue(100) - self.progress_bar.hide() - self.btn_cancel.hide() - self.is_converting = False - elapsed = int(time.time() - self.start_time) - h, m, s = elapsed // 3600, (elapsed % 3600) // 60, elapsed % 60 - self.update_log((f"\nTime elapsed: {h:02d}:{m:02d}:{s:02d}", "grey")) - - # Default to showing the button - show_open_file_button = True - # Check conditions to hide the button (only if flags exist for the completed conversion) - save_sep = getattr(self, "save_chapters_separately", False) - merge_end = getattr( - self, "merge_chapters_at_end", True - ) # Default to True if flag doesn't exist - if save_sep and not merge_end: - show_open_file_button = False - - if self.open_file_btn: - self.open_file_btn.setVisible(show_open_file_button) - - # Only show finish_widget if queue is done - if ( - self.current_queue_index + 1 >= len(self.queued_items) - or not self.queued_items - ): - # Queue finished, show finish screen - self.controls_widget.hide() - self.finish_widget.show() - sb = self.log_text.verticalScrollBar() - sb.setValue(sb.maximum()) - save_config(self.config) - # Show queue summary if more than one item - if len(self.queued_items) > 1: - self.show_queue_summary() - else: - # More items in queue: clear log and reload for next item - self.log_text.clear() - QApplication.processEvents() - - # Start new queued item, if we're using a queued conversion - self.queue_item_conversion_finished() - - def reset_ui(self): - try: - self.etr_label.hide() # Hide ETR label - self.progress_bar.setValue(0) - self.progress_bar.hide() - self.selected_file = self.selected_file_type = self.selected_book_path = ( - None - ) - self.selected_chapters = set() # Reset selected chapters - - # Ensure open file button is visible when resetting - if self.open_file_btn: - self.open_file_btn.show() - self.controls_widget.show() - self.queue_row_widget.show() # Show queue row on reset - self.finish_widget.hide() - self.btn_start.setText("Start") - # Disconnect only if connected, then reconnect - try: - self.btn_start.clicked.disconnect() - except TypeError: - pass # Ignore error if not connected - self.btn_start.clicked.connect(self.start_conversion) - self.enable_disable_queue_buttons() - self.restore_input_box() - self.input_box.clear_input() # Reset text and style - # Trigger the "Clear Queue" button (simulate user click) - self.btn_clear_queue.click() - except Exception as e: - self._show_error_message_box("Reset Error", f"Could not reset UI:\n{e}") - - def go_back_ui(self): - self.finish_widget.hide() - self.controls_widget.show() - self.queue_row_widget.show() # Show queue row on go back - self.progress_bar.hide() - self.restore_input_box() - self.log_text.clear() - - # Use displayed_file_path instead of selected_file for EPUBs or PDFs - display_path = ( - self.displayed_file_path if self.displayed_file_path else self.selected_file - ) - - # Only repopulate if not cleared by queue - if not getattr(self, "input_box_cleared_by_queue", False): - if display_path and os.path.exists(display_path): - self.input_box.set_file_info(display_path) - else: - self.input_box.clear_input() - else: - self.input_box.clear_input() - - # Ensure open file button is visible when going back - if self.open_file_btn: - self.open_file_btn.show() - - def on_save_option_changed(self, option): - self.save_option = option - self.config["save_option"] = option - if option == "Choose output folder": - try: - folder = QFileDialog.getExistingDirectory( - self, "Select Output Folder", "" - ) - if folder: - self.selected_output_folder = folder - self.save_path_label.setText(folder) - self.save_path_row_widget.show() - self.config["selected_output_folder"] = folder - else: - self.save_option = "Save next to input file" - self.save_combo.setCurrentText(self.save_option) - self.config["save_option"] = self.save_option - except Exception as e: - self._show_error_message_box( - "Folder Dialog Error", f"Could not open folder dialog:\n{e}" - ) - self.save_option = "Save next to input file" - self.save_combo.setCurrentText(self.save_option) - self.config["save_option"] = self.save_option - else: - self.save_path_row_widget.hide() - self.selected_output_folder = None - self.config["selected_output_folder"] = None - save_config(self.config) - - def go_to_file(self): - path = self.last_output_path - if not path: - return - try: - # Check if path is a directory (for multiple chapter files) - if os.path.isdir(path): - folder = path - else: - folder = os.path.dirname(path) - QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) - except Exception as e: - self._show_error_message_box( - "Open Folder Error", f"Could not open folder:\n{e}" - ) - - def open_file(self): - path = self.last_output_path - if not path: - return - try: - # Check if path exists and is a file before opening - if os.path.exists(path): - if os.path.isdir(path): - self._show_error_message_box( - "Open File Error", - "Cannot open a directory as a file. Please use 'Go to folder' instead.", - ) - return - QDesktopServices.openUrl(QUrl.fromLocalFile(path)) - else: - self._show_error_message_box( - "Open File Error", f"File not found: {path}" - ) - except Exception as e: - self._show_error_message_box( - "Open File Error", f"Could not open file:\n{e}" - ) - - def _get_preview_cache_path(self): - """Generate the expected cache path for the current voice settings.""" - speed = self.speed_slider.value() / 100.0 - voice_to_cache = "" - lang_to_cache = "" - - if self.mixed_voice_state: - components = [f"{name}*{weight}" for name, weight in self.mixed_voice_state] - voice_formula = " + ".join(filter(None, components)) - voice_to_cache = voice_formula - if self.selected_profile_name: - from abogen.voice_profiles import load_profiles - - entry = load_profiles().get(self.selected_profile_name, {}) - lang_to_cache = entry.get("language") - else: - lang_to_cache = self.selected_lang - if not lang_to_cache and self.mixed_voice_state: - lang_to_cache = ( - self.mixed_voice_state[0][0][0] - if self.mixed_voice_state and self.mixed_voice_state[0][0] - else None - ) - elif self.selected_voice: - lang_to_cache = self.selected_voice[0] - voice_to_cache = self.selected_voice - else: # No voice or profile selected - return None - - if not lang_to_cache or not voice_to_cache: # Not enough info - return None - - cache_dir = get_user_cache_path("preview_cache") - - if "*" in voice_to_cache: # Voice formula - voice_id = ( - f"voice_formula_{hashlib.md5(voice_to_cache.encode()).hexdigest()[:8]}" - ) - else: # Single voice - voice_id = voice_to_cache - - filename = f"{voice_id}_{lang_to_cache}_{speed:.2f}.wav" - return os.path.join(cache_dir, filename) - - def preview_voice(self): - if self.preview_playing: - try: - if self.play_audio_thread and self.play_audio_thread.isRunning(): - # Call the stop method on PlayAudioThread to safely handle stopping - self.play_audio_thread.stop() - self.play_audio_thread.wait(500) # Wait a bit - except Exception as e: - print(f"Error stopping preview audio: {e}") - self._preview_cleanup() - return - - if hasattr(self, "preview_thread") and self.preview_thread.isRunning(): - return - - # Check for cache first - cached_path = self._get_preview_cache_path() - if cached_path and os.path.exists(cached_path): - print(f"Cache hit for {cached_path}") - self.btn_preview.setEnabled(False) # Disable button briefly - self.voice_combo.setEnabled(False) - self.btn_voice_formula_mixer.setEnabled(False) - self.btn_start.setEnabled(False) - - # Directly play from cache - self.preview_playing = True - self.btn_preview.setIcon(self.stop_icon) - self.btn_preview.setToolTip("Stop preview") - self.btn_preview.setEnabled(True) - - def cleanup_cached_play(): - self._preview_cleanup() - - try: - # Ensure pygame mixer is initialized for the audio thread - import pygame - - if not pygame.mixer.get_init(): - pygame.mixer.init() - - self.play_audio_thread = PlayAudioThread(cached_path) - self.play_audio_thread.finished.connect(cleanup_cached_play) - self.play_audio_thread.error.connect( - lambda msg: ( - self._show_preview_error_box(msg), - cleanup_cached_play(), - ) - ) - self.play_audio_thread.start() - except Exception as e: - self._show_error_message_box( - "Preview Error", f"Could not play cached preview audio:\n{e}" - ) - cleanup_cached_play() - return - - # If no cache hit, proceed to load pipeline and generate - self.btn_preview.setEnabled(False) - self.btn_preview.setToolTip("Loading...") - self.voice_combo.setEnabled(False) - self.btn_voice_formula_mixer.setEnabled(False) # Disable mixer button - self.btn_start.setEnabled(False) # Disable start button during preview - - # Start loading animation - ensure signal connection is always active - if hasattr(self, "loading_movie"): - # Disconnect previous connections to avoid multiple connections - try: - self.loading_movie.frameChanged.disconnect() - except TypeError: - pass # Ignore error if not connected - - # Reconnect the signal - self.loading_movie.frameChanged.connect( - lambda: self.btn_preview.setIcon( - QIcon(self.loading_movie.currentPixmap()) - ) - ) - self.loading_movie.start() - - def pipeline_loaded_callback(np_module, kpipeline_class, error): - self._on_pipeline_loaded_for_preview(np_module, kpipeline_class, error) - - load_thread = LoadPipelineThread(pipeline_loaded_callback) - load_thread.start() - - def _on_pipeline_loaded_for_preview(self, np_module, kpipeline_class, error): - # stop loading animation and restore icon on error - if error: - self.loading_movie.stop() - self._show_error_message_box( - "Loading Error", f"Error loading numpy or KPipeline: {error}" - ) - self.btn_preview.setIcon(self.play_icon) - self.btn_preview.setEnabled(True) - self.btn_preview.setToolTip("Preview selected voice") - self.voice_combo.setEnabled(True) - self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button - self.btn_start.setEnabled(True) # Re-enable start button on error - return - - # Support preview for voice profiles - speed = self.speed_slider.value() / 100.0 - if self.mixed_voice_state: - # Build voice formula string - components = [f"{name}*{weight}" for name, weight in self.mixed_voice_state] - voice = " + ".join(filter(None, components)) - # determine language: use profile setting, else explicit mixer selection, else fallback to first voice code - if self.selected_profile_name: - from abogen.voice_profiles import load_profiles - - entry = load_profiles().get(self.selected_profile_name, {}) - lang = entry.get("language") - else: - lang = self.selected_lang - if not lang and self.mixed_voice_state: - lang = ( - self.mixed_voice_state[0][0][0] - if self.mixed_voice_state and self.mixed_voice_state[0][0] - else None - ) - else: - lang = self.selected_voice[0] - voice = self.selected_voice - - # use same gpu/cpu logic as in conversion - gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu) - - self.preview_thread = VoicePreviewThread( - np_module, kpipeline_class, lang, voice, speed, gpu_ok - ) - self.preview_thread.finished.connect(self._play_preview_audio) - self.preview_thread.error.connect(self._preview_error) - self.preview_thread.start() - - def _play_preview_audio(self, from_cache=True): # from_cache default is now False - # If preview_thread is the source, get temp_wav from it - if hasattr(self, "preview_thread") and not from_cache: - temp_wav = self.preview_thread.temp_wav - elif from_cache: # This case is now handled before calling _play_preview_audio - cached_path = self._get_preview_cache_path() - if cached_path and os.path.exists(cached_path): - temp_wav = cached_path - else: # Should not happen if cache check was done - self._show_error_message_box( - "Preview Error", - "Cache file expected but not found, please try again.", - ) - self._preview_cleanup() - return - else: # Should have temp_wav from preview_thread or handled by cache check - self._show_error_message_box( - "Preview Error", "Preview audio path not found." - ) - self._preview_cleanup() - return - - if not temp_wav: - if hasattr(self, "loading_movie"): - self.loading_movie.stop() - self._show_error_message_box( - "Preview Error", "Preview error: No audio generated." - ) - self._preview_cleanup() - return - - # stop loading animation, switch to stop icon - if hasattr(self, "loading_movie"): - self.loading_movie.stop() - self.preview_playing = True - self.btn_preview.setIcon(self.stop_icon) - self.btn_preview.setToolTip("Stop preview") - self.btn_preview.setEnabled(True) - - def cleanup(): - # Only remove if not from cache AND it's a temp file from VoicePreviewThread - if ( - not from_cache - and hasattr(self, "preview_thread") - and hasattr(self.preview_thread, "temp_wav") - and self.preview_thread.temp_wav == temp_wav - ): - try: - if os.path.exists( - temp_wav - ): # Ensure it exists before trying to remove - os.remove(temp_wav) - except Exception: - pass - self._preview_cleanup() - - try: - # Ensure pygame mixer is initialized for the audio thread - import pygame - - if not pygame.mixer.get_init(): - pygame.mixer.init() - - self.play_audio_thread = PlayAudioThread(temp_wav) - self.play_audio_thread.finished.connect(cleanup) - self.play_audio_thread.error.connect( - lambda msg: (self._show_preview_error_box(msg), cleanup()) - ) - self.play_audio_thread.start() - except Exception as e: - self._show_error_message_box( - "Preview Error", f"Could not play preview audio:\n{e}" - ) - cleanup() - - def _show_error_message_box(self, title, message): - box = QMessageBox(self) - box.setIcon(QMessageBox.Icon.Critical) - box.setWindowTitle(title) - box.setText(message) - copy_btn = QPushButton("Copy") - box.addButton(copy_btn, QMessageBox.ButtonRole.ActionRole) - box.addButton(QMessageBox.StandardButton.Ok) - copy_btn.clicked.connect(lambda: QApplication.clipboard().setText(message)) - box.exec() - - def _show_preview_error_box(self, msg): - self._show_error_message_box("Preview Error", f"Preview error: {msg}") - - def _preview_cleanup(self): - self.preview_playing = False - if hasattr(self, "loading_movie"): - self.loading_movie.stop() - try: - if hasattr(self, "loading_movie"): - self.loading_movie.frameChanged.disconnect() - except Exception: - pass # Ignore error if not connected - self.btn_preview.setIcon(self.play_icon) - self.btn_preview.setToolTip("Preview selected voice") - self.btn_preview.setEnabled(True) - self.voice_combo.setEnabled(True) - self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button - self.btn_start.setEnabled(True) - - def _preview_error(self, msg): - self._show_error_message_box("Preview Error", f"Preview error: {msg}") - self._preview_cleanup() - - def cancel_conversion(self): - if self.is_converting: - box = QMessageBox(self) - box.setIcon(QMessageBox.Icon.Warning) - box.setWindowTitle("Cancel Conversion") - box.setText( - "A conversion is currently running. Are you sure you want to cancel?" - ) - box.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - ) - box.setDefaultButton(QMessageBox.StandardButton.No) - if box.exec() != QMessageBox.StandardButton.Yes: - return - try: - if ( - hasattr(self, "conversion_thread") - and self.conversion_thread.isRunning() - ): - if not hasattr(self, "_conversion_lock"): - self._conversion_lock = threading.Lock() - - def _cancel(): - with self._conversion_lock: - self.conversion_thread.cancel() # <-- Use cancel() method - self.conversion_thread.wait() - - threading.Thread(target=_cancel, daemon=True).start() - - self.is_converting = False - self.etr_label.hide() # Hide ETR label - self.progress_bar.hide() - self.btn_cancel.hide() - self.controls_widget.show() - self.queue_row_widget.show() # Show queue row on cancel - self.finish_widget.hide() - self.restore_input_box() - self.log_text.clear() - display_path = ( - self.displayed_file_path - if self.displayed_file_path - else self.selected_file - ) - # Only repopulate if not cleared by queue - if not getattr(self, "input_box_cleared_by_queue", False): - if display_path and os.path.exists(display_path): - self.input_box.set_file_info(display_path) - else: - self.input_box.clear_input() - else: - self.input_box.clear_input() - prevent_sleep_end() - except Exception as e: - self._show_error_message_box( - "Cancel Error", f"Could not cancel conversion:\n{e}" - ) - - def on_subtitle_mode_changed(self, mode): - self.subtitle_mode = mode - self.config["subtitle_mode"] = mode - save_config(self.config) - # Disable subtitle format combo if subtitles are disabled - if mode == "Disabled": - self.subtitle_format_combo.setEnabled(False) - else: - self.subtitle_format_combo.setEnabled(True) - # If highlighting mode selected, SRT is not supported. Disable SRT option and - # switch away from it if currently selected. - try: - idx_srt = self.subtitle_format_combo.findData("srt") - if mode == "Sentence + Highlighting": - if idx_srt >= 0: - item = self.subtitle_format_combo.model().item(idx_srt) - if item is not None: - item.setEnabled(False) - # If current format is SRT, switch to a compatible ASS format - if self.subtitle_format_combo.currentData() == "srt": - new_idx = self.subtitle_format_combo.findData("ass_centered_narrow") - if new_idx >= 0: - self.subtitle_format_combo.setCurrentIndex(new_idx) - self.set_subtitle_format( - self.subtitle_format_combo.itemData(new_idx) - ) - else: - # Re-enable SRT option when not in highlighting mode - if idx_srt >= 0: - item = self.subtitle_format_combo.model().item(idx_srt) - if item is not None: - item.setEnabled(True) - except Exception: - # Ignore errors interacting with model (defensive) - pass - - def on_format_changed(self, fmt): - self.selected_format = fmt - self.config["selected_format"] = fmt - save_config(self.config) - - def on_gpu_setting_changed(self, state): - self.use_gpu = state == Qt.CheckState.Checked.value - self.config["use_gpu"] = self.use_gpu - save_config(self.config) - - def cleanup_conversion_thread(self): - # Stop conversion thread - if ( - hasattr(self, "conversion_thread") - and self.conversion_thread is not None - and self.conversion_thread.isRunning() - ): - self.conversion_thread.cancel() - self.conversion_thread.wait() - - def cleanup_preview_threads(self): - # Stop preview generation thread - if ( - hasattr(self, "preview_thread") - and self.preview_thread is not None - and self.preview_thread.isRunning() - ): - self.preview_thread.terminate() - self.preview_thread.wait() - - # Stop audio playback thread - if ( - hasattr(self, "play_audio_thread") - and self.play_audio_thread is not None - and self.play_audio_thread.isRunning() - ): - self.play_audio_thread.stop() - self.play_audio_thread.wait() - - # Cleanup pygame mixer if initialized - try: - pygame = sys.modules.get("pygame") - if pygame and pygame.mixer.get_init(): - pygame.mixer.quit() - except Exception: - pass - - def closeEvent(self, event): - if self.is_converting: - box = QMessageBox(self) - box.setIcon(QMessageBox.Icon.Warning) - box.setWindowTitle("Conversion in Progress") - box.setText( - "A conversion is currently running. Are you sure you want to exit?" - ) - box.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - ) - box.setDefaultButton(QMessageBox.StandardButton.No) - if box.exec() == QMessageBox.StandardButton.Yes: - self.cleanup_conversion_thread() - self.cleanup_preview_threads() - event.accept() - else: - event.ignore() - else: - self.cleanup_conversion_thread() - self.cleanup_preview_threads() - event.accept() - - def show_chapter_options_dialog(self, chapter_count): - """Show dialog to ask user about chapter processing options when chapters are detected in a .txt file""" - # Check if this is a timestamp detection (-1) or chapter detection - if chapter_count == -1: - from abogen.conversion import TimestampDetectionDialog - - dialog = TimestampDetectionDialog(parent=self) - dialog.setWindowModality(Qt.WindowModality.ApplicationModal) - - # Dialog always accepts (Yes or No), never cancels the conversion - dialog.exec() - treat_as_subtitle = dialog.use_timestamps() - if ( - hasattr(self, "conversion_thread") - and self.conversion_thread.isRunning() - ): - self.conversion_thread.set_timestamp_response(treat_as_subtitle) - return - - # Normal chapter detection - from abogen.conversion import ChapterOptionsDialog - - dialog = ChapterOptionsDialog(chapter_count, parent=self) - dialog.setWindowModality(Qt.WindowModality.ApplicationModal) - - if dialog.exec() == QDialog.DialogCode.Accepted: - options = dialog.get_options() - if ( - hasattr(self, "conversion_thread") - and self.conversion_thread.isRunning() - ): - self.conversion_thread.set_chapter_options(options) - else: - self.cancel_conversion() - - def apply_theme(self, theme): - - app = QApplication.instance() - is_windows = platform.system() == "Windows" - available_styles = [s.lower() for s in QStyleFactory.keys()] - - def is_windows_dark_mode(): - try: - import winreg - - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", - ) as key: - value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") - return value == 0 - except Exception: - return False - - # --- Theme selection logic --- - def set_dark_palette(): - palette = QPalette() - dark_bg = QColor(COLORS["DARK_BG"]) - base_bg = QColor(COLORS["DARK_BASE"]) - alt_bg = QColor(COLORS["DARK_ALT"]) - button_bg = QColor(COLORS["DARK_BUTTON"]) - disabled_fg = QColor(COLORS["DARK_DISABLED"]) - palette.setColor(QPalette.ColorRole.Window, dark_bg) - palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white) - palette.setColor(QPalette.ColorRole.Base, base_bg) - palette.setColor(QPalette.ColorRole.AlternateBase, alt_bg) - palette.setColor(QPalette.ColorRole.ToolTipBase, dark_bg) - palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white) - palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white) - palette.setColor(QPalette.ColorRole.Button, button_bg) - palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white) - # Disabled roles - palette.setColor( - QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_fg - ) - palette.setColor( - QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_fg - ) - palette.setColor( - QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_fg - ) - palette.setColor( - QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, dark_bg - ) - palette.setColor( - QPalette.ColorGroup.Disabled, QPalette.ColorRole.Button, dark_bg - ) - app.setPalette(palette) - - def set_light_palette(): - palette = QPalette() - disabled_fg = QColor(COLORS["LIGHT_DISABLED"]) - palette.setColor(QPalette.ColorRole.Window, QColor(COLORS["LIGHT_BG"])) - palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.black) - palette.setColor(QPalette.ColorRole.Base, Qt.GlobalColor.white) - palette.setColor(QPalette.ColorRole.AlternateBase, Qt.GlobalColor.white) - palette.setColor(QPalette.ColorRole.ToolTipBase, Qt.GlobalColor.white) - palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.black) - palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.black) - palette.setColor(QPalette.ColorRole.Button, Qt.GlobalColor.white) - palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.black) - # Disabled roles - palette.setColor( - QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_fg - ) - palette.setColor( - QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_fg - ) - palette.setColor( - QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_fg - ) - palette.setColor( - QPalette.ColorGroup.Disabled, - QPalette.ColorRole.Base, - Qt.GlobalColor.white, - ) - palette.setColor( - QPalette.ColorGroup.Disabled, - QPalette.ColorRole.Button, - Qt.GlobalColor.white, - ) - app.setPalette(palette) - - # --- Dark title bar support for Windows --- - def set_title_bar_dark_mode(window, enable): - if is_windows: - try: - window.update() - DWMWA_USE_IMMERSIVE_DARK_MODE = 20 - set_window_attribute = ctypes.windll.dwmapi.DwmSetWindowAttribute - hwnd = int(window.winId()) - value = ctypes.c_int(2 if enable else 0) - set_window_attribute( - hwnd, - DWMWA_USE_IMMERSIVE_DARK_MODE, - ctypes.byref(value), - ctypes.sizeof(value), - ) - except Exception: - pass - - # Main logic - dark_mode = theme == "dark" or ( - theme == "system" and is_windows and is_windows_dark_mode() - ) - if dark_mode: - app.setStyle("Fusion") - set_dark_palette() - elif (theme == "light" or theme == "system") and is_windows: - if "windowsvista" in available_styles: - app.setStyle("windowsvista") - else: - app.setStyle("Fusion") - app.setPalette(QPalette()) - elif theme == "light": - app.setStyle("Fusion") - set_light_palette() - else: - app.setStyle("Fusion") - app.setPalette(QPalette()) - - # Always set the title bar mode according to the current theme for all top-level widgets - for widget in app.topLevelWidgets(): - set_title_bar_dark_mode(widget, dark_mode) - - # Refresh all top-level widgets - style_name = app.style().objectName() - app.setStyle(style_name) - for widget in app.topLevelWidgets(): - app.style().polish(widget) - widget.update() - - # Remove old event filter if present, then install a new one for dark title bar on new windows - if hasattr(app, "_dark_titlebar_event_filter"): - app.removeEventFilter(app._dark_titlebar_event_filter) - delattr(app, "_dark_titlebar_event_filter") - - def get_dark_mode(): - return theme == "dark" or ( - theme == "system" and is_windows and is_windows_dark_mode() - ) - - app._dark_titlebar_event_filter = DarkTitleBarEventFilter( - is_windows, get_dark_mode, set_title_bar_dark_mode - ) - app.installEventFilter(app._dark_titlebar_event_filter) - - # Save config if changed - if self.config.get("theme", "system") != theme: - self.config["theme"] = theme - save_config(self.config) - - def show_settings_menu(self): - """Show a dropdown menu for settings options.""" - menu = QMenu(self) - - theme_menu = QMenu("Theme", self) - theme_menu.setToolTip("Choose the application theme") - - theme_group = QActionGroup(self) - theme_group.setExclusive(True) - - # Theme options: (internal_value, display_text) - theme_options = [ - ("system", "System"), - ("light", "Light"), - ("dark", "Dark"), - ] - - # Get current theme from config, default to "system" - current_theme = self.config.get("theme", "system") - for value, text in theme_options: - theme_action = QAction(text, self) - theme_action.setCheckable(True) - theme_action.setChecked(current_theme == value) - theme_action.triggered.connect(lambda checked, v=value: self.apply_theme(v)) - theme_group.addAction(theme_action) - theme_menu.addAction(theme_action) - - menu.addMenu(theme_menu) - - # Add separate chapters format option - separate_chapters_format_menu = QMenu("Separate chapters audio format", self) - separate_chapters_format_menu.setToolTip( - "Choose the format for individual chapter files" - ) - - format_group = QActionGroup(self) - format_group.setExclusive(True) - - for format_option in ["wav", "flac", "mp3", "opus"]: - format_action = QAction(format_option, self) - format_action.setCheckable(True) - format_action.setChecked(self.separate_chapters_format == format_option) - format_action.triggered.connect( - lambda checked, fmt=format_option: self.set_separate_chapters_format( - fmt - ) - ) - format_group.addAction(format_action) - separate_chapters_format_menu.addAction(format_action) - - menu.addMenu(separate_chapters_format_menu) - - # Add max words per subtitle option - max_words_action = QAction("Configure max words per subtitle", self) - max_words_action.triggered.connect(self.set_max_subtitle_words) - menu.addAction(max_words_action) - - # Add silence between chapters option - silence_action = QAction("Configure silence between chapters", self) - silence_action.triggered.connect(self.set_silence_between_chapters) - menu.addAction(silence_action) - - max_lines_action = QAction("Configure max lines in log window", self) - max_lines_action.triggered.connect(self.set_max_log_lines) - menu.addAction(max_lines_action) - - # Add separator - menu.addSeparator() - - # Add shortcut to desktop (Windows or Linux) - if platform.system() == "Windows" or platform.system() == "Linux": - # Use extended label on Linux - label = ( - "Create desktop shortcut and install" - if platform.system() == "Linux" - else "Create desktop shortcut" - ) - add_shortcut_action = QAction(label, self) - add_shortcut_action.triggered.connect(self.add_shortcut_to_desktop) - menu.addAction(add_shortcut_action) - - # Add reveal config option - reveal_config_action = QAction("Open configuration directory", self) - reveal_config_action.triggered.connect(self.reveal_config_in_explorer) - menu.addAction(reveal_config_action) - - # Add open cache directory option - open_cache_action = QAction("Open cache directory", self) - open_cache_action.triggered.connect(self.open_cache_directory) - menu.addAction(open_cache_action) - - # Add clear cache files option - clear_cache_action = QAction("Clear cache files", self) - clear_cache_action.triggered.connect(self.clear_cache_files) - menu.addAction(clear_cache_action) - - # Add separator - menu.addSeparator() - - # Add use silent gaps option (for subtitle files) - self.silent_gaps_action = QAction("Use silent gaps between subtitles", self) - self.silent_gaps_action.setCheckable(True) - self.silent_gaps_action.setChecked(self.use_silent_gaps) - self.silent_gaps_action.triggered.connect( - lambda checked: self.toggle_use_silent_gaps(checked) - ) - menu.addAction(self.silent_gaps_action) - - # Subtitle speed adjustment method - speed_method_menu = menu.addMenu("Subtitle speed adjustment method") - speed_method_menu.setToolTip( - "Choose speed adjustment method:\n" - "TTS Regeneration: Better quality\n" - "FFmpeg Time-stretch: Faster processing" - ) - - speed_method_group = QActionGroup(self) - speed_method_group.setExclusive(True) - - for method, label in [ - ("tts", "TTS Regeneration (better quality)"), - ("ffmpeg", "FFmpeg Time-stretch (better speed)"), - ]: - action = QAction(label, speed_method_menu) - action.setCheckable(True) - action.setChecked(self.subtitle_speed_method == method) - action.triggered.connect( - lambda checked, m=method: self.toggle_subtitle_speed_method(m) - ) - speed_method_group.addAction(action) - speed_method_menu.addAction(action) - - self.speed_method_group = speed_method_group - - # Add separator - menu.addSeparator() - - # Add spaCy sentence segmentation option - spacy_action = QAction("Use spaCy for sentence segmentation", self) - spacy_action.setCheckable(True) - spacy_action.setChecked(self.use_spacy_segmentation) - spacy_action.triggered.connect( - lambda checked: self.toggle_spacy_segmentation(checked) - ) - menu.addAction(spacy_action) - - # Add separator - menu.addSeparator() - - # Add "Pre-download models and voices for offline use" option - predownload_action = QAction( - "Pre-download models and voices for offline use", self - ) - predownload_action.triggered.connect(self.show_predownload_dialog) - menu.addAction(predownload_action) - - # Add "Disable Kokoro's internet access" option - disable_kokoro_action = QAction("Disable Kokoro's internet access", self) - disable_kokoro_action.setCheckable(True) - disable_kokoro_action.setChecked( - self.config.get("disable_kokoro_internet", False) - ) - disable_kokoro_action.triggered.connect( - lambda checked: self.toggle_kokoro_internet_access(checked) - ) - menu.addAction(disable_kokoro_action) - - # Add check for updates option - check_updates_action = QAction("Check for updates at startup", self) - check_updates_action.setCheckable(True) - check_updates_action.setChecked(self.config.get("check_updates", True)) - check_updates_action.triggered.connect(self.toggle_check_updates) - menu.addAction(check_updates_action) - - # Add "Reset to default settings" option - reset_defaults_action = QAction("Reset to default settings", self) - reset_defaults_action.triggered.connect(self.reset_to_default_settings) - menu.addAction(reset_defaults_action) - - # Add about action - about_action = QAction("About", self) - about_action.triggered.connect(self.show_about_dialog) - menu.addAction(about_action) - - menu.exec(self.settings_btn.mapToGlobal(QPoint(0, self.settings_btn.height()))) - - def toggle_replace_single_newlines(self, enabled): - self.replace_single_newlines = enabled - self.config["replace_single_newlines"] = enabled - save_config(self.config) - - def toggle_use_silent_gaps(self, enabled): - # Show confirmation dialog with explanation - action = "enable" if enabled else "disable" - message = ( - "When enabled, allows speech to continue naturally into the silent periods between subtitles, " - "preventing unnecessary audio speed-up based on subtitle end timestamps.\n\nWhen disabled, ensures strict subtitle timing where " - f"audio ends exactly when the subtitle ends.\n\nDo you want to {action} this option?" - ) - - reply = QMessageBox.question( - self, - "Use Silent Gaps Between Subtitles", - message, - QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, - ) - - if reply == QMessageBox.StandardButton.Ok: - self.use_silent_gaps = enabled - self.config["use_silent_gaps"] = enabled - save_config(self.config) - else: - # Revert the checkbox state if cancelled - self.silent_gaps_action.setChecked(not enabled) - - def toggle_subtitle_speed_method(self, method): - self.subtitle_speed_method = method - self.config["subtitle_speed_method"] = method - save_config(self.config) - - def toggle_spacy_segmentation(self, enabled): - self.use_spacy_segmentation = enabled - self.config["use_spacy_segmentation"] = enabled - save_config(self.config) - - def restart_app(self): - - import sys - - exe = sys.executable - args = sys.argv - - # On Windows, use .exe if available - if platform.system() == "Windows": - script_path = args[0] - if not script_path.lower().endswith(".exe"): - exe_path = os.path.splitext(script_path)[0] + ".exe" - if os.path.exists(exe_path): - args[0] = exe_path - - QProcess.startDetached(exe, args) - QApplication.quit() - - def toggle_kokoro_internet_access(self, disabled): - if disabled: - message = ( - "Disabling Kokoro's internet access will block downloads of models and voices from Hugging Face Hub. " - "This can make processing faster when there is no internet connection, since no requests will be made. " - "The app needs to restart to apply this change.\n\nDo you want to continue?" - ) - else: - message = ( - "Enabling Kokoro's internet access will allow it to download models and voices from Hugging Face Hub. " - "The app needs to restart to apply this change.\n\nDo you want to continue?" - ) - reply = QMessageBox.question( - self, - "Restart Required", - message, - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if reply == QMessageBox.StandardButton.Yes: - self.config["disable_kokoro_internet"] = disabled - save_config(self.config) - try: - self.restart_app() - except Exception as e: - QMessageBox.critical( - self, "Restart Failed", f"Failed to restart the application:\n{e}" - ) - - def reset_to_default_settings(self): - reply = QMessageBox.question( - self, - "Reset Settings", - "This will reset all settings to their default values and restart the application.\n\nDo you want to continue?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if reply == QMessageBox.StandardButton.Yes: - from abogen.utils import get_user_config_path - - config_path = get_user_config_path() - try: - if os.path.exists(config_path): - os.remove(config_path) - self.restart_app() - except Exception as e: - QMessageBox.critical( - self, "Reset Error", f"Could not reset settings:\n{e}" - ) - - def reveal_config_in_explorer(self): - """Open the configuration file location in file explorer.""" - from abogen.utils import get_user_config_path - - try: - config_path = get_user_config_path() - # Open the directory containing the config file - QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(config_path))) - except Exception as e: - QMessageBox.critical( - self, "Config Error", f"Could not open config location:\n{e}" - ) - - def open_cache_directory(self): - """Open the cache directory used by the program.""" - try: - # Get the abogen cache directory - cache_dir = get_user_cache_path() - - # Create the directory if it doesn't exist - if not os.path.exists(cache_dir): - os.makedirs(cache_dir) - - # Open the directory in file explorer - QDesktopServices.openUrl(QUrl.fromLocalFile(cache_dir)) - except Exception as e: - QMessageBox.critical( - self, "Cache Directory Error", f"Could not open cache directory:\n{e}" - ) - - def add_shortcut_to_desktop(self): - """Create a desktop shortcut to this program using PowerShell.""" - import sys - from platformdirs import user_desktop_dir - from abogen.utils import create_process - - try: - if platform.system() == "Windows": - # where to put the .lnk - desktop = user_desktop_dir() - shortcut_path = os.path.join(desktop, "abogen.lnk") - - # target exe - python_dir = os.path.dirname(sys.executable) - target = os.path.join(python_dir, "Scripts", "abogen.exe") - if not os.path.exists(target): - QMessageBox.critical( - self, - "Shortcut Error", - f"Could not find abogen.exe at:\n{target}", - ) - return - - # icon (fallback to exe if missing) - icon = get_resource_path("abogen.assets", "icon.ico") - if not icon or not os.path.exists(icon): - icon = target # Create a more direct PowerShell command - shortcut_ps = shortcut_path.replace("'", "''").replace("\\", "\\\\") - target_ps = target.replace("'", "''").replace("\\", "\\\\") - workdir_ps = ( - os.path.dirname(target).replace("'", "''").replace("\\", "\\\\") - ) - icon_ps = icon.replace("'", "''").replace("\\", "\\\\") - # Create PowerShell script as a single line with no line breaks (more reliable) - ps_cmd = f"$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('{shortcut_ps}'); $lnk.TargetPath='{target_ps}'; $lnk.WorkingDirectory='{workdir_ps}'; $lnk.IconLocation='{icon_ps}'; $lnk.Save()" - - # Run PowerShell with the command directly - proc = create_process( - 'powershell -NoProfile -ExecutionPolicy Bypass -Command "' - + ps_cmd - + '"' - ) - proc.wait() - - if proc.returncode == 0: - QMessageBox.information( - self, - "Shortcut Created", - f"Shortcut created on desktop:\n{shortcut_path}", - ) - else: - QMessageBox.critical( - self, - "Shortcut Error", - f"PowerShell failed with exit code: {proc.returncode}", - ) - elif platform.system() == "Linux": - desktop = user_desktop_dir() - if not desktop or not os.path.isdir(desktop): - QMessageBox.critical( - self, "Shortcut Error", "Could not determine desktop directory." - ) - return - - shortcut_path = os.path.join(desktop, "abogen.desktop") - - import shutil - - found = shutil.which("abogen") - if found: - target = found - else: - local_bin = os.path.expanduser("~/.local/bin/abogen") - if os.path.exists(local_bin): - target = local_bin - else: - python_dir = os.path.dirname(sys.executable) - target = os.path.join(python_dir, "bin", "abogen") - if not os.path.exists(target): - target_fallback = os.path.join(python_dir, "abogen") - if os.path.exists(target_fallback): - target = target_fallback - else: - QMessageBox.critical( - self, - "Shortcut Error", - "Could not find abogen executable in PATH or common installation directories.", - ) - return - - icon_path = get_resource_path("abogen.assets", "icon.png") - - desktop_entry_content = f"""[Desktop Entry] -Version={VERSION} -Name={PROGRAM_NAME} -Comment={PROGRAM_DESCRIPTION} -Exec={target} -Icon={icon_path} -Terminal=false -Type=Application -Categories=AudioVideo;Audio;Utility; +The actual implementation lives in abogen.pyqt.gui. """ - with open(shortcut_path, "w", encoding="utf-8") as f: - f.write(desktop_entry_content) - os.chmod(shortcut_path, 0o755) +from __future__ import annotations - QMessageBox.information( - self, - "Shortcut Created", - f"Shortcut created on desktop:\n{shortcut_path}", - ) +from abogen.pyqt.gui import * # noqa: F401, F403 +from abogen.pyqt.gui import abogen - # Offer installation for current user under ~/.local/share/applications - reply = QMessageBox.question( - self, - "Install Application Entry", - "Install application entry for current user?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if reply == QMessageBox.StandardButton.Yes: - import shutil - - user_app_dir = os.path.expanduser("~/.local/share/applications") - os.makedirs(user_app_dir, exist_ok=True) - user_entry = os.path.join(user_app_dir, "abogen.desktop") - try: - shutil.copyfile(shortcut_path, user_entry) - os.chmod(user_entry, 0o644) - QMessageBox.information( - self, - "Installation Complete", - f"Desktop entry installed to {user_entry}", - ) - except Exception as e: - QMessageBox.warning( - self, - "Install Error", - f"Could not install entry:\n{e}", - ) - else: - QMessageBox.information( - self, - "Unsupported OS", - "Desktop shortcut creation is not supported on this operating system.", - ) - - except Exception as e: - QMessageBox.critical( - self, "Shortcut Error", f"Could not create shortcut:\n{e}" - ) - - def toggle_check_updates(self, checked): - self.config["check_updates"] = checked - save_config(self.config) - - def show_voice_formula_dialog(self): - from abogen.voice_profiles import load_profiles - - profiles = load_profiles() - initial_state = None - selected_profile = self.selected_profile_name - if selected_profile: - entry = profiles.get(selected_profile, {}) - if isinstance(entry, dict): - initial_state = entry.get("voices", []) - else: - initial_state = entry - elif self.mixed_voice_state is not None: - initial_state = self.mixed_voice_state - elif self.selected_voice: - # If a single voice is selected, default to first profile if available - if profiles: - first_profile = next(iter(profiles)) - entry = profiles[first_profile] - selected_profile = first_profile - if isinstance(entry, dict): - initial_state = entry.get("voices", []) - else: - initial_state = entry - self.selected_lang = entry[0][0] if entry and entry[0] else None - dialog = VoiceFormulaDialog( - self, initial_state=initial_state, selected_profile=selected_profile - ) - if dialog.exec() == QDialog.DialogCode.Accepted: - if dialog.current_profile: - self.selected_profile_name = dialog.current_profile - self.config["selected_profile_name"] = dialog.current_profile - if "selected_voice" in self.config: - del self.config["selected_voice"] - save_config(self.config) - self.populate_profiles_in_voice_combo() - idx = self.voice_combo.findData(f"profile:{dialog.current_profile}") - if idx >= 0: - self.voice_combo.setCurrentIndex(idx) - self.mixed_voice_state = dialog.get_selected_voices() - - def show_predownload_dialog(self): - """Show the pre-download models and voices dialog.""" - from abogen.predownload_gui import PreDownloadDialog - - dialog = PreDownloadDialog(self) - dialog.exec() - - def show_about_dialog(self): - """Show an About dialog with program information including GitHub link.""" - # Get application icon for dialog - icon = self.windowIcon() - - # Create custom dialog - dialog = QDialog(self) - dialog.setWindowTitle(f"About {PROGRAM_NAME}") - dialog.setWindowFlags( - dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint - ) - dialog.setFixedSize(400, 320) # Increased height for new button - - layout = QVBoxLayout(dialog) - layout.setSpacing(10) - - # Header with icon and title - header_layout = QHBoxLayout() - icon_label = QLabel() - if not icon.isNull(): - icon_label.setPixmap(icon.pixmap(64, 64)) - else: - # Fallback text if icon not available - icon_label.setText("📚") - icon_label.setStyleSheet("font-size: 48px;") - - header_layout.addWidget(icon_label) - - # Fix: Added style to reduce space between h1 and h3 - title_label = QLabel( - f"

    {PROGRAM_NAME} v{VERSION}

    Audiobook Generator

    " - ) - title_label.setTextFormat(Qt.TextFormat.RichText) - header_layout.addWidget(title_label, 1) - layout.addLayout(header_layout) - - # Description - desc_label = QLabel( - f"

    {PROGRAM_DESCRIPTION}

    " - "

    Visit the GitHub repository for updates, documentation, and to report issues.

    " - ) - desc_label.setTextFormat(Qt.TextFormat.RichText) - desc_label.setWordWrap(True) - layout.addWidget(desc_label) - - # GitHub link - github_btn = QPushButton("Visit GitHub Repository") - github_btn.setIcon(QIcon(get_resource_path("abogen.assets", "github.png"))) - github_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(GITHUB_URL))) - github_btn.setFixedHeight(32) - layout.addWidget(github_btn) - - # Check for updates button - update_btn = QPushButton("Check for updates") - update_btn.clicked.connect(self.manual_check_for_updates) - update_btn.setFixedHeight(32) - layout.addWidget(update_btn) - - # Close button - close_btn = QPushButton("Close") - close_btn.clicked.connect(dialog.accept) - close_btn.setFixedHeight(32) - layout.addWidget(close_btn) - - dialog.exec() - - def manual_check_for_updates(self): - """Manually check for updates and always show result""" - # Set a flag to always show the result message - self._show_update_check_result = True - self.check_for_updates_startup() - - def check_for_updates_startup(self): - import urllib.request - - def show_update_message(remote_version, local_version): - msg_box = QMessageBox(self) - msg_box.setIcon(QMessageBox.Icon.Information) - msg_box.setWindowTitle("Update Available") - msg_box.setText( - f"A new version of {PROGRAM_NAME} is available! ({local_version} > {remote_version})" - ) - msg_box.setInformativeText( - f"If you installed via pip, update by running:\n" - f"pip install --upgrade {PROGRAM_NAME}\n\n" - f"If you're using the Windows portable version, run 'WINDOWS_INSTALL.bat' again.\n\n" - "Alternatively, visit the GitHub repository for more information. " - "Would you like to view the changelog?" - ) - msg_box.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - ) - msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) - if msg_box.exec() == QMessageBox.StandardButton.Yes: - try: - QDesktopServices.openUrl(QUrl(GITHUB_URL + "/releases/latest")) - except Exception: - pass - - # Reset flag to track if we should show "no updates" message - show_result = ( - hasattr(self, "_show_update_check_result") - and self._show_update_check_result - ) - self._show_update_check_result = False - - try: - update_url = "https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/abogen/VERSION" - with urllib.request.urlopen(update_url) as response: - remote_raw = response.read().decode().strip() - local_raw = VERSION - - # Parse version numbers - remote_version = remote_raw - local_version = local_raw - - try: - remote_num = int("".join(remote_version.split("."))) - local_num = int("".join(local_version.split("."))) - except ValueError as ve: - return - - if remote_num > local_num: - # Use QTimer to ensure UI is ready, then show update message. - QTimer.singleShot( - 1000, lambda: show_update_message(remote_version, local_version) - ) - elif show_result: - # Show "no updates" message if manually checking - QMessageBox.information( - self, - "Up to Date", - f"You are running the latest version of {PROGRAM_NAME} ({local_version}).", - ) - except Exception as e: - if show_result: - QMessageBox.warning( - self, - "Update Check Failed", - f"Could not check for updates:\n{str(e)}", - ) - pass - - def clear_cache_files(self): - """Clear cache files created by the program.""" - import glob - - try: - # Get the abogen cache directory - cache_dir = get_user_cache_path() - - # Find all .txt files and cover images in the abogen cache directory - cache_files = glob.glob(os.path.join(cache_dir, "*.txt")) - cache_files.extend(glob.glob(os.path.join(cache_dir, "cover_*.jpg"))) - - # Count the files - file_count = len(cache_files) - - # Check for preview cache files - preview_cache_dir = os.path.join(cache_dir, "preview_cache") - preview_files = [] - if os.path.exists(preview_cache_dir): - preview_pattern = os.path.join(preview_cache_dir, "*.wav") - preview_files = glob.glob(preview_pattern) - - preview_count = len(preview_files) - - if file_count == 0 and preview_count == 0: - QMessageBox.information( - self, "No Cache Files", "No cache files were found." - ) - return - - # Create a custom message box with checkbox - msg_box = QMessageBox(self) - msg_box.setIcon(QMessageBox.Icon.Question) - msg_box.setWindowTitle("Clear Cache Files") - - msg_text = f"Found {file_count} cache file{'s' if file_count != 1 else ''} in the {PROGRAM_NAME} cache folder." - if preview_count > 0: - msg_text += f"\nAlso found {preview_count} preview cache file{'s' if preview_count != 1 else ''}." - - msg_box.setText(msg_text + "\nDo you want to delete them?") - - # Add checkbox for preview cache - preview_cache_checkbox = QCheckBox("Also clean preview cache", msg_box) - preview_cache_checkbox.setChecked(False) - # Only enable checkbox if preview files exist - preview_cache_checkbox.setEnabled(preview_count > 0) - - # Add the checkbox to the layout - msg_box.setCheckBox(preview_cache_checkbox) - - # Add buttons - msg_box.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - ) - msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) - - if msg_box.exec() != QMessageBox.StandardButton.Yes: - return - - # Delete the text files - deleted_count = 0 - for file_path in cache_files: - try: - os.remove(file_path) - deleted_count += 1 - except Exception as e: - print(f"Error deleting {file_path}: {e}") - - # Delete preview cache files if checkbox is checked - deleted_preview_count = 0 - if preview_cache_checkbox.isChecked() and preview_count > 0: - for file_path in preview_files: - try: - os.remove(file_path) - deleted_preview_count += 1 - except Exception as e: - print(f"Error deleting preview cache {file_path}: {e}") - - # Build result message - result_msg = f"Successfully deleted {deleted_count} temporary file{'s' if deleted_count != 1 else ''}." - if preview_cache_checkbox.isChecked() and deleted_preview_count > 0: - result_msg += f"\nAlso deleted {deleted_preview_count} preview cache file{'s' if deleted_preview_count != 1 else ''}." - - # Show results - QMessageBox.information(self, "Cache Files Cleared", result_msg) - - # If currently selected file is in the cache directory, clear the UI - if ( - self.selected_file - and os.path.dirname(self.selected_file) == cache_dir - and self.selected_file.endswith(".txt") - ): - self.input_box.clear_input() - - except Exception as e: - QMessageBox.critical( - self, "Error", f"An error occurred while clearing temporary files:\n{e}" - ) - - def set_max_log_lines(self): - """Open a dialog to set the maximum lines in the log window.""" - from PyQt6.QtWidgets import QInputDialog - - value, ok = QInputDialog.getInt( - self, - "Max Lines in Log Window", - "Enter the maximum number of lines to display in the log window:", - self.log_window_max_lines, - 10, # min value - 999999999, # max value - 1, # step - ) - if ok: - self.log_window_max_lines = value - self.config["log_window_max_lines"] = value - save_config(self.config) - QMessageBox.information( - self, - "Setting Saved", - f"Maximum lines in log window set to {value}.", - ) - - def set_max_subtitle_words(self): - """Open a dialog to set the maximum words per subtitle""" - from PyQt6.QtWidgets import QInputDialog - - current_value = self.config.get("max_subtitle_words", 50) - - value, ok = QInputDialog.getInt( - self, - "Max Words Per Subtitle", - "Enter the maximum number of words per\nsubtitle (before splitting the subtitle):", - current_value, - 1, # min value - 200, # max value - 1, # step - ) - - if ok: - # Save the new value - self.max_subtitle_words = value - self.config["max_subtitle_words"] = value - save_config(self.config) - - # Show confirmation - QMessageBox.information( - self, - "Setting Saved", - f"Maximum words per subtitle set to {value}.", - ) - - def set_silence_between_chapters(self): - """Open a dialog to set the silence duration between chapters""" - - current_value = self.config.get("silence_duration", 2.0) - - dlg = QInputDialog(self) - dlg.setWindowTitle("Silence Duration (seconds)") - dlg.setLabelText( - "Enter the duration of silence\nbetween chapters (in seconds):" - ) - dlg.setInputMode(QInputDialog.InputMode.DoubleInput) - dlg.setDoubleDecimals(1) - dlg.setDoubleMinimum(0.0) - dlg.setDoubleMaximum(60.0) - dlg.setDoubleValue(current_value) - dlg.setDoubleStep(0.1) # <-- set step to 0.1 - - if dlg.exec() == QDialog.DialogCode.Accepted: - value = dlg.doubleValue() - # Round to one decimal to avoid floating-point representation noise - value = round(value, 1) - - # Save the new value - self.silence_duration = value - self.config["silence_duration"] = value - save_config(self.config) - - # Show confirmation (format with one decimal) - QMessageBox.information( - self, - "Setting Saved", - f"Silence duration between chapters set to {value:.1f} seconds.", - ) - - def set_separate_chapters_format(self, fmt): - """Set the format for separate chapters audio files.""" - self.separate_chapters_format = fmt - self.config["separate_chapters_format"] = fmt - save_config(self.config) - - def set_subtitle_format(self, fmt): - """Set the subtitle format.""" - self.config["subtitle_format"] = fmt - save_config(self.config) - - def show_model_download_warning(self, title, message): - QMessageBox.information(self, title, message) +__all__ = ["abogen"] diff --git a/abogen/heteronym_overrides.py b/abogen/heteronym_overrides.py new file mode 100644 index 0000000..dbb5384 --- /dev/null +++ b/abogen/heteronym_overrides.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +try: # pragma: no cover - optional dependency + import spacy # type: ignore +except Exception: # pragma: no cover - spaCy may be unavailable in minimal environments + spacy = None + + +@dataclass(frozen=True) +class HeteronymVariant: + key: str + label: str + replacement_token: str + example_sentence: str + + +@dataclass(frozen=True) +class HeteronymSpec: + token: str + variants: Tuple[HeteronymVariant, HeteronymVariant] + + def default_choice_for_token(self, spacy_token: Any) -> str: + """Return the most likely variant key for this token.""" + pos = (getattr(spacy_token, "pos_", "") or "").upper() + tag = (getattr(spacy_token, "tag_", "") or "").upper() + + token_lower = self.token.casefold() + if token_lower == "wind": + # VERB => /waɪnd/, NOUN => /wɪnd/ + return "verb" if pos == "VERB" else "noun" + if token_lower == "read": + # VBD/VBN => /rɛd/ + return "past" if tag in {"VBD", "VBN"} else "present" + if token_lower == "tear": + return "verb" if pos == "VERB" else "noun" + if token_lower == "close": + return "verb" if pos == "VERB" else "adj" + if token_lower == "lead": + # Default to verb unless POS suggests noun. + return "metal" if pos == "NOUN" else "verb" + return self.variants[0].key + + +# Minimal, high-confidence starter set. +# NOTE: These replacements intentionally prioritize speech output. +# Some replacements may not be appropriate for subtitles/text exports. +_HETERONYM_SPECS: Dict[str, HeteronymSpec] = { + "wind": HeteronymSpec( + token="wind", + variants=( + HeteronymVariant( + key="noun", + label="Noun (the wind)", + replacement_token="wind", + example_sentence="Listen to the wind.", + ), + HeteronymVariant( + key="verb", + label="Verb (to wind)", + replacement_token="wynd", + example_sentence="I need to wind the watch.", + ), + ), + ), + "read": HeteronymSpec( + token="read", + variants=( + HeteronymVariant( + key="present", + label="Present (I read every day)", + replacement_token="read", + example_sentence="I read every day.", + ), + HeteronymVariant( + key="past", + label="Past (I read it yesterday)", + replacement_token="red", + example_sentence="I read it yesterday.", + ), + ), + ), + "tear": HeteronymSpec( + token="tear", + variants=( + HeteronymVariant( + key="noun", + label="Noun (a tear /crying/)", + replacement_token="tier", + example_sentence="A tear rolled down her cheek.", + ), + HeteronymVariant( + key="verb", + label="Verb (to tear /rip/)", + replacement_token="tear", + example_sentence="Please don't tear the page.", + ), + ), + ), + "close": HeteronymSpec( + token="close", + variants=( + HeteronymVariant( + key="adj", + label="Adjective (close /near/)", + replacement_token="close", + example_sentence="We are close to the station.", + ), + HeteronymVariant( + key="verb", + label="Verb (close /klohz/)", + replacement_token="cloze", + example_sentence="Please close the door.", + ), + ), + ), + "lead": HeteronymSpec( + token="lead", + variants=( + HeteronymVariant( + key="verb", + label="Verb (to lead)", + replacement_token="lead", + example_sentence="They will lead the way.", + ), + HeteronymVariant( + key="metal", + label="Noun (lead /metal/)", + replacement_token="led", + example_sentence="The pipe was made of lead.", + ), + ), + ), +} + + +def _hash_id(*parts: str) -> str: + digest = hashlib.sha1("\n".join(parts).encode("utf-8")).hexdigest() + return digest[:12] + + +_WORD_BOUNDARY_CACHE: Dict[str, re.Pattern[str]] = {} + + +def _word_boundary_pattern(token: str) -> re.Pattern[str]: + key = token.casefold() + cached = _WORD_BOUNDARY_CACHE.get(key) + if cached is not None: + return cached + escaped = re.escape(token) + pattern = re.compile(rf"(?i)(?'s|\u2019s|\u2019)?(?!\w)") + _WORD_BOUNDARY_CACHE[key] = pattern + return pattern + + +def _preserve_case(replacement: str, original: str) -> str: + if not replacement: + return replacement + if original.isupper(): + return replacement.upper() + if original[:1].isupper(): + return replacement[:1].upper() + replacement[1:] + return replacement + + +def _build_replacement_sentence(sentence: str, token: str, replacement_token: str) -> str: + pattern = _word_boundary_pattern(token) + + def _repl(match: re.Match[str]) -> str: + matched = match.group(0) or "" + suffix = match.group("possessive") or "" + base = matched[: len(matched) - len(suffix)] if suffix else matched + return _preserve_case(replacement_token, base) + suffix + + return pattern.sub(_repl, sentence) + + +def _load_spacy(language: str) -> Any: + if spacy is None: + return None + + # English only for now. + # Use installed small model; keep it simple. + lang = (language or "en").lower() + if lang.startswith("en"): + try: + return spacy.load("en_core_web_sm") + except Exception: + return spacy.blank("en") + return spacy.blank("xx") + + +def extract_heteronym_overrides( + chapters: Sequence[Mapping[str, Any]], + *, + language: str, + existing: Optional[Iterable[Mapping[str, Any]]] = None, +) -> List[Dict[str, Any]]: + """Extract distinct heteronym-containing sentences from chapters. + + Returns entries shaped for persistence + UI. + + Each entry contains: + - id + - token + - sentence + - options: [{key,label,replacement_token,replacement_sentence,example_sentence}] + - default_choice + - choice + """ + + lang = (language or "en").lower() + if not lang.startswith("en"): + return [] + + if spacy is None: + return [] + + nlp = _load_spacy(lang) + if nlp is None: + return [] + + previous_choices: Dict[str, str] = {} + if existing: + for item in existing: + if not isinstance(item, Mapping): + continue + entry_id = str(item.get("id") or "").strip() + choice = str(item.get("choice") or "").strip() + if entry_id and choice: + previous_choices[entry_id] = choice + + results: List[Dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + + for chapter in chapters: + if not isinstance(chapter, Mapping): + continue + text = str(chapter.get("text") or "") + if not text.strip(): + continue + + doc = nlp(text) + for sent in getattr(doc, "sents", []): + sentence = str(getattr(sent, "text", "") or "").strip() + if not sentence: + continue + + for token in sent: + token_text = str(getattr(token, "text", "") or "") + if not token_text: + continue + token_key = token_text.casefold() + spec = _HETERONYM_SPECS.get(token_key) + if not spec: + continue + + dedupe_key = (token_key, sentence) + if dedupe_key in seen: + continue + seen.add(dedupe_key) + + entry_id = _hash_id(token_key, sentence) + default_choice = spec.default_choice_for_token(token) + choice = previous_choices.get(entry_id, default_choice) + + options: List[Dict[str, Any]] = [] + for variant in spec.variants: + replacement_sentence = _build_replacement_sentence( + sentence, token=spec.token, replacement_token=variant.replacement_token + ) + options.append( + { + "key": variant.key, + "label": variant.label, + "replacement_token": variant.replacement_token, + "replacement_sentence": replacement_sentence, + "example_sentence": variant.example_sentence, + } + ) + + results.append( + { + "id": entry_id, + "token": token_text, + "token_lower": token_key, + "sentence": sentence, + "options": options, + "default_choice": default_choice, + "choice": choice, + } + ) + + return results diff --git a/abogen/integrations/__init__.py b/abogen/integrations/__init__.py new file mode 100644 index 0000000..06e8198 --- /dev/null +++ b/abogen/integrations/__init__.py @@ -0,0 +1 @@ +"""Integration clients for external services.""" diff --git a/abogen/integrations/audiobookshelf.py b/abogen/integrations/audiobookshelf.py new file mode 100644 index 0000000..0538cce --- /dev/null +++ b/abogen/integrations/audiobookshelf.py @@ -0,0 +1,680 @@ +from __future__ import annotations + +import json +import logging +import math +import mimetypes +import re +from contextlib import ExitStack +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +import httpx + +logger = logging.getLogger(__name__) + + +class AudiobookshelfUploadError(RuntimeError): + """Raised when an upload to Audiobookshelf fails.""" + + +@dataclass(frozen=True) +class AudiobookshelfConfig: + base_url: str + api_token: str + library_id: Optional[str] = None + collection_id: Optional[str] = None + folder_id: Optional[str] = None + verify_ssl: bool = True + send_cover: bool = True + send_chapters: bool = True + send_subtitles: bool = True + timeout: float = 3600.0 + + def normalized_base_url(self) -> str: + base = (self.base_url or "").strip() + if not base: + raise ValueError("Audiobookshelf base URL is required") + normalized = base.rstrip("/") + # The web UI historically suggested including '/api' in the base URL; trim + # it here so we can safely append `/api/...` endpoints below. + if normalized.lower().endswith("/api"): + normalized = normalized[:-4] + return normalized or base + + +class AudiobookshelfClient: + """Client for the legacy Audiobookshelf multipart upload endpoint.""" + + def __init__(self, config: AudiobookshelfConfig) -> None: + if not config.api_token: + raise ValueError("Audiobookshelf API token is required") + # library_id is now optional for discovery + self._config = config + normalized = config.normalized_base_url() or "" + self._base_url = normalized.rstrip("/") or normalized + self._client_base_url = f"{self._base_url}/" + self._folder_cache: Optional[Tuple[str, str, str]] = None + + def get_libraries(self) -> List[Dict[str, Any]]: + """Fetch all libraries from the Audiobookshelf server.""" + route = self._api_path("libraries") + try: + with self._open_client() as client: + response = client.get(route) + response.raise_for_status() + data = response.json() + # data['libraries'] is a list of library objects + return data.get("libraries", []) + except httpx.HTTPError as exc: + raise AudiobookshelfUploadError(f"Failed to fetch libraries: {exc}") from exc + + def _api_path(self, suffix: str = "") -> str: + """Join the API prefix with the provided suffix without losing proxies.""" + clean_suffix = suffix.lstrip("/") + return f"api/{clean_suffix}" if clean_suffix else "api" + + def upload_audiobook( + self, + audio_path: Path, + *, + metadata: Dict[str, Any], + cover_path: Optional[Path] = None, + chapters: Optional[Iterable[Dict[str, Any]]] = None, + subtitles: Optional[Iterable[Path]] = None, + ) -> Dict[str, Any]: + if not audio_path.exists(): + raise AudiobookshelfUploadError(f"Audio path does not exist: {audio_path}") + + form_fields = self._build_upload_fields(audio_path, metadata, chapters) + file_entries = self._build_file_entries(audio_path, cover_path, subtitles) + + route = self._api_path("upload") + try: + with self._open_client() as client, ExitStack() as stack: + files_payload = self._open_file_handles(file_entries, stack) + response = client.post(route, data=form_fields, files=files_payload) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + detail = (exc.response.text or "").strip() + if detail: + detail = detail[:200] + message = f"Audiobookshelf upload failed with status {status}: {detail}" + else: + message = f"Audiobookshelf upload failed with status {status}" + raise AudiobookshelfUploadError( + message + ) from exc + except httpx.HTTPError as exc: + raise AudiobookshelfUploadError(f"Audiobookshelf upload failed: {exc}") from exc + + return {} + + def _open_client(self) -> httpx.Client: + headers = { + "Authorization": f"Bearer {self._config.api_token}", + "Accept": "application/json", + } + return httpx.Client( + base_url=self._client_base_url, + headers=headers, + timeout=self._config.timeout, + verify=self._config.verify_ssl, + ) + + def _build_upload_fields( + self, + audio_path: Path, + metadata: Dict[str, Any], + chapters: Optional[Iterable[Dict[str, Any]]], + ) -> Dict[str, str]: + folder_id, _, _ = self._ensure_folder() + title = self._extract_title(metadata, audio_path) + author = self._extract_author(metadata) + series = self._extract_series(metadata) + series_sequence = self._extract_series_sequence(metadata) + + fields: Dict[str, str] = { + "library": self._config.library_id, + "folder": folder_id, + "title": title, + } + if author: + fields["author"] = author + if series: + fields["series"] = series + if series_sequence: + fields["seriesSequence"] = series_sequence + if self._config.collection_id: + fields["collectionId"] = self._config.collection_id + + metadata_payload: Dict[str, Any] = metadata or {} + if chapters and self._config.send_chapters: + metadata_payload = dict(metadata_payload) + metadata_payload["chapters"] = list(chapters) + + if metadata_payload: + # Ensure authors is a list of strings in the JSON payload if it exists + if "authors" in metadata_payload: + authors_val = metadata_payload["authors"] + if isinstance(authors_val, str): + metadata_payload["authors"] = [a.strip() for a in authors_val.split(",") if a.strip()] + elif isinstance(authors_val, list): + metadata_payload["authors"] = [str(a).strip() for a in authors_val if str(a).strip()] + + try: + fields["metadata"] = json.dumps(metadata_payload, ensure_ascii=False) + except (TypeError, ValueError): + logger.debug("Failed to serialize Audiobookshelf metadata payload") + + return fields + + def _build_file_entries( + self, + audio_path: Path, + cover_path: Optional[Path], + subtitles: Optional[Iterable[Path]], + ) -> List[Tuple[str, Path]]: + entries: List[Tuple[str, Path]] = [("file0", audio_path)] + index = 1 + + if cover_path and self._config.send_cover and cover_path.exists(): + entries.append((f"file{index}", cover_path)) + index += 1 + + if subtitles and self._config.send_subtitles: + for subtitle in subtitles: + if subtitle.exists(): + entries.append((f"file{index}", subtitle)) + index += 1 + + return entries + + def _open_file_handles( + self, + entries: Sequence[Tuple[str, Path]], + stack: ExitStack, + ) -> List[Tuple[str, Tuple[str, Any, str]]]: + files: List[Tuple[str, Tuple[str, Any, str]]] = [] + for field_name, path in entries: + mime_type, _ = mimetypes.guess_type(path.name) + mime_type = mime_type or "application/octet-stream" + handle = stack.enter_context(path.open("rb")) + files.append((field_name, (path.name, handle, mime_type))) + return files + + def find_existing_items( + self, + title: str, + *, + folder_id: Optional[str] = None, + ) -> List[Mapping[str, Any]]: + normalized_title = self._normalize_title_value(title) + if not normalized_title: + return [] + + folder_hint = folder_id or self._config.folder_id + target_folders = set() + if folder_hint: + folder_token = str(folder_hint).strip().lower() + if folder_token: + target_folders.add(folder_token) + + requests = self._candidate_search_requests(title, folder_hint) + if not requests: + return [] + + matches: List[Mapping[str, Any]] = [] + + try: + with self._open_client() as client: + for route, params in requests: + try: + response = client.get(route, params=params) + except httpx.HTTPError as exc: + logger.debug("Audiobookshelf lookup failed for %s: %s", route, exc) + continue + + if response.status_code == 404: + continue + + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + if status in {401, 403}: + raise AudiobookshelfUploadError( + "Audiobookshelf authentication failed while checking for existing items." + ) from exc + logger.debug("Audiobookshelf lookup error %s for %s", status, route) + continue + + try: + payload = response.json() + except ValueError: + continue + + candidates = self._extract_candidate_items(payload) + for item in candidates: + item_title = self._normalize_item_title(item) + if not item_title or item_title != normalized_title: + continue + if target_folders: + item_folder = self._normalize_folder_id(item) + if item_folder and item_folder not in target_folders: + continue + matches.append(item) + if matches: + break + except AudiobookshelfUploadError: + raise + except Exception: + logger.debug( + "Unexpected error while checking Audiobookshelf for existing items", + exc_info=True, + ) + + return matches + + def delete_items(self, items: Iterable[Mapping[str, Any] | str]) -> None: + to_delete: List[str] = [] + for entry in items: + if isinstance(entry, Mapping): + item_id = self._extract_item_id(entry) + else: + item_id = str(entry).strip() + if item_id: + to_delete.append(item_id) + + if not to_delete: + return + + with self._open_client() as client: + for item_id in to_delete: + self._delete_single_item(client, item_id) + + def _candidate_search_requests( + self, + title: str, + folder_id: Optional[str], + ) -> List[Tuple[str, Dict[str, Any]]]: + query = (title or "").strip() + if not query: + return [] + + library_id = self._config.library_id + folder_token = (folder_id or self._config.folder_id or "").strip() + + requests: List[Tuple[str, Dict[str, Any]]] = [] + seen_routes: set[str] = set() + + def _append(route: str, params: Dict[str, Any]) -> None: + if route in seen_routes: + return + seen_routes.add(route) + requests.append((route, params)) + + if folder_token: + _append( + self._api_path(f"folders/{folder_token}/items"), + {"library": library_id, "search": query}, + ) + + _append(self._api_path(f"libraries/{library_id}/items"), {"search": query}) + _append(self._api_path("items"), {"library": library_id, "search": query}) + _append( + self._api_path("search"), + {"query": query, "library": library_id, "media": "audiobook"}, + ) + + return requests + + def _delete_single_item(self, client: httpx.Client, item_id: str) -> None: + routes = [ + self._api_path(f"items/{item_id}"), + self._api_path(f"libraries/{self._config.library_id}/items/{item_id}"), + ] + + for route in routes: + try: + response = client.delete(route) + except httpx.HTTPError as exc: + logger.debug("Audiobookshelf delete failed for %s: %s", route, exc) + continue + + if response.status_code in (200, 202, 204): + return + if response.status_code == 404: + continue + + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise AudiobookshelfUploadError( + f"Failed to delete Audiobookshelf item '{item_id}': {exc}" + ) from exc + + logger.debug("Audiobookshelf item %s could not be confirmed deleted", item_id) + + def resolve_folder(self) -> Tuple[str, str, str]: + """Return the resolved folder (id, name, library name).""" + return self._ensure_folder() + + def list_folders(self) -> List[Dict[str, str]]: + """Return all folders for the configured library.""" + library_name, folders = self._load_library_metadata() + results: List[Dict[str, str]] = [] + for folder in folders: + folder_id = str(folder.get("id") or "").strip() + if not folder_id: + continue + name = self._folder_display_name(folder) + path = self._select_folder_path(folder) + results.append( + { + "id": folder_id, + "name": name, + "path": path, + "library": library_name, + } + ) + results.sort(key=lambda entry: (entry.get("path") or entry.get("name") or entry.get("id") or "").lower()) + return results + + def _ensure_folder(self) -> Tuple[str, str, str]: + if self._folder_cache: + return self._folder_cache + + identifier = (self._config.folder_id or "").strip() + if not identifier: + raise AudiobookshelfUploadError( + "Audiobookshelf folder is required; enter the folder name or ID in Settings." + ) + + identifier_norm = self._normalize_identifier(identifier) + library_name, folders = self._load_library_metadata() + + # direct ID match + for folder in folders: + folder_id = str(folder.get("id") or "").strip() + if folder_id and folder_id == identifier: + folder_name = self._folder_display_name(folder) or folder_id + self._folder_cache = (folder_id, folder_name, library_name) + return self._folder_cache + + has_path_component = "/" in identifier_norm + + for folder in folders: + folder_id = str(folder.get("id") or "").strip() + if not folder_id: + continue + folder_name = self._folder_display_name(folder) + name_norm = self._normalize_identifier(folder_name) + if name_norm and name_norm == identifier_norm: + self._folder_cache = (folder_id, folder_name or folder_id, library_name) + return self._folder_cache + + for candidate in self._folder_path_candidates(folder): + candidate_norm = self._normalize_identifier(candidate) + if not candidate_norm: + continue + if candidate_norm == identifier_norm: + self._folder_cache = (folder_id, folder_name or folder_id, library_name) + return self._folder_cache + if has_path_component and candidate_norm.endswith(identifier_norm): + self._folder_cache = (folder_id, folder_name or folder_id, library_name) + return self._folder_cache + if not has_path_component: + tail = candidate_norm.split("/")[-1] + if tail and tail == identifier_norm: + self._folder_cache = (folder_id, folder_name or folder_id, library_name) + return self._folder_cache + + raise AudiobookshelfUploadError( + f"Folder '{identifier}' was not found in library '{library_name}'. " + "Enter the folder name exactly as it appears in Audiobookshelf, a trailing path segment, or paste the folder ID." + ) + + def _load_library_metadata(self) -> Tuple[str, List[Mapping[str, Any]]]: + try: + with self._open_client() as client: + response = client.get(self._api_path(f"libraries/{self._config.library_id}")) + response.raise_for_status() + payload = response.json() + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + if status == 404: + message = f"Audiobookshelf library '{self._config.library_id}' not found." + else: + detail = (exc.response.text or "").strip() + if detail: + detail = detail[:200] + message = ( + f"Failed to load Audiobookshelf library '{self._config.library_id}' " + f"(status {status}): {detail}" + ) + else: + message = ( + f"Failed to load Audiobookshelf library '{self._config.library_id}' " + f"(status {status})." + ) + raise AudiobookshelfUploadError(message) from exc + except httpx.HTTPError as exc: + raise AudiobookshelfUploadError( + f"Failed to reach Audiobookshelf library '{self._config.library_id}': {exc}" + ) from exc + + if not isinstance(payload, Mapping): + return self._config.library_id, [] + + library_name = str(payload.get("name") or payload.get("label") or self._config.library_id) + raw_folders = payload.get("libraryFolders") or payload.get("folders") or [] + folders = [entry for entry in raw_folders if isinstance(entry, Mapping)] + return library_name, folders + + @staticmethod + def _folder_path_candidates(folder: Mapping[str, Any]) -> List[str]: + candidates: List[str] = [] + for key in ("fullPath", "fullpath", "path", "folderPath", "virtualPath"): + value = folder.get(key) + if isinstance(value, str) and value.strip(): + candidates.append(value) + return candidates + + @staticmethod + def _folder_display_name(folder: Mapping[str, Any]) -> str: + name = str(folder.get("name") or folder.get("label") or "").strip() + if name: + return name + path = AudiobookshelfClient._select_folder_path(folder) + if path: + tail = path.strip("/ ") + tail = tail.split("/")[-1] if tail else "" + if tail: + return tail + return str(folder.get("id") or "").strip() + + @staticmethod + def _select_folder_path(folder: Mapping[str, Any]) -> str: + for candidate in AudiobookshelfClient._folder_path_candidates(folder): + normalized = candidate.replace("\\", "/").strip() + if normalized: + return normalized + return "" + + @staticmethod + def _normalize_identifier(value: str) -> str: + token = (value or "").strip() + token = token.replace("\\", "/") + if len(token) > 1 and token[1] == ":": + token = token[2:] + token = token.strip("/ ") + return token.lower() + + @staticmethod + def _normalize_title_value(value: Optional[str]) -> str: + if not isinstance(value, str): + return "" + normalized = re.sub(r"\s+", " ", value).strip() + return normalized.casefold() if normalized else "" + + @staticmethod + def _normalize_item_title(item: Mapping[str, Any]) -> str: + if not isinstance(item, Mapping): + return "" + for key in ("title", "name", "label"): + candidate = item.get(key) + if isinstance(candidate, str) and candidate.strip(): + return AudiobookshelfClient._normalize_title_value(candidate) + library_item = item.get("libraryItem") + if isinstance(library_item, Mapping): + return AudiobookshelfClient._normalize_item_title(library_item) + return "" + + @staticmethod + def _normalize_folder_id(item: Mapping[str, Any]) -> Optional[str]: + if not isinstance(item, Mapping): + return None + for key in ("folderId", "libraryFolderId", "folder_id", "folder"): + value = item.get(key) + if isinstance(value, str) and value.strip(): + return value.strip().lower() + if isinstance(value, (int, float)): + return str(value).strip().lower() + library_item = item.get("libraryItem") + if isinstance(library_item, Mapping): + return AudiobookshelfClient._normalize_folder_id(library_item) + return None + + @staticmethod + def _extract_item_id(item: Mapping[str, Any]) -> Optional[str]: + if not isinstance(item, Mapping): + return None + for key in ("id", "libraryItemId", "itemId"): + value = item.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + if isinstance(value, (int, float)): + return str(value).strip() + library_item = item.get("libraryItem") + if isinstance(library_item, Mapping): + return AudiobookshelfClient._extract_item_id(library_item) + return None + + @staticmethod + def _extract_candidate_items(payload: Any) -> List[Mapping[str, Any]]: + items: List[Mapping[str, Any]] = [] + seen_ids: set[str] = set() + visited: set[int] = set() + + def _visit(obj: Any) -> None: + if isinstance(obj, Mapping): + obj_id = id(obj) + if obj_id in visited: + return + visited.add(obj_id) + + title = AudiobookshelfClient._normalize_item_title(obj) + item_id = AudiobookshelfClient._extract_item_id(obj) + if title and item_id: + key = item_id.strip().lower() + if key not in seen_ids: + seen_ids.add(key) + items.append(obj) + + for value in obj.values(): + _visit(value) + + elif isinstance(obj, list): + for entry in obj: + _visit(entry) + + _visit(payload) + return items + + @staticmethod + def _extract_title(metadata: Mapping[str, Any], audio_path: Path) -> str: + title = metadata.get("title") if isinstance(metadata, Mapping) else None + candidate = str(title).strip() if isinstance(title, str) else "" + if candidate: + return candidate + return audio_path.stem or audio_path.name + + @staticmethod + def _extract_author(metadata: Mapping[str, Any]) -> str: + authors = metadata.get("authors") if isinstance(metadata, Mapping) else None + if isinstance(authors, str): + candidate = authors.strip() + return candidate + if isinstance(authors, Iterable) and not isinstance(authors, (str, Mapping)): + names = [str(entry).strip() for entry in authors if isinstance(entry, str) and entry.strip()] + if names: + # ABS expects a comma-separated string for multiple authors. + return ", ".join(names) + return "" + + @staticmethod + def _extract_series(metadata: Mapping[str, Any]) -> str: + series_name = metadata.get("seriesName") if isinstance(metadata, Mapping) else None + if isinstance(series_name, str) and series_name.strip(): + return series_name.strip() + return "" + + @staticmethod + def _extract_series_sequence(metadata: Mapping[str, Any]) -> str: + if not isinstance(metadata, Mapping): + return "" + + preferred_keys = ( + "seriesSequence", + "series_sequence", + "seriesIndex", + "series_index", + "seriesNumber", + "series_number", + "bookNumber", + "book_number", + ) + + for key in preferred_keys: + if key not in metadata: + continue + normalized = AudiobookshelfClient._normalize_series_sequence(metadata.get(key)) + if normalized: + return normalized + return "" + + @staticmethod + def _normalize_series_sequence(raw: Any) -> str: + if raw is None: + return "" + + if isinstance(raw, (int, float)): + if isinstance(raw, float) and (math.isnan(raw) or math.isinf(raw)): + return "" + text = str(raw) + else: + text = str(raw).strip() + + if not text: + return "" + + candidate = text.replace(",", ".") + match = re.search(r"\d+(?:\.\d+)?", candidate) + if not match: + return "" + + normalized = match.group(0) + if "." in normalized: + normalized = normalized.rstrip("0").rstrip(".") + if not normalized: + normalized = "0" + return normalized + + try: + return str(int(normalized)) + except ValueError: + cleaned = normalized.lstrip("0") + return cleaned or "0" diff --git a/abogen/integrations/calibre_opds.py b/abogen/integrations/calibre_opds.py new file mode 100644 index 0000000..128e2ba --- /dev/null +++ b/abogen/integrations/calibre_opds.py @@ -0,0 +1,1460 @@ +from __future__ import annotations + +import dataclasses +import html +import re +import unicodedata +import xml.etree.ElementTree as ET +from collections import deque +from dataclasses import dataclass, field +from pathlib import PurePosixPath +from typing import Any, Deque, Dict, Iterable, Iterator, List, Mapping, Optional, Set, Tuple, Union +from urllib.parse import quote, urljoin, urlparse + +import httpx + + +ATOM_NS = "http://www.w3.org/2005/Atom" +OPDS_NS = "http://opds-spec.org/2010/catalog" +DC_NS = "http://purl.org/dc/terms/" +CALIBRE_CATALOG_NS = "http://calibre.kovidgoyal.net/2009/catalog" +CALIBRE_METADATA_NS = "http://calibre.kovidgoyal.net/2009/metadata" +NS = { + "atom": ATOM_NS, + "opds": OPDS_NS, + "dc": DC_NS, + "calibre": CALIBRE_CATALOG_NS, + "calibre_md": CALIBRE_METADATA_NS, +} + + +_TAG_STRIP_RE = re.compile(r"<[^>]+>") +_SERIES_PREFIX_RE = re.compile(r"^\s*(series|books?)\s*[:\-]\s*", re.IGNORECASE) +_SERIES_NUMBER_BRACKET_RE = re.compile(r"[\[(]\s*(?:book\s*)?(\d+(?:\.\d+)?)\s*[\])]", re.IGNORECASE) +_SERIES_NUMBER_HASH_RE = re.compile(r"#\s*(\d+(?:\.\d+)?)") +_SERIES_NUMBER_BOOK_RE = re.compile(r"\bbook\s+(\d+(?:\.\d+)?)\b", re.IGNORECASE) +_SERIES_LINE_TEXT_RE = re.compile(r"^\s*series\s*[:\-]\s*(.+)$", re.IGNORECASE) +_SUMMARY_METADATA_LINE_RE = re.compile(r"^([A-Z][A-Z0-9&/\- +'\u2019]{1,40})\s*[:\-]\s*(.+)$") +_EPUB_MIME_TYPES = { + "application/epub+zip", + "application/zip", + "application/x-zip", + "application/x-zip-compressed", +} +_SUPPORTED_DOWNLOAD_MIME_TYPES = set(_EPUB_MIME_TYPES) | {"application/pdf"} +_SUPPORTED_DOWNLOAD_EXTENSIONS = {".epub", ".pdf"} +_STOP_WORDS = { + "a", "an", "the", "and", "or", "but", "if", "then", "else", "when", + "at", "by", "for", "from", "in", "into", "of", "off", "on", "onto", + "to", "with", "is", "are", "was", "were", "be", "been", "being", + "that", "this", "these", "those", "it", "its" +} + + +class CalibreOPDSError(RuntimeError): + """Raised when the Calibre OPDS client encounters an unrecoverable error.""" + + +@dataclass +class OPDSLink: + href: str + rel: Optional[str] = None + type: Optional[str] = None + title: Optional[str] = None + + def to_dict(self) -> Dict[str, Optional[str]]: + return { + "href": self.href, + "rel": self.rel, + "type": self.type, + "title": self.title, + } + + +@dataclass +class OPDSEntry: + id: str + title: str + position: Optional[int] = None + authors: List[str] = field(default_factory=list) + subtitle: Optional[str] = None + updated: Optional[str] = None + published: Optional[str] = None + summary: Optional[str] = None + download: Optional[OPDSLink] = None + alternate: Optional[OPDSLink] = None + thumbnail: Optional[OPDSLink] = None + links: List[OPDSLink] = field(default_factory=list) + series: Optional[str] = None + series_index: Optional[float] = None + tags: List[str] = field(default_factory=list) + rating: Optional[float] = None + rating_max: Optional[float] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "title": self.title, + "position": self.position, + "authors": list(self.authors), + "subtitle": self.subtitle, + "updated": self.updated, + "published": self.published, + "summary": self.summary, + "download": self.download.to_dict() if self.download else None, + "alternate": self.alternate.to_dict() if self.alternate else None, + "thumbnail": self.thumbnail.to_dict() if self.thumbnail else None, + "links": [link.to_dict() for link in self.links], + "series": self.series, + "series_index": self.series_index, + "tags": list(self.tags), + "rating": self.rating, + "rating_max": self.rating_max, + } + + +@dataclass +class OPDSFeed: + id: Optional[str] + title: Optional[str] + entries: List[OPDSEntry] + links: Dict[str, OPDSLink] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "title": self.title, + "entries": [entry.to_dict() for entry in self.entries], + "links": {key: link.to_dict() for key, link in self.links.items()}, + } + + +def feed_to_dict(feed: OPDSFeed) -> Dict[str, Any]: + return feed.to_dict() + + +@dataclass +class DownloadedResource: + filename: str + mime_type: str + content: bytes + + +class CalibreOPDSClient: + """Client for interacting with a Calibre-Web OPDS catalog.""" + + def __init__( + self, + base_url: str, + *, + username: Optional[str] = None, + password: Optional[str] = None, + timeout: float = 15.0, + verify: bool = True, + ) -> None: + if not base_url: + raise ValueError("Calibre OPDS base URL is required") + normalized = base_url.strip() + if not normalized: + raise ValueError("Calibre OPDS base URL is required") + # Store the original URL without forcing a trailing slash. + # Some servers (e.g., Booklore) return 404 for URLs with trailing slashes. + self._base_url = normalized.rstrip("/") + self._auth = None + if username: + self._auth = httpx.BasicAuth(username, password or "") + self._timeout = timeout + self._verify = verify + self._headers = { + "User-Agent": "abogen-calibre-opds/1.0", + "Accept": "application/atom+xml,application/xml;q=0.9,*/*;q=0.8", + } + + @staticmethod + def _strip_html(value: Optional[str]) -> Optional[str]: + if not value: + return None + cleaned = _TAG_STRIP_RE.sub("", value) + return html.unescape(cleaned).strip() or None + + def _make_url(self, href: Optional[str]) -> str: + if not href: + return self._base_url + href = href.strip() + if href.startswith("http://") or href.startswith("https://"): + return href + if href.startswith("/"): + # Absolute path - join with origin only + parsed = urlparse(self._base_url) + return f"{parsed.scheme}://{parsed.netloc}{href}" + if href.startswith("?") or href.startswith("#"): + return f"{self._base_url}{href}" + if href.startswith("./") or href.startswith("../"): + # For relative paths, we need a trailing slash for urljoin to work correctly + base_with_slash = self._base_url if self._base_url.endswith("/") else f"{self._base_url}/" + return urljoin(base_with_slash, href) + # Relative path like "search" or "catalog?page=1" - treat as sibling + base_with_slash = self._base_url if self._base_url.endswith("/") else f"{self._base_url}/" + return urljoin(base_with_slash, href) + + def _open_client(self) -> httpx.Client: + return httpx.Client( + auth=self._auth, + headers=dict(self._headers), + timeout=self._timeout, + verify=self._verify, + ) + + def fetch_feed(self, href: Optional[str] = None, *, params: Optional[Mapping[str, Any]] = None) -> OPDSFeed: + target = self._make_url(href) + try: + with self._open_client() as client: + response = client.get(target, params=params, follow_redirects=True) + response.raise_for_status() + except httpx.HTTPStatusError as exc: # pragma: no cover - thin wrapper + raise CalibreOPDSError(f"Calibre OPDS request failed: {exc.response.status_code}") from exc + except httpx.HTTPError as exc: # pragma: no cover - thin wrapper + raise CalibreOPDSError(f"Calibre OPDS request failed: {exc}") from exc + + return self._parse_feed(response.text, base_url=target) + + def _fetch_opensearch_template(self, href: str) -> Optional[str]: + target = self._make_url(href) + try: + with self._open_client() as client: + response = client.get(target, follow_redirects=True) + response.raise_for_status() + + # Simple XML parsing to find the Url template + # We avoid full namespace handling for robustness + root = ET.fromstring(response.text) + for node in root.iter(): + if node.tag.endswith("Url"): + template = node.attrib.get("template") + if template and "{searchTerms}" in template: + mime = node.attrib.get("type", "") + # Prefer atom/xml feeds + if "atom" in mime or "xml" in mime: + return template + return None + except Exception: + return None + + def _find_best_seed_feed(self, root_feed: OPDSFeed) -> OPDSFeed: + # If the root feed already has books, use it + for entry in root_feed.entries: + if any("acquisition" in (link.rel or "") for link in entry.links): + return root_feed + + # Otherwise, look for a "By Title" or "All" navigation entry + candidates = ["title", "all", "books", "catalog"] + best_href = None + + for entry in root_feed.entries: + title_lower = (entry.title or "").lower() + if any(c in title_lower for c in candidates): + # Check if it has a navigation link + for link in entry.links: + if self._is_navigation_link(link): + best_href = link.href + # Prefer "By Title" explicitly + if "title" in title_lower: + break + if best_href and "title" in title_lower: + break + + if best_href: + try: + return self.fetch_feed(best_href) + except CalibreOPDSError: + pass + + return root_feed + + def search(self, query: str, start_href: Optional[str] = None) -> OPDSFeed: + cleaned = (query or "").strip() + if not cleaned: + return self.fetch_feed(start_href) if start_href else self.fetch_feed() + + base_feed: Optional[OPDSFeed] = None + try: + base_feed = self.fetch_feed() + except CalibreOPDSError: + pass + + # 1. Try explicit search link from feed + if base_feed: + # Check for OpenSearch description first + search_link = self._resolve_link(base_feed.links, "search") + if search_link and search_link.type == "application/opensearchdescription+xml": + template = self._fetch_opensearch_template(search_link.href) + if template: + search_url = template.replace("{searchTerms}", quote(cleaned)) + try: + feed = self.fetch_feed(search_url) + if feed.entries: + filtered = self._filter_feed_entries(feed, cleaned) + if filtered.entries: + return filtered + except CalibreOPDSError: + pass + + # Check for direct template + search_url = self._resolve_search_url(base_feed, cleaned) + if search_url: + try: + feed = self.fetch_feed(search_url) + if feed.entries: + filtered = self._filter_feed_entries(feed, cleaned) + if filtered.entries: + return filtered + except CalibreOPDSError: + pass + + # 2. Try common guesses if explicit link failed + candidates: List[Tuple[Optional[str], Optional[Mapping[str, Any]]]] = [ + ("search", {"query": cleaned}), + ("search", {"q": cleaned}), + (None, {"search": cleaned}), + ] + + last_error: Optional[Exception] = None + + for path, params in candidates: + try: + feed = self.fetch_feed(path, params=params) + if feed.entries: + # Check if the server ignored the query and returned the default feed + if base_feed and feed.title == base_feed.title: + # Compare first entry ID to see if it's the same feed + if feed.entries[0].id == base_feed.entries[0].id: + continue + + filtered = self._filter_feed_entries(feed, cleaned) + if filtered.entries: + return filtered + except CalibreOPDSError as exc: + last_error = exc + continue + + # 3. Fallback to local search (crawling) + seed_feed: Optional[OPDSFeed] = None + if start_href: + try: + seed_feed = self.fetch_feed(start_href) + except CalibreOPDSError: + pass + + if not seed_feed and base_feed: + # If we are falling back to base_feed (Root), try to find a better seed + seed_feed = self._find_best_seed_feed(base_feed) + + if not seed_feed: + try: + seed_feed = self.fetch_feed() + except CalibreOPDSError as exc: + if last_error: + raise last_error + raise exc + + # Heuristic: If the seed feed has acquisition links, use linear scan. + # Otherwise, use BFS to find content. + has_books = False + if seed_feed and seed_feed.entries: + for entry in seed_feed.entries[:5]: + for link in entry.links: + if "acquisition" in (link.rel or ""): + has_books = True + break + if has_books: + break + + if has_books: + return self._collect_search_results(seed_feed, cleaned) + else: + return self._local_search(cleaned, seed_feed=seed_feed) + + def _collect_search_results( + self, + seed_feed: OPDSFeed, + query: str, + *, + max_pages: int = 40, + ) -> OPDSFeed: + normalized = (query or "").strip() + if not normalized: + return seed_feed + seen_ids: Set[str] = set() + collected: List[OPDSEntry] = [] + for page in self._iter_paginated_feeds(seed_feed, max_pages=max_pages): + filtered = self._filter_feed_entries(page, normalized) + for entry in filtered.entries: + entry_id = (entry.id or "").strip() + if entry_id: + if entry_id in seen_ids: + continue + seen_ids.add(entry_id) + collected.append(entry) + return dataclasses.replace(seed_feed, entries=collected) + + def _iter_paginated_feeds(self, seed_feed: OPDSFeed, *, max_pages: int = 40) -> Iterator[OPDSFeed]: + yield seed_feed + next_link = self._resolve_link(seed_feed.links, "next") + visited: Set[str] = set() + pages_examined = 0 + while next_link and pages_examined < max_pages: + href = (next_link.href or "").strip() + if not href: + break + absolute = self._make_url(href) + if absolute in visited: + break + visited.add(absolute) + pages_examined += 1 + try: + page = self.fetch_feed(absolute) + except CalibreOPDSError: + break + yield page + next_link = self._resolve_link(page.links, "next") + + @staticmethod + def _merge_feed_entries(primary: OPDSFeed, secondary: OPDSFeed) -> OPDSFeed: + if primary is secondary or not secondary.entries: + return primary + seen_ids: Set[str] = set() + combined: List[OPDSEntry] = list(primary.entries) + for entry in primary.entries: + entry_id = (entry.id or "").strip() + if entry_id: + seen_ids.add(entry_id) + for entry in secondary.entries: + entry_id = (entry.id or "").strip() + if entry_id and entry_id in seen_ids: + continue + if entry_id: + seen_ids.add(entry_id) + combined.append(entry) + return dataclasses.replace(primary, entries=combined) + + def _local_search( + self, + query: str, + *, + seed_feed: Optional[OPDSFeed] = None, + max_pages: int = 40, + ) -> OPDSFeed: + normalized = (query or "").strip() + if not normalized: + return seed_feed or self.fetch_feed() + tokens = [token for token in re.split(r"\s+", normalized.lower()) if token] + if not tokens: + return seed_feed or self.fetch_feed() + + start_feed = seed_feed or self.fetch_feed() + collected: List[OPDSEntry] = [] + seen_match_ids: Set[str] = set() + + def add_matches(feed: OPDSFeed) -> None: + filtered = self._filter_feed_entries(feed, normalized) + for entry in filtered.entries: + entry_id = (entry.id or "").strip() + if entry_id: + if entry_id in seen_match_ids: + continue + seen_match_ids.add(entry_id) + collected.append(entry) + + add_matches(start_feed) + + queue: Deque[str] = deque() + queued: Set[str] = set() + visited: Set[str] = set() + + def is_navigation_link(rel_hint: Optional[str], link: OPDSLink) -> bool: + rel_candidates: List[str] = [] + if rel_hint: + rel_candidates.append(rel_hint) + if link.rel and link.rel not in rel_candidates: + rel_candidates.append(link.rel) + rel_candidates = [(rel or "").strip().lower() for rel in rel_candidates if rel] + link_type = (link.type or "").strip().lower() + if link_type and "opds-catalog" in link_type: + return True + for rel_value in rel_candidates: + if not rel_value: + continue + if "acquisition" in rel_value: + return False + if rel_value == "self": + continue + if rel_value == "next": + return True + if rel_value in {"start", "up", "down"}: + return True + if rel_value.endswith("navigation") or rel_value.endswith("collection"): + return True + if rel_value.startswith("http://opds-spec.org/"): + if rel_value.startswith("http://opds-spec.org/group") or rel_value.startswith( + "http://opds-spec.org/sort" + ): + return True + if rel_value.endswith("navigation") or rel_value.endswith("collection"): + return True + return False + + def enqueue_link(link: OPDSLink, rel_hint: Optional[str] = None) -> None: + if not is_navigation_link(rel_hint, link): + return + href = (link.href or "").strip() + if not href: + return + absolute = self._make_url(href) + if absolute in queued or absolute in visited: + return + queued.add(absolute) + queue.append(absolute) + + for rel_key, link in (start_feed.links or {}).items(): + enqueue_link(link, rel_key) + for entry in start_feed.entries: + for link in entry.links: + enqueue_link(link, link.rel) + + pages_examined = 0 + while queue and pages_examined < max_pages: + href = queue.popleft() + if href in visited: + continue + visited.add(href) + pages_examined += 1 + try: + feed = self.fetch_feed(href) + except CalibreOPDSError: + continue + add_matches(feed) + for rel_key, link in (feed.links or {}).items(): + enqueue_link(link, rel_key) + for entry in feed.entries: + for link in entry.links: + enqueue_link(link, link.rel) + if collected: + return dataclasses.replace(start_feed, entries=collected) + return dataclasses.replace(start_feed, entries=[]) + + def download(self, href: str) -> DownloadedResource: + if not href: + raise ValueError("Download link missing") + target = self._make_url(href) + try: + with self._open_client() as client: + response = client.get(target, follow_redirects=True) + response.raise_for_status() + except httpx.HTTPStatusError as exc: # pragma: no cover - thin wrapper + raise CalibreOPDSError( + f"Download failed with status {exc.response.status_code}" + ) from exc + except httpx.HTTPError as exc: # pragma: no cover - thin wrapper + raise CalibreOPDSError(f"Download failed: {exc}") from exc + + mime_type = response.headers.get("Content-Type", "application/octet-stream").split(";")[0].strip() + filename = self._deduce_filename(response, target, mime_type) + return DownloadedResource(filename=filename, mime_type=mime_type, content=response.content) + + def _deduce_filename(self, response: httpx.Response, url: str, mime_type: str) -> str: + header = response.headers.get("Content-Disposition", "") + match = re.search(r'filename="?([^";]+)"?', header) + if match: + candidate = match.group(1).strip() + if candidate: + return candidate + parsed = urlparse(url) + stem = (parsed.path or "").strip("/").split("/")[-1] + if not stem: + stem = "download" + if "." not in stem: + extension = self._extension_for_mime(mime_type) + if extension: + stem = f"{stem}{extension}" + return stem + + @staticmethod + def _extension_for_mime(mime_type: str) -> str: + normalized = mime_type.lower() + if normalized in _EPUB_MIME_TYPES: + return ".epub" + if normalized == "application/pdf": + return ".pdf" + if normalized in {"text/plain", "text/html"}: + return ".txt" + return "" + + def _parse_feed(self, xml_payload: str, *, base_url: str) -> OPDSFeed: + try: + root = ET.fromstring(xml_payload) + except ET.ParseError as exc: + raise CalibreOPDSError(f"Unable to parse OPDS feed: {exc}") from exc + + feed_id = root.findtext("atom:id", default=None, namespaces=NS) + feed_title = root.findtext("atom:title", default=None, namespaces=NS) + links_list = self._extract_links(root.findall("atom:link", NS), base_url) + links = self._links_to_dict(links_list) + parsed_entries = [self._parse_entry(node, base_url) for node in root.findall("atom:entry", NS)] + entries: List[OPDSEntry] = [] + for entry in parsed_entries: + if entry.download and self._is_supported_download(entry.download): + entries.append(entry) + continue + if self._has_navigation_link(entry): + entries.append(entry) + return OPDSFeed(id=feed_id, title=feed_title, entries=entries, links=links) + + def _parse_entry(self, node: ET.Element, base_url: str) -> OPDSEntry: + entry_id = node.findtext("atom:id", default="", namespaces=NS).strip() + title = node.findtext("atom:title", default="Untitled", namespaces=NS).strip() or "Untitled" + + subtitle = ( + node.findtext("calibre_md:subtitle", default=None, namespaces=NS) + or node.findtext("calibre:subtitle", default=None, namespaces=NS) + or node.findtext("atom:subtitle", default=None, namespaces=NS) + ) + subtitle = self._strip_html(subtitle.strip()) if subtitle else None + + position_value = self._extract_position(node) + updated = node.findtext("atom:updated", default=None, namespaces=NS) + published = ( + node.findtext("dc:date", default=None, namespaces=NS) + or node.findtext("atom:published", default=None, namespaces=NS) + ) + if published: + published = published.strip() or None + + summary_text = ( + self._extract_text(node.find("atom:summary", NS)) + or self._extract_text(node.find("atom:content", NS)) + or self._extract_text(node.find("dc:description", NS)) + ) + summary_metadata: Dict[str, str] = {} + summary_body: Optional[str] = None + if summary_text: + summary_metadata, summary_body = self._split_summary_metadata(summary_text) + cleaned_summary = self._strip_html(summary_body or summary_text) + + authors: List[str] = [] + for author_node in node.findall("atom:author", NS): + name = author_node.findtext("atom:name", default="", namespaces=NS).strip() + if name: + authors.append(name) + if not authors: + creators = node.findall("dc:creator", NS) + for creator in creators: + value = (creator.text or "").strip() + if value: + authors.append(value) + + links = node.findall("atom:link", NS) + all_links = self._extract_links(links, base_url) + link_dict = self._links_to_dict(all_links) + download_link = self._select_download_link(all_links) + alternate_link = link_dict.get("alternate") + thumb_link = link_dict.get("http://opds-spec.org/image/thumbnail") or link_dict.get( + "thumbnail" + ) + + series_name = ( + node.findtext("calibre:series", default=None, namespaces=NS) + or node.findtext("calibre_md:series", default=None, namespaces=NS) + ) + if series_name: + series_name = series_name.strip() or None + + series_index_raw = ( + node.findtext("calibre:series_index", default=None, namespaces=NS) + or node.findtext("calibre_md:series_index", default=None, namespaces=NS) + ) + series_index: Optional[float] = None + if series_index_raw is not None: + text = str(series_index_raw).strip() + if text: + try: + series_index = float(text) + except ValueError: + match = re.search(r"\d+(?:\.\d+)?", text.replace(",", ".")) + if match: + try: + series_index = float(match.group(0)) + except ValueError: + series_index = None + + if series_name is None or series_index is None: + category_series_name, category_series_index = self._extract_series_from_categories( + node.findall("atom:category", NS), + authors=authors, + ) + if series_name is None and category_series_name: + series_name = category_series_name + if series_index is None and category_series_index is not None: + series_index = category_series_index + + if (series_name is None or series_index is None) and summary_text: + text_series_name, text_series_index = self._extract_series_from_text(summary_text) + if series_name is None and text_series_name: + series_name = text_series_name + if series_index is None and text_series_index is not None: + series_index = text_series_index + + tags_value = summary_metadata.get("TAGS") + tags = self._parse_tags(tags_value) if tags_value else [] + rating_value = summary_metadata.get("RATING") + rating, rating_max = self._parse_rating(rating_value) if rating_value else (None, None) + + return OPDSEntry( + id=entry_id or title, + title=title, + position=position_value, + authors=authors, + subtitle=subtitle, + updated=updated, + published=published, + summary=cleaned_summary, + download=download_link, + alternate=alternate_link, + thumbnail=thumb_link, + links=all_links, + series=series_name, + series_index=series_index, + tags=tags, + rating=rating, + rating_max=rating_max, + ) + + def _extract_series_from_categories( + self, + category_nodes: List[ET.Element], + *, + authors: Optional[List[str]] = None, + ) -> tuple[Optional[str], Optional[float]]: + name: Optional[str] = None + index: Optional[float] = None + author_set = {str(author).strip().casefold() for author in (authors or []) if str(author).strip()} + for category in category_nodes: + scheme = (category.attrib.get("scheme") or "").strip().lower() + label = (category.attrib.get("label") or "").strip() + term = (category.attrib.get("term") or "").strip() + values: List[str] = [] + if label: + values.append(label) + if term and term not in values: + values.append(term) + + # Be conservative: category schemes are often URLs and can contain unrelated substrings. + # Also, some catalog feeds incorrectly include author names in series-like categories. + is_series_hint = self._is_series_scheme(scheme) or any("series" in value.lower() for value in values if value) + if not is_series_hint: + continue + + for value in values: + if not value: + continue + candidate_name, candidate_index = self._parse_series_value(value) + if candidate_name and candidate_name.casefold() in author_set: + # Guardrail: avoid mapping the author name into series. + continue + if candidate_name and not name: + name = candidate_name + if candidate_index is not None and index is None: + index = candidate_index + if name and index is not None: + return name, index + return name, index + + @staticmethod + def _is_series_scheme(scheme: str) -> bool: + cleaned = (scheme or "").strip().lower() + if not cleaned: + return False + if "author" in cleaned: + return False + return bool(re.search(r"(^|[/#:\-])series([/#:\-]|$)", cleaned)) + + def _parse_series_value(self, value: str) -> tuple[Optional[str], Optional[float]]: + cleaned = re.sub(r"\s+", " ", value or "").strip() + if not cleaned: + return None, None + cleaned = _SERIES_PREFIX_RE.sub("", cleaned) + working = cleaned + number: Optional[float] = None + + bracket_match = _SERIES_NUMBER_BRACKET_RE.search(working) + if bracket_match: + number = self._coerce_series_index(bracket_match.group(1)) + start, end = bracket_match.span() + working = (working[:start] + working[end:]).strip() + + if number is None: + hash_match = _SERIES_NUMBER_HASH_RE.search(working) + if hash_match: + number = self._coerce_series_index(hash_match.group(1)) + start, end = hash_match.span() + working = (working[:start] + working[end:]).strip() + + if number is None: + book_match = _SERIES_NUMBER_BOOK_RE.search(working) + if book_match: + number = self._coerce_series_index(book_match.group(1)) + start, end = book_match.span() + working = (working[:start] + working[end:]).strip() + + name = working.strip(" -–—,:") + name = re.sub(r"\s+", " ", name).strip() + if not name: + name = None + return name, number + + @staticmethod + def _extract_text(node: Optional[ET.Element]) -> Optional[str]: + if node is None: + return None + # Prefer itertext to capture nested XHTML content + parts = list(node.itertext()) + if not parts: + return (node.text or "").strip() or None + combined = "".join(parts).strip() + return combined or None + + def _extract_series_from_text(self, text: str) -> tuple[Optional[str], Optional[float]]: + for line in text.splitlines(): + match = _SERIES_LINE_TEXT_RE.match(line) + if not match: + continue + candidate = match.group(1).strip() + if not candidate: + continue + name, number = self._parse_series_value(candidate) + if name or number is not None: + return name, number + return None, None + + def _split_summary_metadata(self, text: Optional[str]) -> tuple[Dict[str, str], Optional[str]]: + metadata: Dict[str, str] = {} + if text is None: + return metadata, None + lines = text.splitlines() + index = 0 + total = len(lines) + while index < total and not lines[index].strip(): + index += 1 + while index < total: + stripped = lines[index].strip() + if not stripped: + break + match = _SUMMARY_METADATA_LINE_RE.match(stripped) + if not match: + break + key = match.group(1).strip().upper() + value = match.group(2).strip() + if key and value: + metadata[key] = value + index += 1 + remainder = "\n".join(lines[index:]).strip() + return metadata, (remainder or None) + + @staticmethod + def _parse_tags(value: str) -> List[str]: + if not value: + return [] + tokens = re.split(r"[;,\n]\s*", value) + cleaned: List[str] = [] + seen: set[str] = set() + for token in tokens: + entry = token.strip() + if not entry: + continue + key = entry.casefold() + if key in seen: + continue + seen.add(key) + cleaned.append(entry) + return cleaned + + @staticmethod + def _parse_rating(value: str) -> tuple[Optional[float], Optional[float]]: + if not value: + return None, None + text = value.strip() + if not text: + return None, None + stars = text.count("★") + half = 0.5 if "½" in text else 0.0 + if stars or half: + rating = stars + half + return (rating if rating > 0 else None, 5.0) + match = re.search(r"\d+(?:\.\d+)?", text.replace(",", ".")) + if match: + try: + rating_value = float(match.group(0)) + except ValueError: + return None, None + return rating_value, 5.0 + return None, None + + @staticmethod + def _coerce_series_index(value: str) -> Optional[float]: + text = value.strip().replace(",", ".") + if not text: + return None + try: + return float(text) + except ValueError: + return None + + def _extract_position(self, node: ET.Element) -> Optional[int]: + candidates = [ + node.findtext("opds:position", default=None, namespaces=NS), + node.findtext("opds:groupPosition", default=None, namespaces=NS), + node.findtext("opds:order", default=None, namespaces=NS), + node.findtext("dc:identifier", default=None, namespaces=NS), + ] + for value in candidates: + if value is None: + continue + text = str(value).strip() + if not text: + continue + try: + return int(float(text)) + except (TypeError, ValueError): + continue + return None + + def _extract_links(self, link_nodes: List[ET.Element], base_url: str) -> List[OPDSLink]: + links: List[OPDSLink] = [] + for link in link_nodes: + href = link.attrib.get("href") + if not href: + continue + rel = link.attrib.get("rel") + link_type = link.attrib.get("type") + title = link.attrib.get("title") + base_for_join = base_url or self._base_url + absolute_href = urljoin(base_for_join, href) + links.append(OPDSLink(href=absolute_href, rel=rel, type=link_type, title=title)) + return links + + def _links_to_dict(self, links: List[OPDSLink]) -> Dict[str, OPDSLink]: + results: Dict[str, OPDSLink] = {} + for entry in links: + key = entry.rel or entry.href + if not key: + continue + + # Prioritize search links with template parameters + if key == "search" and key in results: + existing = results[key] + if "{searchTerms}" in (existing.href or ""): + continue + if "{searchTerms}" in (entry.href or ""): + results[key] = entry + continue + + results[key] = entry + return results + + @staticmethod + def _is_supported_download(link: OPDSLink) -> bool: + mime = (link.type or "").split(";")[0].strip().lower() + if mime in _SUPPORTED_DOWNLOAD_MIME_TYPES: + return True + href = (link.href or "").strip() + if not href: + return False + parsed_path = urlparse(href).path or "" + extension = PurePosixPath(parsed_path).suffix.lower() + return extension in _SUPPORTED_DOWNLOAD_EXTENSIONS + + @staticmethod + def _select_download_link(links: Mapping[str, OPDSLink] | Iterable[OPDSLink]) -> Optional[OPDSLink]: + if isinstance(links, Mapping): + iterable: List[OPDSLink] = list(links.values()) + else: + iterable = list(links) + supported = [link for link in iterable if CalibreOPDSClient._is_supported_download(link)] + best: Optional[OPDSLink] = None + for link in supported: + rel = (link.rel or "").lower() + if "acquisition" not in rel: + continue + mime = (link.type or "").lower() + if mime in _EPUB_MIME_TYPES: + return link + if best is None: + best = link + if best: + return best + if supported: + return supported[0] + # No valid acquisition-style link exposed + return None + + @staticmethod + def _resolve_link(links: Optional[Mapping[str, OPDSLink]], rel: str) -> Optional[OPDSLink]: + if not links: + return None + if rel in links: + return links[rel] + rel_lower = rel.lower() + for key, link in links.items(): + key_lower = (key or "").strip().lower() + if key_lower == rel_lower or key_lower.endswith(rel_lower): + return link + return None + + @staticmethod + def _is_navigation_link(link: OPDSLink) -> bool: + href = (link.href or "").strip() + if not href: + return False + rel = (link.rel or "").strip().lower() + link_type = (link.type or "").strip().lower() + if "acquisition" in rel: + return False + if rel == "self": + return False + if "opds-catalog" in link_type: + return True + if rel.endswith("navigation") or rel.endswith("collection"): + return True + if rel.startswith("http://opds-spec.org/sort") or rel.startswith("http://opds-spec.org/group"): + return True + return False + + @staticmethod + def _has_navigation_link(entry: OPDSEntry) -> bool: + return any(CalibreOPDSClient._is_navigation_link(link) for link in entry.links) + + @staticmethod + def _browse_mode_for_title(title: Optional[str]) -> str: + if not title: + return "generic" + lowered = title.lower() + if "author" in lowered: + return "author" + if "series" in lowered: + return "series" + if "title" in lowered or "book" in lowered: + return "title" + return "generic" + + @staticmethod + def _strip_leading_article(text: str) -> str: + working = text.strip() + lowered = working.lower() + for article in ("the ", "a ", "an "): + if lowered.startswith(article): + return working[len(article):].strip() + return working + + @staticmethod + def _alphabet_source(entry: OPDSEntry, mode: str) -> str: + if mode == "author" and entry.authors: + candidate = entry.authors[0] or "" + if "," in candidate: + return candidate.split(",", 1)[0].strip() + parts = candidate.split() + if len(parts) > 1: + return parts[-1].strip() + return candidate.strip() + if mode == "series" and entry.series: + return entry.series.strip() + if entry.title: + return entry.title.strip() + if entry.series: + return entry.series.strip() + for link in entry.links: + if link.title: + return link.title.strip() + return "" + + @staticmethod + def _normalize_text(text: str) -> str: + if not text: + return "" + # Normalize unicode characters to their base form (e.g. é -> e) + normalized = unicodedata.normalize('NFKD', text).encode('ASCII', 'ignore').decode('utf-8') + return normalized.lower().strip() + + @staticmethod + def _alphabet_letter_for_entry(entry: OPDSEntry, mode: str) -> Optional[str]: + source = CalibreOPDSClient._alphabet_source(entry, mode) + if not source: + return None + if mode == "title": + source = CalibreOPDSClient._strip_leading_article(source) + + # Normalize to handle accents (É -> E) + normalized_source = unicodedata.normalize('NFKD', source).encode('ASCII', 'ignore').decode('utf-8') + + cleaned = re.sub(r"^[^0-9A-Za-z]+", "", normalized_source) + if not cleaned: + return "#" + initial = cleaned[0] + if initial.isalpha(): + return initial.upper() + if initial.isdigit(): + return "#" + return "#" + + @staticmethod + def _entry_matches_query(entry: OPDSEntry, tokens: List[str]) -> bool: + if not tokens: + return True + search_fragments: List[str] = [] + + if entry.title: + search_fragments.append(CalibreOPDSClient._normalize_text(entry.title)) + if entry.series: + search_fragments.append(CalibreOPDSClient._normalize_text(entry.series)) + + for author in entry.authors: + cleaned = (author or "").strip() + if not cleaned: + continue + normalized_author = CalibreOPDSClient._normalize_text(cleaned) + search_fragments.append(normalized_author) + for part in re.split(r"[\s,]+", normalized_author): + part = part.strip() + if part: + search_fragments.append(part) + + if not search_fragments: + return False + + # Check if all tokens match at least one fragment + # Tokens are already normalized in _filter_feed_entries + return all(any(token in fragment for fragment in search_fragments) for token in tokens) + + def _filter_feed_entries(self, feed: OPDSFeed, query: str) -> OPDSFeed: + normalized_query = CalibreOPDSClient._normalize_text(query) + if not normalized_query: + return feed + tokens = [token for token in re.split(r"\s+", normalized_query) if token] + if not tokens: + return feed + + scored_entries = [] + for entry in feed.entries: + if not self._entry_matches_query(entry, tokens): + continue + score = self._calculate_match_score(entry, tokens) + # Require a minimum score to avoid weak matches (e.g. single word in summary) + if score >= 10: + scored_entries.append((score, entry)) + + # Sort by score descending + scored_entries.sort(key=lambda x: x[0], reverse=True) + + filtered = [e for s, e in scored_entries] + return dataclasses.replace(feed, entries=filtered) + + def _estimate_letter_position(self, letter: str) -> float: + """Estimate the relative position (0.0-1.0) of a letter in an alphabetical list.""" + if letter == "#": + return 0.0 + if not letter or not letter.isalpha(): + return 0.0 + + # Approximate cumulative distribution of starting letters in English book titles + # This is a heuristic to jump closer to the target + weights = { + 'A': 0.00, 'B': 0.08, 'C': 0.15, 'D': 0.22, 'E': 0.28, + 'F': 0.33, 'G': 0.38, 'H': 0.43, 'I': 0.49, 'J': 0.53, + 'K': 0.55, 'L': 0.58, 'M': 0.63, 'N': 0.68, 'O': 0.71, + 'P': 0.75, 'Q': 0.80, 'R': 0.81, 'S': 0.85, 'T': 0.92, + 'U': 0.97, 'V': 0.98, 'W': 0.99, 'X': 0.995, 'Y': 0.997, 'Z': 0.999 + } + return weights.get(letter.upper(), 0.0) + + def _attempt_smart_jump(self, feed: OPDSFeed, letter: str) -> Optional[OPDSFeed]: + """ + Attempt to jump to a page closer to the target letter by analyzing pagination links. + Returns a new OPDSFeed if a jump was successful, or None. + """ + first_link = self._resolve_link(feed.links, "first") + last_link = self._resolve_link(feed.links, "last") + next_link = self._resolve_link(feed.links, "next") + + if not (first_link and last_link and next_link): + return None + + # Try to extract offsets from URLs to determine page size and total items + # Common Calibre pattern: .../offset/0, .../offset/50 + def extract_offset(href: str) -> Optional[int]: + match = re.search(r"/(\d+)/?$", href) + if match: + return int(match.group(1)) + # Try query param + parsed = urlparse(href) + qs = dict(pair.split('=') for pair in parsed.query.split('&') if '=' in pair) + if 'offset' in qs: + try: + return int(qs['offset']) + except ValueError: + pass + return None + + start_offset = extract_offset(first_link.href) + next_offset = extract_offset(next_link.href) + last_offset = extract_offset(last_link.href) + + if start_offset is None or next_offset is None or last_offset is None: + return None + + page_size = next_offset - start_offset + if page_size <= 0: + return None + + # Estimate total items (last_offset is the start of the last page) + # We assume the last page is roughly half full for estimation + total_items = last_offset + (page_size // 2) + + target_ratio = self._estimate_letter_position(letter) + # Aim slightly early (subtract 1-2 pages worth) to ensure we don't miss the start + target_offset = int(total_items * target_ratio) + target_offset = max(0, target_offset - (page_size * 2)) + + # Round to nearest page boundary + target_offset = (target_offset // page_size) * page_size + + # If the jump is too small (e.g. we are already near the start), don't bother + if target_offset < (page_size * 3): + return None + + # Construct the new URL + # We assume the URL structure is consistent and we can just replace the offset + # This is risky but works for standard Calibre OPDS + base_href = first_link.href + if str(start_offset) in base_href: + # Path based replacement + # Replace the last occurrence of the offset + parts = base_href.rsplit(str(start_offset), 1) + if len(parts) == 2: + new_href = f"{parts[0]}{target_offset}{parts[1]}" + try: + return self.fetch_feed(new_href) + except Exception: + return None + + return None + + def browse_letter( + self, + letter: str, + *, + start_href: Optional[str] = None, + max_pages: int = 40, + ) -> OPDSFeed: + normalized = (letter or "").strip() + if not normalized: + return self.fetch_feed(start_href) + key = normalized.upper() + if key in {"ALL", "*"}: + return self.fetch_feed(start_href) + if key in {"0-9", "NUMERIC"}: + key = "#" + if len(key) > 1: + key = key[0] + if key != "#" and not key.isalpha(): + key = "#" + base_feed = self.fetch_feed(start_href) + + # Ensure we start from the beginning of the feed if possible + first_link = self._resolve_link(base_feed.links, "first") or self._resolve_link(base_feed.links, "start") + if first_link and first_link.href: + try: + # Only switch if the href is different to avoid redundant fetch + if not start_href or first_link.href != start_href: + base_feed = self.fetch_feed(first_link.href) + except CalibreOPDSError: + pass + + mode = self._browse_mode_for_title(base_feed.title) + + def letter_matches(entry: OPDSEntry, active_mode: str) -> bool: + letter_value = self._alphabet_letter_for_entry(entry, active_mode) + if not letter_value: + return False + if key == "#": + return letter_value == "#" + return letter_value == key + + collected: List[OPDSEntry] = [] + seen_ids: Set[str] = set() + letter_href: Optional[str] = None + + def add_entry(entry: OPDSEntry) -> None: + entry_id = (entry.id or "").strip() + if entry_id: + if entry_id in seen_ids: + return + seen_ids.add(entry_id) + collected.append(entry) + + # Check the first page for navigation links before attempting any jumps + # This handles the case where "By Title" has "A", "B", "C" folders + has_nav_links = False + for entry in base_feed.entries: + if self._has_navigation_link(entry): + has_nav_links = True + if letter_matches(entry, mode): + for link in entry.links: + if self._is_navigation_link(link): + href = (link.href or "").strip() + if href: + letter_href = href + break + if letter_href: + break + + # If we didn't find a direct link, and it looks like a flat list (no nav links matching criteria), + # try to jump closer to the target letter if we are in a sorted mode + if not letter_href and mode in {"title", "author", "series"}: + jump_feed = self._attempt_smart_jump(base_feed, key) + if jump_feed: + base_feed = jump_feed + + for page in self._iter_paginated_feeds(base_feed, max_pages=max_pages): + for entry in page.entries: + if not letter_matches(entry, mode): + continue + if self._has_navigation_link(entry): + if letter_href is None: + for link in entry.links: + if self._is_navigation_link(link): + href = (link.href or "").strip() + if href: + letter_href = href + break + else: + add_entry(entry) + + letter_feed: Optional[OPDSFeed] = None + if letter_href: + try: + letter_feed = self.fetch_feed(letter_href) + except CalibreOPDSError: + letter_feed = None + else: + letter_mode = self._browse_mode_for_title(letter_feed.title) + for page in self._iter_paginated_feeds(letter_feed, max_pages=max_pages): + for entry in page.entries: + if not letter_matches(entry, letter_mode): + continue + if self._has_navigation_link(entry): + continue + add_entry(entry) + + template = letter_feed or base_feed + if collected: + return dataclasses.replace(template, entries=collected) + return dataclasses.replace(template, entries=[]) + + def _resolve_search_url(self, feed: OPDSFeed, query: str) -> Optional[str]: + link = self._resolve_link(feed.links, "search") + if not link: + link = self._resolve_link(feed.links, "http://opds-spec.org/search") + + if not link or not link.href: + return None + + href = link.href.strip() + if "{searchTerms}" in href: + return href.replace("{searchTerms}", quote(query)) + + return href + + def _calculate_match_score(self, entry: OPDSEntry, tokens: List[str]) -> int: + if not tokens: + return 0 + + score = 0 + + # Prepare normalized text + title = self._normalize_text(entry.title) + authors = [self._normalize_text(a) for a in entry.authors] + series = self._normalize_text(entry.series) if entry.series else "" + summary = self._normalize_text(entry.summary) if entry.summary else "" + tags = [self._normalize_text(t) for t in entry.tags] + + # 1. Exact/Phrase matches + query_phrase = " ".join(tokens) + if query_phrase == title: + score += 1000 + elif query_phrase in title: + score += 500 + + for author in authors: + if query_phrase in author: + score += 300 + + if query_phrase in series: + score += 200 + + for tag in tags: + if query_phrase == tag: + score += 100 + elif query_phrase in tag: + score += 50 + + # 2. Token matches + # Filter out stop words unless the query is only stop words + significant_tokens = [t for t in tokens if t not in _STOP_WORDS] + if not significant_tokens: + significant_tokens = tokens + + for token in significant_tokens: + token_score = 0 + # Use regex for word boundary matching + # Escape token to handle special chars + token_regex = r"\b" + re.escape(token) + r"\b" + + # Title: High weight + if re.search(token_regex, title): + token_score = max(token_score, 50) + elif token in title: + token_score = max(token_score, 5) + + # Author: Medium-High weight + for author in authors: + if re.search(token_regex, author): + token_score = max(token_score, 40) + elif token in author: + token_score = max(token_score, 5) + + # Series: Medium weight + if token in series: + if re.search(token_regex, series): + token_score = max(token_score, 30) + else: + token_score = max(token_score, 5) + + # Tags: Medium weight + for tag in tags: + if re.search(token_regex, tag): + token_score = max(token_score, 30) + elif token in tag: + token_score = max(token_score, 5) + + # Summary: Low weight + if token in summary: + if re.search(token_regex, summary): + # Only add if not found elsewhere? Or just add small amount? + if token_score == 0: + token_score = 15 + else: + token_score += 5 # Small boost if also in description + elif token_score == 0: + token_score = 2 # Very low for substring in summary + + score += token_score + + return score diff --git a/abogen/kokoro_text_normalization.py b/abogen/kokoro_text_normalization.py new file mode 100644 index 0000000..563e63d --- /dev/null +++ b/abogen/kokoro_text_normalization.py @@ -0,0 +1,2240 @@ +from __future__ import annotations + +import json +import re +import unicodedata +import os +import locale +from fractions import Fraction +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple +import logging + +logger = logging.getLogger(__name__) + +try: # pragma: no cover - optional dependency guard + from num2words import num2words +except ImportError: + num2words = None + logger.warning("num2words library not found. Number normalization will be disabled.") +except Exception as e: # pragma: no cover - graceful degradation + num2words = None + logger.error(f"Failed to import num2words: {e}") + +HAS_NUM2WORDS = num2words is not None + +if TYPE_CHECKING: # pragma: no cover - type checking only + from abogen.llm_client import LLMCompletion + +from abogen.spacy_contraction_resolver import resolve_ambiguous_contractions + +# ---------- Contraction Category Defaults ---------- + +CONTRACTION_CATEGORY_DEFAULTS: Dict[str, bool] = { + "contraction_aux_be": True, + "contraction_aux_have": True, + "contraction_modal_will": True, + "contraction_modal_would": True, + "contraction_negation_not": True, + "contraction_let_us": True, +} + +# ---------- Configuration Dataclass ---------- + +@dataclass +class ApostropheConfig: + contraction_mode: str = "expand" # expand|collapse|keep + possessive_mode: str = "keep" # keep|collapse + plural_possessive_mode: str = "collapse" # keep|collapse + irregular_possessive_mode: str = "keep" # keep|expand (expand just means keep or add hints; modify if needed) + sibilant_possessive_mode: str = "mark" # keep|mark|approx + fantasy_mode: str = "keep" # keep|mark|collapse_internal + acronym_possessive_mode: str = "keep" # keep|collapse_add_s + decades_mode: str = "expand" # keep|expand + leading_elision_mode: str = "expand" # keep|expand + ambiguous_past_modal_mode: str = "contextual" # keep|expand_prefer_would|expand_prefer_had|contextual + add_phoneme_hints: bool = True # Whether to emit markers like ‹IZ› + fantasy_marker: str = "‹FAP›" # Marker inserted if fantasy_mode == mark + sibilant_iz_marker: str = "‹IZ›" # Marker for /ɪz/ insertion + joiner: str = "" # Replacement used when collapsing internal apostrophes + lowercase_for_matching: bool = True # Normalize to lower for rule matching (not output) + protect_cultural_names: bool = True # Always keep O'Brien, D'Angelo, etc. + convert_numbers: bool = True # Convert grouped numbers such as 12,500 to words + convert_currency: bool = True # Convert currency symbols to words + remove_footnotes: bool = True # Remove footnote indicators + number_lang: str = "en" # num2words language code + year_pronunciation_mode: str = "american" # off|american (extend if needed) + contraction_categories: Dict[str, bool] = field(default_factory=lambda: dict(CONTRACTION_CATEGORY_DEFAULTS)) + + def is_contraction_enabled(self, category: str) -> bool: + return self.contraction_categories.get(category, True) + +# ---------- Dictionaries / Patterns ---------- + +# Common contraction expansions (type + expansion words) +CONTRACTION_LEXICON: Dict[str, Tuple[str, Tuple[str, ...]]] = { + "let's": ("contraction_let_us", ("let", "us")), + "can't": ("contraction_negation_not", ("can", "not")), + "won't": ("contraction_negation_not", ("will", "not")), + "don't": ("contraction_negation_not", ("do", "not")), + "doesn't": ("contraction_negation_not", ("does", "not")), + "didn't": ("contraction_negation_not", ("did", "not")), + "isn't": ("contraction_negation_not", ("is", "not")), + "aren't": ("contraction_negation_not", ("are", "not")), + "wasn't": ("contraction_negation_not", ("was", "not")), + "weren't": ("contraction_negation_not", ("were", "not")), + "haven't": ("contraction_negation_not", ("have", "not")), + "hasn't": ("contraction_negation_not", ("has", "not")), + "hadn't": ("contraction_negation_not", ("had", "not")), + "couldn't": ("contraction_negation_not", ("could", "not")), + "shouldn't": ("contraction_negation_not", ("should", "not")), + "wouldn't": ("contraction_negation_not", ("would", "not")), + "mustn't": ("contraction_negation_not", ("must", "not")), + "mightn't": ("contraction_negation_not", ("might", "not")), + "shan't": ("contraction_negation_not", ("shall", "not")), +} + +SUFFIX_CONTRACTION_RULES: Tuple[Tuple[str, str, str], ...] = ( + ("'ll", "will", "contraction_modal_will"), + ("'re", "are", "contraction_aux_be"), + ("'ve", "have", "contraction_aux_have"), +) + +SUFFIX_CONTRACTION_BASES: Dict[str, Tuple[str, ...]] = { + "'m": ("i",), +} + +# For ambiguous 'd and 's we handle separately +_NUMBER_WITH_GROUP_RE = re.compile(r"(?{_NUMBER_CORE_PATTERN})(?P\s*[{_NUMBER_RANGE_CLASS}]\s*)(?P{_NUMBER_CORE_PATTERN})(?![\w{_NUMBER_RANGE_CLASS}/])" +) +_NUMBER_SPACE_RANGE_RE = re.compile( + rf"(?{_NUMBER_CORE_PATTERN})(?P\s+)(?P{_NUMBER_CORE_PATTERN})(?![\w{_NUMBER_RANGE_CLASS}/])" +) +_FRACTION_SLASHES = "/⁄" +_FRACTION_SLASH_CLASS = re.escape(_FRACTION_SLASHES) +_FRACTION_RE = re.compile( + rf"(?-?\d+)\s*[{_FRACTION_SLASH_CLASS}]\s*(?P-?\d+)(?![\w{_FRACTION_SLASH_CLASS}])" +) + +_CURRENCY_RE = re.compile( + r"(?P[$£€¥])\s*(?P\d{1,3}(?:,\d{3})*(?:\.\d+)?)(?:\s+(?Phundred|thousand|million|billion|trillion|quadrillion))?(?!\d)", + re.IGNORECASE +) + +_URL_RE = re.compile(r"(https?://)?(www\.)?(?P[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+)(/[^\s]*)?") +_FOOTNOTE_RE = re.compile(r"([a-zA-Z]+)(\d+)") +_BRACKET_FOOTNOTE_RE = re.compile(r"\[\d+\]") + +_ISO_DATE_RE = re.compile(r"\b(?P\d{4})[/-](?P\d{1,2})[/-](?P\d{1,2})\b") +_MDY_DATE_RE = re.compile( + r"\b(?PJan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\.?\s+" + r"(?P\d{1,2})(?:st|nd|rd|th)?\s*,\s*(?P\d{4})\b", + re.IGNORECASE, +) + +_TIME_RE = re.compile( + r"\b(?P\d{1,2})(?::(?P\d{2}))?\s*(?Pa\.?m\.?|p\.?m\.?)\b", + re.IGNORECASE, +) + +_ADDRESS_ABBR_RE = re.compile(r"(?P\b\w+\s+)(?PSt|Rd|Ave|Blvd|Ln|Dr)\.(?=\s*(?:,|\.|!|\?|$))") + +_MONTH_MAP = { + "jan": "January", + "january": "January", + "feb": "February", + "february": "February", + "mar": "March", + "march": "March", + "apr": "April", + "april": "April", + "may": "May", + "jun": "June", + "june": "June", + "jul": "July", + "july": "July", + "aug": "August", + "august": "August", + "sep": "September", + "sept": "September", + "september": "September", + "oct": "October", + "october": "October", + "nov": "November", + "november": "November", + "dec": "December", + "december": "December", +} + + +def _is_us_locale() -> bool: + for key in ("LC_ALL", "LC_TIME", "LANG"): + value = os.environ.get(key) + if value and "en_US" in value: + return True + try: + loc = locale.getlocale(locale.LC_TIME) + if loc and loc[0] and "en_US" in loc[0]: + return True + except Exception: + pass + return False + + +def _year_to_words_american(value: int, language: str) -> str: + if language.lower().startswith("en") and 2000 <= value <= 2099: + if value == 2000: + return "two thousand" + if 2001 <= value <= 2009: + tail = _DIGIT_WORDS[value % 10] + return f"two thousand {tail}" + + first_two = value // 100 + last_two = value % 100 + first_words = _int_to_words(first_two, language) or "twenty" + if last_two == 0: + return f"{first_words} hundred" + if last_two < 10: + return f"{first_words} oh {_DIGIT_WORDS[last_two]}" + last_words = _int_to_words(last_two, language) + return f"{first_words} {last_words or last_two}" + + words = _int_to_words(value, language) + return words or str(value) + + +def _normalize_dates(text: str, language: str) -> str: + us = _is_us_locale() + + def _format_iso(match: re.Match[str]) -> str: + year = int(match.group("year")) + month = int(match.group("month")) + day = int(match.group("day")) + if not (1 <= month <= 12 and 1 <= day <= 31): + return match.group(0) + month_name = ( + [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ][month - 1] + ) + ordinal = _int_to_ordinal_words(day, language) or str(day) + year_words = _year_to_words_american(year, language) + return f"{month_name} {ordinal}, {year_words}" if us else f"{ordinal} {month_name} {year_words}" + + def _format_mdy(match: re.Match[str]) -> str: + month_raw = str(match.group("month") or "").strip().lower().rstrip(".") + month_name = _MONTH_MAP.get(month_raw) + if not month_name: + return match.group(0) + day = int(match.group("day")) + year = int(match.group("year")) + ordinal = _int_to_ordinal_words(day, language) or str(day) + year_words = _year_to_words_american(year, language) + return f"{month_name} {ordinal}, {year_words}" if us else f"{ordinal} {month_name} {year_words}" + + out = _ISO_DATE_RE.sub(_format_iso, text) + out = _MDY_DATE_RE.sub(_format_mdy, out) + return out + + +def _normalize_times(text: str) -> str: + def _replace(match: re.Match[str]) -> str: + hour = match.group("hour") + minute = match.group("minute") + meridian = match.group("meridian").lower().replace(".", "") + if minute: + return f"{hour}:{minute} {meridian}" + return f"{hour} {meridian}" + + return _TIME_RE.sub(_replace, text) + + +def _normalize_address_abbreviations(text: str) -> str: + mapping = { + "st": "street", + "rd": "road", + "ave": "avenue", + "blvd": "boulevard", + "ln": "lane", + "dr": "drive", + } + + def _replace(match: re.Match[str]) -> str: + abbr = match.group("abbr") + full = mapping.get(abbr.lower()) + if not full: + return match.group(0) + return match.group("prefix") + _match_casing(abbr, full) + + return _ADDRESS_ABBR_RE.sub(_replace, text) + + +def _normalize_internet_slang(text: str) -> str: + mapping = { + "pls": "please", + "plz": "please", + } + + def _replace(match: re.Match[str]) -> str: + token = match.group(0) + replacement = mapping.get(token.lower()) + if not replacement: + return token + return _match_casing(token, replacement) + + return re.sub(r"\b(?:pls|plz)\b", _replace, text, flags=re.IGNORECASE) + +_DECIMAL_NUMBER_RE = re.compile( + rf"(?-?(?:\d{{1,3}}(?:,\d{{3}})+|\d+)\.(?P\d+))(?![\w{_NUMBER_RANGE_CLASS}/])" +) +_PLAIN_NUMBER_RE = re.compile( + rf"(?{_NUMBER_CORE_PATTERN})(?![\w{_NUMBER_RANGE_CLASS}/])" +) + +_DIGIT_WORDS = ("zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine") + + +def _int_to_words(value: int, language: str) -> Optional[str]: + """Convert integer to spelled-out words using configured language.""" + if num2words is None: + return None + + try: + words = num2words(abs(value), lang=language) + except Exception: # pragma: no cover - unsupported locale + return None + + if value < 0: + return f"minus {words}" + return words + + +def _int_to_ordinal_words(value: int, language: str) -> Optional[str]: + if num2words is not None: + try: + return num2words(value, lang=language, ordinal=True) + except Exception: # pragma: no cover - unsupported locale + return None + + if language.lower().startswith("en"): + ordinals = { + 1: "first", + 2: "second", + 3: "third", + 4: "fourth", + 5: "fifth", + 6: "sixth", + 7: "seventh", + 8: "eighth", + 9: "ninth", + 10: "tenth", + 11: "eleventh", + 12: "twelfth", + 13: "thirteenth", + 14: "fourteenth", + 15: "fifteenth", + 16: "sixteenth", + 17: "seventeenth", + 18: "eighteenth", + 19: "nineteenth", + 20: "twentieth", + 21: "twenty-first", + 22: "twenty-second", + 23: "twenty-third", + 24: "twenty-fourth", + 25: "twenty-fifth", + 26: "twenty-sixth", + 27: "twenty-seventh", + 28: "twenty-eighth", + 29: "twenty-ninth", + 30: "thirtieth", + 31: "thirty-first", + } + return ordinals.get(int(value)) + + return None + + +def _pluralize_fraction_word(base: str) -> str: + if base == "half": + return "halves" + if base == "calf": # defensive; unlikely but keeps pattern predictable + return "calves" + if base.endswith("f"): + return base[:-1] + "ves" + if base.endswith("fe"): + return base[:-2] + "ves" + return base + "s" + + +def _fraction_denominator_word(denominator: int, numerator: int, language: str) -> Optional[str]: + """Return spoken form for fraction denominator respecting plurality.""" + if denominator == 0: + return None + + numerator_abs = abs(numerator) + if denominator == 1: + return "" + if denominator == 2: + return "half" if numerator_abs == 1 else "halves" + if denominator == 4: + return "quarter" if numerator_abs == 1 else "quarters" + + base = _int_to_ordinal_words(denominator, language) + if base is None: + return None + if numerator_abs == 1: + return base + return _pluralize_fraction_word(base) + + +def _format_fraction_words(numerator: int, denominator: int, language: str) -> Optional[str]: + """Return spoken representation of a simple fraction.""" + if denominator == 0: + return None + + fraction = Fraction(numerator, denominator) + num = fraction.numerator + den = fraction.denominator + + if abs(den) > 100: + return None + + numerator_words = _int_to_words(abs(num), language) + if numerator_words is None: + return None + + denom_word = _fraction_denominator_word(den, num, language) + if denom_word is None: + return None + + if denom_word: + if num < 0: + numerator_words = f"minus {numerator_words}" + return f"{numerator_words} {denom_word}".strip() + + # If denominator collapses to 1, just speak the integer value. + spoken = _int_to_words(num, language) + return spoken + + +def _replace_number_range(match: re.Match[str], language: str) -> str: + left_raw = match.group("left") + right_raw = match.group("right") + left = _coerce_int_token(left_raw) + right = _coerce_int_token(right_raw) + if left is None or right is None: + return match.group(0) + + left_words = _int_to_words(left, language) + right_words = _int_to_words(right, language) + if not left_words or not right_words: + return match.group(0) + + return f"{left_words} to {right_words}" + + +def _replace_space_separated_range(match: re.Match[str], language: str) -> str: + left_raw = match.group("left") + right_raw = match.group("right") + left = _coerce_int_token(left_raw) + right = _coerce_int_token(right_raw) + if left is None or right is None: + return match.group(0) + + left_words = _int_to_words(left, language) + right_words = _int_to_words(right, language) + if not left_words or not right_words: + return match.group(0) + + return f"{left_words} to {right_words}" + + +def _replace_fraction(match: re.Match[str], language: str) -> str: + numerator_raw = match.group("numerator") + denominator_raw = match.group("denominator") + try: + numerator = int(numerator_raw) + denominator = int(denominator_raw) + except ValueError: + return match.group(0) + + spoken = _format_fraction_words(numerator, denominator, language) + if not spoken: + return match.group(0) + return spoken + + +def _coerce_int_token(token: str) -> Optional[int]: + if token is None: + return None + cleaned = token.replace(",", "").strip() + if not cleaned or cleaned in {"-", "+"}: + return None + try: + return int(cleaned) + except ValueError: + return None +AMBIGUOUS_D_BASES = {"i","you","he","she","we","they"} +AMBIGUOUS_S_BASES = { + "it", + "that", + "what", + "where", + "who", + "when", + "how", + "there", + "here", + "he", + "she", + "we", + "they", + "you", +} + + +def _is_ambiguous_d(token: str) -> bool: + low = token.lower() + return low.endswith("'d") and low[:-2] in AMBIGUOUS_D_BASES + + +def _is_ambiguous_s(token: str) -> bool: + low = token.lower() + return low.endswith("'s") and low[:-2] in AMBIGUOUS_S_BASES + +# Irregular possessives that are not formed by simple + 's logic +IRREGULAR_POSSESSIVES = { + "children's": "children's", + "men's": "men's", + "women's": "women's", + "people's": "people's", + "geese's": "geese's", + "mouse's": "mouse's", # singular irregular +} + +SIBILANT_END_RE = re.compile(r"(?:[sxz]|(?:ch|sh))$", re.IGNORECASE) + +DECADE_RE = re.compile(r"^'\d0s$", re.IGNORECASE) # '90s, '80s +LEADING_ELISION = { + "'tis": "it is", + "'twas": "it was", + "'cause": "because", + "'em": "them", + "'round": "around", + "'til": "until", +} + +CULTURAL_NAME_PATTERNS = [ + re.compile(r"^O'[A-Z][a-z]+$"), + re.compile(r"^D'[A-Z][a-z]+$"), + re.compile(r"^L'[A-Za-z].*$"), + re.compile(r"^Mc[A-Z].*$"), # not apostrophe, but often relevant (kept anyway) +] + +ACRONYM_POSSESSIVE_RE = re.compile(r"^[A-Z]{2,}'s$") + +INTERNAL_APOSTROPHE_RE = re.compile(r"[A-Za-z]'.+[A-Za-z]") # apostrophe not at edge + +# Capture contiguous runs of Unicode letters/digits/apostrophes/hyphens, otherwise fall back to +# single-character tokens (punctuation, symbols, etc.). +WORD_TOKEN_RE = re.compile( + r"[0-9A-Za-z'’\u00C0-\u1FFF\u2C00-\uD7FF\-]+|[^0-9A-Za-z\s]", + re.UNICODE, +) + +APOSTROPHE_CHARS = "’`´ꞌʼ" + +TERMINAL_PUNCTUATION = {".", "?", "!", "…", ";", ":"} +CLOSING_PUNCTUATION = '"\'”’)]}»›' +ELLIPSIS_SUFFIXES = ("...", "…") +_LINE_SPLIT_RE = re.compile(r"(\n+)") + +TITLE_ABBREVIATIONS = { + "mr": "mister", + "mrs": "missus", + "ms": "miz", + "dr": "doctor", + "prof": "professor", + "rev": "reverend", + "gen": "general", + "sgt": "sergeant", +} + +SUFFIX_ABBREVIATIONS = { + "jr": "junior", + "sr": "senior", +} + +_TITLE_PATTERN = re.compile( + r"\b(?P" + "|".join(sorted(TITLE_ABBREVIATIONS.keys(), key=len, reverse=True)) + r")\.", + re.IGNORECASE, +) +_SUFFIX_PATTERN = re.compile( + r"\b(?P" + "|".join(sorted(SUFFIX_ABBREVIATIONS.keys(), key=len, reverse=True)) + r")\.", + re.IGNORECASE, +) + +# ---------- Utility Functions ---------- + +def normalize_unicode_apostrophes(text: str) -> str: + text = unicodedata.normalize("NFKC", text) + for ch in APOSTROPHE_CHARS: + text = text.replace(ch, "'") + return text + +def tokenize(text: str) -> List[str]: + # Simple tokenization preserving punctuation tokens + return WORD_TOKEN_RE.findall(text) + + +def tokenize_with_spans(text: str) -> List[Tuple[str, int, int]]: + return [(match.group(0), match.start(), match.end()) for match in WORD_TOKEN_RE.finditer(text)] + + +def _cleanup_spacing(text: str) -> str: + if not text: + return text + + for marker in ("\ufeff", "\u200b", "\u200c", "\u200d", "\u2060"): + text = text.replace(marker, "") + + # Collapse spaces before closing punctuation. + text = re.sub(r"\s+([,.;:!?%])", r"\1", text) + text = re.sub(r"\s+([’\"”»›)\]\}])", r"\1", text) + + # Remove spaces directly after opening punctuation/quotes. + text = re.sub(r"([«‹“‘\"'(\[\{])\s+", r"\1", text) + + # Ensure spaces exist after sentence punctuation when followed by a word/quote. + text = re.sub(r"([,.;:!?%])(?![\s”'\"’»›)])", r"\1 ", text) + text = re.sub(r"([”\"’])(?![\s.,;:!?\"”’»›)])", r"\1 ", text) + + # Tighten hyphen/em dash spacing between word characters. + text = re.sub(r"(?<=\w)\s*([-–—])\s*(?=\w)", r"\1", text) + + # Normalize multiple spaces. + text = re.sub(r"\s{2,}", " ", text) + return text.strip() + + +_ROMAN_VALUE_MAP = { + "I": 1, + "V": 5, + "X": 10, + "L": 50, + "C": 100, + "D": 500, + "M": 1000, +} + +_ROMAN_COMPOSE_ORDER = [ + (1000, "M"), + (900, "CM"), + (500, "D"), + (400, "CD"), + (100, "C"), + (90, "XC"), + (50, "L"), + (40, "XL"), + (10, "X"), + (9, "IX"), + (5, "V"), + (4, "IV"), + (1, "I"), +] + +_ROMAN_PREFIX_RE = re.compile(r"^(?P[IVXLCDM]+)(?P[\s\.:,;\-–—]*)", re.IGNORECASE) + +_ROMAN_TOKEN_RE = re.compile(r"^[IVXLCDM]+$") +_ROMAN_CARDINAL_CONTEXTS = { + "act", + "appendix", + "article", + "battle", + "book", + "campaign", + "chapter", + "episode", + "film", + "final", + "fantasy", + "game", + "installment", + "lesson", + "level", + "mission", + "movement", + "opus", + "operation", + "page", + "part", + "phase", + "psalm", + "round", + "scene", + "season", + "section", + "series", + "song", + "super", + "bowl", + "stage", + "step", + "track", + "volume", + "war", + "world", + "world war", +} +_ROMAN_NAME_TITLES = { + "baron", + "baroness", + "captain", + "cardinal", + "count", + "countess", + "duchess", + "duke", + "emperor", + "empress", + "general", + "governor", + "king", + "lord", + "lady", + "major", + "pope", + "president", + "prince", + "princess", + "queen", + "saint", + "sir", +} +_ROMAN_NAME_CONNECTORS = { + "de", + "del", + "della", + "der", + "di", + "dos", + "la", + "le", + "of", + "the", + "van", + "von", +} +_ROMAN_BREAK_TOKENS = { + ",", + ".", + "!", + "?", + ";", + ":", + "(", + ")", + "[", + "]", + "{", + "}", + "—", + "–", + "-", + "'", + '"', +} + +_ROMAN_CONTEXT_PASSTHROUGH = {"-", "–", "—", ":"} +_ROMAN_CONTEXT_COMPOUND_RE = re.compile( + r"^(?P[A-Za-z]+)(?P[-–—:])(?P[IVXLCDM]+)$", + re.IGNORECASE, +) + + +def _roman_to_int(token: str) -> Optional[int]: + if not token: + return None + total = 0 + prev = 0 + token_upper = token.upper() + for char in reversed(token_upper): + value = _ROMAN_VALUE_MAP.get(char) + if value is None: + return None + if value < prev: + total -= value + else: + total += value + prev = value + if total <= 0: + return None + if _int_to_roman(total) != token_upper: + return None + return total + + +def _int_to_roman(value: int) -> str: + parts: List[str] = [] + remaining = value + for amount, symbol in _ROMAN_COMPOSE_ORDER: + while remaining >= amount: + parts.append(symbol) + remaining -= amount + return "".join(parts) + + +def _is_titlecase_token(token: str) -> bool: + cleaned = token.replace("'", "").replace("-", "") + if not cleaned: + return False + if not cleaned[0].isalpha() or not cleaned[0].isupper(): + return False + tail = cleaned[1:] + return not tail or tail.islower() + + +def _token_is_cardinal_context(token: str) -> bool: + return token.lower() in _ROMAN_CARDINAL_CONTEXTS + + +def _has_cardinal_leading_context(tokens: Sequence[Tuple[str, int, int]], index: int) -> bool: + j = index - 1 + while j >= 0: + token, *_ = tokens[j] + stripped = token.strip() + if not stripped: + j -= 1 + continue + lowered = stripped.lower() + if lowered in _ROMAN_CONTEXT_PASSTHROUGH: + j -= 1 + continue + if lowered in _ROMAN_BREAK_TOKENS: + return False + cleaned = lowered.strip("()[]{}\"'.,;!?") + if cleaned in _ROMAN_CARDINAL_CONTEXTS: + return True + if cleaned: + return False + j -= 1 + return False + + +def _should_render_ordinal( + tokens: Sequence[Tuple[str, int, int]], + index: int, + value: int, +) -> bool: + # Treat trailing roman numerals in name-like sequences as ordinals while + # leaving enumerated headings or series labels as cardinals. + if value <= 0: + return False + if index <= 0: + return False + + uppercase_count = 0 + title_count = 0 + j = index - 1 + while j >= 0: + token, *_ = tokens[j] + lowered = token.lower() + + if lowered in _ROMAN_CARDINAL_CONTEXTS: + return False + if lowered in _ROMAN_BREAK_TOKENS or token.isdigit(): + break + if lowered in _ROMAN_NAME_CONNECTORS: + j -= 1 + continue + if _is_titlecase_token(token): + uppercase_count += 1 + if lowered in _ROMAN_NAME_TITLES: + title_count += 1 + j -= 1 + continue + break + + if not uppercase_count: + return False + + if title_count: + return value <= 50 + + if uppercase_count >= 2: + return value <= 20 + + return False + + +def _normalize_roman_numerals(text: str, language: str) -> str: + if not text: + return text + + tokens = tokenize_with_spans(text) + if not tokens: + return text + + parts: List[str] = [] + cursor = 0 + + for index, (token, start, end) in enumerate(tokens): + parts.append(text[cursor:start]) + replacement = token + + compound_match = _ROMAN_CONTEXT_COMPOUND_RE.match(token) + if compound_match: + context_word = compound_match.group("context") + separator = compound_match.group("sep") + roman_part = compound_match.group("roman") + numeric_value = _roman_to_int(roman_part.upper()) + if ( + numeric_value is not None + and numeric_value <= 200 + and context_word.lower() in _ROMAN_CARDINAL_CONTEXTS + ): + words = _int_to_words(numeric_value, language) + if words: + if separator == ":": + replacement = f"{context_word}: {words}" + else: + replacement = f"{context_word} {words}" + else: + candidate = token.upper() + is_roman = _ROMAN_TOKEN_RE.match(candidate) + if is_roman: + numeric_value = _roman_to_int(candidate) + if numeric_value is not None: + convert = False + if len(token) >= 2: + if token.isupper(): + convert = True + elif numeric_value <= 200 and _has_cardinal_leading_context(tokens, index): + convert = True + elif len(token) == 1: + # Only convert single letters if context is strong + if _has_cardinal_leading_context(tokens, index): + convert = True + + if convert: + if _should_render_ordinal(tokens, index, numeric_value): + ordinal = _int_to_ordinal_words(numeric_value, language) + if ordinal: + replacement = f"the {ordinal}" + else: + words = _int_to_words(numeric_value, language) + if words: + replacement = words + + parts.append(replacement) + cursor = end + + parts.append(text[cursor:]) + return "".join(parts) + + +_ACRONYM_ALLOWLIST = { + "AI", + "API", + "CPU", + "DIY", + "GPU", + "HTML", + "HTTP", + "HTTPS", + "ID", + "JSON", + "MP3", + "MP4", + "M4B", + "NASA", + "OCR", + "PDF", + "SQL", + "TV", + "TTS", + "UK", + "UN", + "UFO", + "OK", + "URL", + "USA", + "US", + "VR", +} +_ROMAN_NUMERAL_LETTERS = frozenset("IVXLCDM") +_CAPS_WORD_PATTERN = re.compile(r"[A-Z][A-Z0-9'\u2019-]*") +_WORD_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9'\u2019-]*") +_QUOTE_PAIRS = { + '"': '"', + "“": "”", + "„": "“", + "«": "»", + "‹": "›", +} + + +def _should_preserve_caps_word(word: str) -> bool: + letters = "".join(ch for ch in word if ch.isalpha()) + if not letters: + return False + base = letters + if word.endswith(("'S", "’S")) and len(letters) > 1: + base = letters[:-1] + upper_base = base.upper() + if upper_base in _ACRONYM_ALLOWLIST: + return True + if all(ch in _ROMAN_NUMERAL_LETTERS for ch in letters.upper()) and len(letters) <= 7: + return True + return False + + +def _should_normalize_caps_segment(segment: str) -> bool: + letters = [ch for ch in segment if ch.isalpha()] + if not letters: + return False + if any(ch.islower() for ch in letters): + return False + if len(letters) <= 1: + return False + if not any(ch.isspace() for ch in segment) and len(letters) <= 4: + return False + return True + + +def _normalize_caps_segment(segment: str) -> str: + if not segment: + return segment + + preserve: Dict[str, str] = {} + for match in _CAPS_WORD_PATTERN.finditer(segment): + word = match.group(0) + if _should_preserve_caps_word(word): + preserve[word.lower()] = word + + lowered = segment.lower() + result_chars: List[str] = [] + capitalize_next = True + for char in lowered: + if capitalize_next and char.isalpha(): + result_chars.append(char.upper()) + capitalize_next = False + else: + result_chars.append(char) + if char.isalpha(): + capitalize_next = False + if char in ".!?": + capitalize_next = True + elif char in "\n": + capitalize_next = True + + def _restore(match: re.Match[str]) -> str: + token = match.group(0) + lookup = preserve.get(token.lower()) + if lookup: + return lookup + lower = token.lower() + if lower == "i": + return "I" + if lower.startswith("i'") or lower.startswith("i\u2019"): + return "I" + token[1:] + return token + + return _WORD_PATTERN.sub(_restore, "".join(result_chars)) + + +def _normalize_all_caps_quotes(text: str) -> str: + if not text: + return text + + builder: List[str] = [] + index = 0 + length = len(text) + + while index < length: + char = text[index] + closing = _QUOTE_PAIRS.get(char) + if not closing: + builder.append(char) + index += 1 + continue + + cursor = index + 1 + while cursor < length and text[cursor] != closing: + cursor += 1 + + if cursor >= length: + builder.append(text[index:]) + break + + body = text[index + 1 : cursor] + if _should_normalize_caps_segment(body): + normalized = _normalize_caps_segment(body) + builder.append(char + normalized + closing) + else: + builder.append(text[index : cursor + 1]) + + index = cursor + 1 + + if index < length: + builder.append(text[index:]) + + return "".join(builder) + + +def normalize_roman_numeral_titles( + titles: Sequence[str], + *, + threshold: float = 0.5, +) -> List[str]: + if not titles: + return [] + + normalized: List[str] = [] + matches: List[Tuple[int, str, int, str, str]] = [] + non_empty = 0 + + for index, raw in enumerate(titles): + title = "" if raw is None else str(raw) + stripped = title.lstrip() + leading_ws = title[: len(title) - len(stripped)] + if not stripped: + normalized.append(title) + continue + + non_empty += 1 + match = _ROMAN_PREFIX_RE.match(stripped) + if not match: + normalized.append(title) + continue + + roman_token = match.group("roman") + separator = match.group("sep") or "" + rest = stripped[match.end():] + + if not separator and rest and rest[:1].isalnum(): + normalized.append(title) + continue + + numeric_value = _roman_to_int(roman_token) + if numeric_value is None: + normalized.append(title) + continue + + matches.append((index, leading_ws, numeric_value, separator, rest)) + normalized.append(title) + + if not matches or non_empty == 0: + return list(normalized) + + if len(matches) <= non_empty * threshold: + return list(normalized) + + output = list(normalized) + for idx, leading_ws, value, separator, rest in matches: + new_title = f"{leading_ws}{value}" + if separator: + new_title += separator + elif rest and not rest[0].isspace() and rest[0] not in ".-–—:;,": + new_title += " " + new_title += rest + output[idx] = new_title + + return output + + +def _match_casing(template: str, replacement: str) -> str: + if template.isupper(): + return replacement.upper() + if template[:1].isupper() and template[1:].islower(): + return replacement.capitalize() + if template[:1].isupper(): + # Mixed case (e.g., Mc), fall back to title case + return replacement.capitalize() + return replacement + + +def expand_titles_and_suffixes(text: str) -> str: + def _replace(match: re.Match[str], mapping: dict[str, str]) -> str: + abbr = match.group("abbr") + lookup = mapping.get(abbr.lower()) + if not lookup: + return match.group(0) + return _match_casing(abbr, lookup) + + text = _TITLE_PATTERN.sub(lambda m: _replace(m, TITLE_ABBREVIATIONS), text) + text = _SUFFIX_PATTERN.sub(lambda m: _replace(m, SUFFIX_ABBREVIATIONS), text) + return text + + +def ensure_terminal_punctuation(text: str) -> str: + def _amend(segment: str) -> str: + if not segment or not segment.strip(): + return segment + + stripped = segment.rstrip() + trailing_ws = segment[len(stripped) :] + + match = re.match(rf"^(.*?)([{re.escape(CLOSING_PUNCTUATION)}]*)$", stripped) + if not match: + return segment + + body, closers = match.groups() + if not body: + return segment + + normalized_body = body.rstrip() + trailing_body_ws = body[len(normalized_body) :] + + if normalized_body.endswith(ELLIPSIS_SUFFIXES): + return normalized_body + trailing_body_ws + closers + trailing_ws + + last_char = normalized_body[-1] + if last_char in TERMINAL_PUNCTUATION: + return normalized_body + trailing_body_ws + closers + trailing_ws + + return normalized_body + "." + trailing_body_ws + closers + trailing_ws + + parts = _LINE_SPLIT_RE.split(text) + amended: List[str] = [] + for part in parts: + if not part: + continue + if part.startswith("\n"): + amended.append(part) + else: + amended.append(_amend(part)) + if not parts: + return _amend(text) + return "".join(amended) + + +def is_cultural_name(token: str, cfg: ApostropheConfig) -> bool: + if not cfg.protect_cultural_names: + return False + for pat in CULTURAL_NAME_PATTERNS: + if pat.match(token): + return True + return False + + +def _case_preserving_words(original: str, words: Sequence[str]) -> str: + if not words: + return "" + if original.isupper(): + return " ".join(word.upper() for word in words) + + if original[:1].isupper(): + adjusted = [words[0].capitalize()] + if len(words) > 1: + adjusted.extend(words[1:]) + return " ".join(adjusted) + + return " ".join(words) + + +def _apply_contraction_policy( + token: str, + *, + category: str, + cfg: ApostropheConfig, + expand: Callable[[], str], + collapse: Optional[str] = None, +) -> str: + mode = cfg.contraction_mode + if mode == "collapse": + return collapse if collapse is not None else token.replace("'", "") + if mode != "expand": + return token + if not cfg.is_contraction_enabled(category): + return token + return expand() + + +def _assemble_contraction_expansion(base_text: str, surface_text: str, expansion_word: str) -> str: + if not expansion_word: + return base_text + + if surface_text.isupper() and expansion_word.isalpha(): + adjusted = expansion_word.upper() + elif len(surface_text) > 2 and surface_text[:-2].istitle() and expansion_word: + adjusted = expansion_word.lower() + else: + adjusted = expansion_word + + return f"{base_text} {adjusted}".strip() + + +def _classify_ambiguous_d(token: str, cfg: ApostropheConfig) -> Tuple[str, str]: + base = token[:-2] + collapse_value = base + "d" + + if cfg.contraction_mode == "collapse": + return "contraction_modal_would", collapse_value + if cfg.contraction_mode != "expand": + return "contraction_modal_would", token + + mode = cfg.ambiguous_past_modal_mode + if mode == "expand_prefer_had": + candidates = [ + ("contraction_aux_have", "had"), + ("contraction_modal_would", "would"), + ] + elif mode == "expand_prefer_would": + candidates = [ + ("contraction_modal_would", "would"), + ("contraction_aux_have", "had"), + ] + else: # contextual + candidates = [ + ("contraction_modal_would", "would"), + ("contraction_aux_have", "had"), + ] + + for category, word in candidates: + if not cfg.is_contraction_enabled(category): + continue + expanded = _assemble_contraction_expansion(base, token, word) + return category, expanded + + # If every category is disabled, leave the token as-is but report default category + return candidates[0][0], token + + +def _classify_ambiguous_s(token: str, cfg: ApostropheConfig) -> Tuple[str, str]: + base = token[:-2] + + if cfg.contraction_mode == "collapse": + return "contraction_aux_be", base + "s" + if cfg.contraction_mode != "expand": + return "contraction_aux_be", token + + candidates = [ + ("contraction_aux_be", "is"), + ("contraction_aux_have", "has"), + ] + + for category, word in candidates: + if not cfg.is_contraction_enabled(category): + continue + expanded = _assemble_contraction_expansion(base, token, word) + return category, expanded + + return candidates[0][0], token + +def classify_token(token: str, cfg: ApostropheConfig) -> Tuple[str, str]: + """ + Classify apostrophe usage and propose normalized form. + Returns (category, normalized_token_or_same). + Categories include: contraction_* variants, plural_possessive, irregular_possessive, + sibilant_possessive, singular_possessive, acronym_possessive, decade, leading_elision, + fantasy_internal, cultural_name, other. + """ + if "'" not in token: + return "other", token + + low = token.lower() + + # 1. Decades + if DECADE_RE.match(token): + if cfg.decades_mode == "expand": + decade_digit = token[1] + decade_map = { + "0": "two thousands", + "1": "tens", + "2": "twenties", + "3": "thirties", + "4": "forties", + "5": "fifties", + "6": "sixties", + "7": "seventies", + "8": "eighties", + "9": "nineties", + } + spoken = decade_map.get(decade_digit) + if spoken: + return "decade", spoken + return "decade", token + return "decade", token + + # 2. Leading elision + if low in LEADING_ELISION: + if cfg.leading_elision_mode == "expand": + return "leading_elision", LEADING_ELISION[low] + return "leading_elision", token + + # 3. Ambiguous 'd contractions + if _is_ambiguous_d(token): + return _classify_ambiguous_d(token, cfg) + + # 4. Ambiguous 's contractions + if _is_ambiguous_s(token): + return _classify_ambiguous_s(token, cfg) + + # 5. Lexicon-based contractions + lex_entry = CONTRACTION_LEXICON.get(low) + if lex_entry is not None: + category, words = lex_entry + + def _expand() -> str: + return _case_preserving_words(token, words) + + collapse_value = token.replace("'", "") + normalized = _apply_contraction_policy(token, category=category, cfg=cfg, expand=_expand, collapse=collapse_value) + return category, normalized + + # 6. Suffix contractions ('m handled separately) + if low.endswith("'m") and low[:-2] in SUFFIX_CONTRACTION_BASES.get("'m", ()): # pronoun I'm + + def _expand_m() -> str: + base = token[:-2] + return _assemble_contraction_expansion(base, token, "am") + + normalized = _apply_contraction_policy( + token, + category="contraction_aux_be", + cfg=cfg, + expand=_expand_m, + collapse=token.replace("'", ""), + ) + return "contraction_aux_be", normalized + + for suffix, append_word, category in SUFFIX_CONTRACTION_RULES: + if low.endswith(suffix) and len(token) > len(suffix): + base = token[: -len(suffix)] + + def _expand_suffix() -> str: + return _assemble_contraction_expansion(base, token, append_word) + + normalized = _apply_contraction_policy( + token, + category=category, + cfg=cfg, + expand=_expand_suffix, + collapse=token.replace("'", ""), + ) + return category, normalized + + # 7. Irregular possessives (keep or expand logic) + if low in IRREGULAR_POSSESSIVES: + if cfg.irregular_possessive_mode == "keep": + return "irregular_possessive", token + return "irregular_possessive", token + + # 8. Plural possessive pattern dogs' + if re.match(r"^[A-Za-z0-9]+s'$", token): + if cfg.plural_possessive_mode == "collapse": + return "plural_possessive", token[:-1] + return "plural_possessive", token + + # 9. Acronym possessive NASA's + if ACRONYM_POSSESSIVE_RE.match(token): + if cfg.acronym_possessive_mode == "collapse_add_s": + return "acronym_possessive", token.replace("'", "") + return "acronym_possessive", token + + # 10. Sibilant singular possessive boss's, church's + if low.endswith("'s"): + base = token[:-2] + if SIBILANT_END_RE.search(base): + if cfg.sibilant_possessive_mode == "keep": + return "sibilant_possessive", token + if cfg.sibilant_possessive_mode == "approx": + return "sibilant_possessive", base + "es" + if cfg.sibilant_possessive_mode == "mark": + normalized = base + normalized += cfg.sibilant_iz_marker if cfg.add_phoneme_hints else "es" + return "sibilant_possessive", normalized + + # 11. Generic singular possessive (\w+'s) + if re.match(r"^[A-Za-z0-9]+'s$", token): + if cfg.possessive_mode == "collapse": + return "singular_possessive", token.replace("'", "") + return "singular_possessive", token + + # 12. Cultural names or fantasy internal + if is_cultural_name(token, cfg): + return "cultural_name", token + + if INTERNAL_APOSTROPHE_RE.search(token): + if cfg.fantasy_mode == "keep": + return "fantasy_internal", token + if cfg.fantasy_mode == "mark": + out = token + (cfg.fantasy_marker if cfg.add_phoneme_hints else "") + return "fantasy_internal", out + if cfg.fantasy_mode == "collapse_internal": + inner = re.sub(r"(?<=\w)'+(?=\w)", cfg.joiner, token) + return "fantasy_internal", inner + + if cfg.fantasy_mode == "collapse_internal": + return "other", token.replace("'", cfg.joiner) + return "other", token + +def normalize_apostrophes(text: str, cfg: ApostropheConfig | None = None) -> Tuple[str, List[Tuple[str,str,str]]]: + """ + Normalize apostrophes per config. + Returns normalized text AND a list of (original_token, category, normalized_token) + so you can debug or post-process (e.g., apply phoneme replacement for ‹IZ›). + """ + if cfg is None: + cfg = ApostropheConfig() + + text = normalize_unicode_apostrophes(text) + text = _normalize_grouped_numbers(text, cfg) + token_entries = tokenize_with_spans(text) + + use_contextual_s = cfg.contraction_mode == "expand" + use_contextual_d = cfg.contraction_mode == "expand" and cfg.ambiguous_past_modal_mode == "contextual" + + need_contextual = False + if (use_contextual_s or use_contextual_d) and token_entries: + for token_value, _, _ in token_entries: + if use_contextual_s and _is_ambiguous_s(token_value): + need_contextual = True + break + if use_contextual_d and _is_ambiguous_d(token_value): + need_contextual = True + break + + contextual_resolutions = resolve_ambiguous_contractions(text) if need_contextual else {} + + results: List[Tuple[str, str, str]] = [] + normalized_tokens: List[str] = [] + + for tok, start, end in token_entries: + category, norm = classify_token(tok, cfg) + + resolution = contextual_resolutions.get((start, end)) if contextual_resolutions else None + if resolution is not None and cfg.contraction_mode == "expand": + if cfg.is_contraction_enabled(resolution.category): + category = resolution.category + norm = resolution.expansion + else: + norm = tok + + results.append((tok, category, norm)) + normalized_tokens.append(norm) + + filtered = [token for token in normalized_tokens if token] + normalized_text = _cleanup_spacing(" ".join(filtered)) + return normalized_text, results + +def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: + if not text: + return text + + language = (cfg.number_lang or "en").strip() or "en" + + def _year_mode() -> str: + mode = (cfg.year_pronunciation_mode or "").strip().lower() + if mode in {"", "none", "off", "disabled"}: + return "off" + if mode not in {"american"}: + return "off" + return mode + + year_mode = _year_mode() + + def _format_year_tail(value: int, *, allow_oh: bool = True) -> Optional[str]: + if value == 0: + return "" + if value < 10: + if allow_oh: + return f"oh {_DIGIT_WORDS[value]}" + return _DIGIT_WORDS[value] + words = _int_to_words(value, language) + if not words: + return None + return words + + def _format_year_like(token: str, value: int) -> Optional[str]: + if year_mode == "off" or num2words is None: + return None + if len(token) != 4 or not token.isdigit(): + return None + if value < 1000 or value > 9999: + return None + style = year_mode + + def _words(value_to_convert: int) -> Optional[str]: + words = _int_to_words(value_to_convert, language) + return words + + if style == "american": + # Special handling for 2000-2009 to use "two thousand X" + if 2000 <= value <= 2009: + words = _words(value) + if words: + return words.replace(" and ", " ") + return None + + # US-style: 1100-1999 are often spoken as "X hundred Y". + if 1100 <= value <= 1999: + hundreds = value // 100 + remainder = value % 100 + prefix = _words(hundreds) + if not prefix: + return None + if remainder == 0: + return f"{prefix} hundred" + tail = _format_year_tail(remainder, allow_oh=True) + if tail is None: + return None + return f"{prefix} hundred {tail}".strip() + + if value % 1000 == 0: + thousands = value // 1000 + thousands_words = _words(thousands) + if thousands_words: + return f"{thousands_words} thousand" + return None + + first_two = value // 100 + last_two = value % 100 + + prefix = _words(first_two) + if not prefix: + return None + + if last_two == 0: + return f"{prefix} hundred" + + if last_two < 10: + # Use "oh X" format (e.g. "nineteen oh five") + return f"{prefix} oh {_DIGIT_WORDS[last_two]}" + + tail = _format_year_tail(last_two) + if tail: + return f"{prefix} {tail}" + return prefix + + return None + + def _replace_grouped(match: re.Match[str]) -> str: + token = match.group(1) + value = _coerce_int_token(token) + if value is None: + cleaned = token.replace(",", "") + return cleaned + if num2words is None: + return str(value) + words = _int_to_words(value, language) + return words or str(value) + + def _replace_plain(match: re.Match[str]) -> str: + token = match.group("number") + if "," in token: + return token.replace(",", "") + + start, end = match.span() + source = match.string + before = source[start - 1] if start > 0 else "" + after = source[end] if end < len(source) else "" + + if before == "/" or after == "/": + return token + + if after == ".": + next_char = source[end + 1] if end + 1 < len(source) else "" + if next_char.isdigit(): + return token + + if before == ".": + prev_char = source[start - 2] if start >= 2 else "" + if prev_char.isdigit() or start == 1: + return token + + value = _coerce_int_token(token) + if value is None: + return token + + # Check context for "address" vs year markers to avoid converting house numbers to years + window_start = max(0, start - 60) + window_end = min(len(source), end + 60) + context = source[window_start:window_end].lower() + + # Check for "address" or "addresses" as a whole word + has_address = bool(re.search(r"\baddress(es)?\b", context)) + + # Check for year markers as whole words + has_year_marker = bool(re.search(r"\b(bc|ad|bce|ce|b\.c\.|a\.d\.|b\.c\.e\.|c\.e\.)\b", context)) + + should_try_year = True + if has_address and not has_year_marker: + should_try_year = False + + if should_try_year: + year_like = _format_year_like(token, value) + if year_like: + return year_like + + if num2words is None: + return str(value) + words = _int_to_words(value, language) + return words or str(value) + + def _replace_decimal(match: re.Match[str]) -> str: + token = match.group("number") + fraction_part = match.group("fraction") + start, end = match.span() + source = match.string + + if end < len(source) and source[end] == ".": + next_char = source[end + 1] if end + 1 < len(source) else "" + if next_char.isdigit(): + return token + + is_negative = token.startswith("-") + core = token[1:] if is_negative else token + if "." not in core: + return token + + integer_part, _, _ = core.partition(".") + if not integer_part or not fraction_part: + return token + + integer_value = _coerce_int_token(integer_part.replace(",", "")) + if integer_value is None: + return token + + trimmed_fraction = fraction_part.rstrip("0") + integer_words = _int_to_words(integer_value, language) + + if not trimmed_fraction: + if integer_words is None: + return token + spoken = integer_words + return f"minus {spoken}" if is_negative else spoken + + if integer_words is None: + fallback_core = core.replace(".", " point ") + return f"minus {fallback_core}" if is_negative else fallback_core + + digit_words: List[str] = [] + for digit in trimmed_fraction: + if not digit.isdigit(): + return token + digit_words.append(_DIGIT_WORDS[int(digit)]) + + spoken = f"{integer_words} point {' '.join(digit_words)}" + return f"minus {spoken}" if is_negative else spoken + + def _replace_currency(match: re.Match[str]) -> str: + if num2words is None: + return match.group(0) + + symbol = match.group("symbol") + amount_str = match.group("amount").replace(",", "") + magnitude = match.group("magnitude") + + try: + amount = float(amount_str) + except ValueError: + return match.group(0) + + if magnitude: + # Magnitude case: $2.5 million -> two point five million dollars + if "." in amount_str: + integer_part, fraction_part = amount_str.split(".", 1) + integer_val = int(integer_part) + integer_words = _int_to_words(integer_val, language) + + # Spell out fraction digits + digit_words = [] + for digit in fraction_part: + if digit.isdigit(): + digit_words.append(_DIGIT_WORDS[int(digit)]) + + amount_spoken = f"{integer_words} point {' '.join(digit_words)}" + else: + amount_spoken = _int_to_words(int(amount), language) + + currency_names = { + "$": "dollars", + "£": "pounds", + "€": "euros", + "¥": "yen", + } + currency_name = currency_names.get(symbol, "dollars") + + return f"{amount_spoken} {magnitude} {currency_name}" + + # Handle $0.99 -> ninety-nine cents (avoid "zero dollars and..."). + if amount_str.startswith("0") and amount < 1.0: + dollars_part, dot, fraction = amount_str.partition(".") + if dot and fraction: + cents_str = (fraction + "00")[:2] + try: + cents_value = int(cents_str) + except ValueError: + cents_value = 0 + if cents_value > 0: + cents_words = _int_to_words(cents_value, language) or str(cents_value) + subunit = { + "$": "cent", + "€": "cent", + "£": "penny", + "¥": "yen", + }.get(symbol, "cent") + if symbol == "£": + subunit = "pence" if cents_value != 1 else "penny" + else: + subunit = (subunit + "s") if cents_value != 1 and subunit not in {"pence", "yen"} else subunit + return f"{cents_words} {subunit}".strip() + + currency_map = { + "$": "USD", + "£": "GBP", + "€": "EUR", + "¥": "JPY", + } + currency_code = currency_map.get(symbol, "USD") + + try: + # Always use float to avoid num2words treating int as cents (if that's what it does) + # or to ensure consistent behavior. + words = num2words(amount, to="currency", currency=currency_code, lang=language) + + # Remove "zero cents" if present + # Patterns: ", zero cents", " and zero cents" + words = words.replace(", zero cents", "").replace(" and zero cents", "") + return words + except Exception: + return match.group(0) + + def _replace_url(match: re.Match[str]) -> str: + domain = match.group("domain") + + # Avoid matching dotted abbreviations/acronyms without an explicit URL prefix. + has_prefix = match.group(1) or match.group(2) + if not has_prefix: + parts = [p for p in domain.split(".") if p] + if parts and all(p.isalpha() and len(p) <= 2 for p in parts): + return match.group(0) + + # Avoid matching numbers like 1.05 or 12.34.56 + # If the domain consists only of digits and dots, ignore it (unless it has http/www prefix) + has_prefix = match.group(1) or match.group(2) + if not has_prefix and all(c.isdigit() or c == '.' or c == '-' for c in domain): + # Check if it really looks like a number (e.g. 1.05) + # If it has multiple dots like 1.2.3.4 it might be an IP, which we might want to speak as dot? + # But 1.05 is definitely a number. + # Let's be safe: if it looks like a float, skip. + try: + float(domain) + return match.group(0) + except ValueError: + pass + + if domain.startswith("www."): + domain = domain[4:] + spoken = domain.replace(".", " dot ") + return spoken + + def _remove_footnote(match: re.Match[str]) -> str: + return match.group(1) + + normalized = text + + # Apply URL replacement first (independent of number conversion). + normalized = _URL_RE.sub(_replace_url, normalized) + + if getattr(cfg, "remove_footnotes", False): + normalized = _FOOTNOTE_RE.sub(_remove_footnote, normalized) + normalized = _BRACKET_FOOTNOTE_RE.sub("", normalized) + + if cfg.convert_currency: + normalized = _CURRENCY_RE.sub(_replace_currency, normalized) + + if cfg.convert_numbers: + normalized = _NUMBER_RANGE_RE.sub(lambda m: _replace_number_range(m, language), normalized) + normalized = _NUMBER_SPACE_RANGE_RE.sub(lambda m: _replace_space_separated_range(m, language), normalized) + normalized = _FRACTION_RE.sub(lambda m: _replace_fraction(m, language), normalized) + normalized = _DECIMAL_NUMBER_RE.sub(_replace_decimal, normalized) + normalized = _NUMBER_WITH_GROUP_RE.sub(_replace_grouped, normalized) + normalized = _PLAIN_NUMBER_RE.sub(_replace_plain, normalized) + normalized = _normalize_roman_numerals(normalized, language) + + return normalized +def _normalize_dotted_acronyms(text: str) -> str: + def _replace(match: re.Match[str]) -> str: + value = match.group(0) + compact = value.replace(".", "") + return compact + + return re.sub(r"\b(?:[A-Z]\.){1,}[A-Z]\.?(?=\W|$)", _replace, text) + +# ---------- Optional phoneme hint post-processing ---------- + +def apply_phoneme_hints(text: str, iz_marker="‹IZ›") -> str: + """ + Replace markers with an orthographic sequence that + your phonemizer will reliably convert to /ɪz/. + """ + return text.replace(iz_marker, " iz") + + +DEFAULT_APOSTROPHE_CONFIG = ApostropheConfig() + + +_MUSTACHE_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}") +_LLM_SYSTEM_PROMPT = ( + "You assist with audiobook preparation. Review the sentence, identify any apostrophes or " + "contractions that should be expanded for clarity, and respond by calling the " + "apply_regex_replacements tool. Each replacement must target a single token, include a precise " + "regex pattern, and provide the exact replacement text. If no changes are required, call the tool " + "with an empty replacements list. Do not rewrite the sentence directly." +) + +_LLM_REGEX_TOOL_NAME = "apply_regex_replacements" +_LLM_REGEX_TOOL = { + "type": "function", + "function": { + "name": _LLM_REGEX_TOOL_NAME, + "description": ( + "Return regex substitutions to normalize apostrophes or contractions in the provided sentence." + ), + "parameters": { + "type": "object", + "properties": { + "replacements": { + "description": "Ordered substitutions to apply to the sentence.", + "type": "array", + "items": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regular expression that matches the token to replace.", + }, + "replacement": { + "type": "string", + "description": "Replacement text for the match.", + }, + "flags": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional re flags such as IGNORECASE.", + }, + "count": { + "type": "integer", + "description": "Optional maximum number of replacements (default all).", + }, + "reason": { + "type": "string", + "description": "Short explanation of why the replacement is needed.", + }, + }, + "required": ["pattern", "replacement"], + }, + } + }, + "required": ["replacements"], + }, + }, +} +_LLM_REGEX_TOOL_CHOICE = {"type": "function", "function": {"name": _LLM_REGEX_TOOL_NAME}} +_LLM_ALLOWED_REGEX_FLAGS = { + "IGNORECASE": re.IGNORECASE, + "MULTILINE": re.MULTILINE, + "DOTALL": re.DOTALL, +} + + +def _render_mustache(template: str, context: Mapping[str, str]) -> str: + if not template: + return "" + + def _replace(match: re.Match[str]) -> str: + key = match.group(1) + return context.get(key, "") + + return _MUSTACHE_PATTERN.sub(_replace, template) + + +_SENTENCE_CAPTURE_RE = re.compile(r"[^.!?]+[.!?]+|[^.!?]+$", re.MULTILINE) + + +def _split_sentences_for_llm(text: str) -> List[str]: + sentences = [segment.strip() for segment in _SENTENCE_CAPTURE_RE.findall(text or "")] + return [segment for segment in sentences if segment] + + +def _normalize_with_llm( + text: str, + *, + settings: Mapping[str, Any], + config: ApostropheConfig, +) -> str: + from abogen.normalization_settings import build_llm_configuration, DEFAULT_LLM_PROMPT + from abogen.llm_client import generate_completion, LLMClientError + + llm_config = build_llm_configuration(settings) + if not llm_config.is_configured(): + raise LLMClientError("LLM configuration is incomplete") + + prompt_template = str(settings.get("llm_prompt") or DEFAULT_LLM_PROMPT) + lines = text.splitlines(keepends=True) + if not lines: + return text + + normalized_lines: List[str] = [] + for raw_line in lines: + newline = "" + if raw_line.endswith(("\r", "\n")): + stripped_newline = raw_line.rstrip("\r\n") + newline = raw_line[len(stripped_newline):] + line_body = stripped_newline + else: + line_body = raw_line + + if not line_body.strip(): + normalized_lines.append(line_body + newline) + continue + + leading_ws = line_body[: len(line_body) - len(line_body.lstrip())] + trailing_ws = line_body[len(line_body.rstrip()):] + core = line_body[len(leading_ws) : len(line_body) - len(trailing_ws)] + + sentences = _split_sentences_for_llm(core) + if not sentences: + normalized_lines.append(line_body + newline) + continue + + paragraph_context = core + rewritten_sentences: List[str] = [] + for sentence in sentences: + prompt_context = { + "text": sentence, + "sentence": sentence, + "paragraph": paragraph_context, + } + prompt = _render_mustache(prompt_template, prompt_context) + completion = generate_completion( + llm_config, + system_message=_LLM_SYSTEM_PROMPT, + user_message=prompt, + tools=[_LLM_REGEX_TOOL], + tool_choice=_LLM_REGEX_TOOL_CHOICE, + ) + rewritten_sentences.append( + _apply_llm_regex_replacements(sentence, completion) + ) + + normalized_core = " ".join(filter(None, rewritten_sentences)) or core + + rebuilt = f"{leading_ws}{normalized_core}{trailing_ws}{newline}" + normalized_lines.append(rebuilt) + + result = "".join(normalized_lines) + return result if result else text + + +def _apply_llm_regex_replacements(sentence: str, completion: "LLMCompletion") -> str: + replacements = _extract_llm_replacements(completion) + if not replacements: + return sentence + + updated = sentence + for spec in replacements: + updated = _apply_single_regex_replacement(updated, spec) + return updated + + +def _extract_llm_replacements(completion: "LLMCompletion") -> List[Dict[str, Any]]: + if completion is None: + return [] + + for call in getattr(completion, "tool_calls", ()): # type: ignore[attr-defined] + if getattr(call, "name", None) != _LLM_REGEX_TOOL_NAME: + continue + payload = _safe_load_json(getattr(call, "arguments", None)) + replacements = _coerce_replacement_list(payload) + if replacements: + return replacements + + if getattr(completion, "content", None): + payload = _safe_load_json(completion.content) + replacements = _coerce_replacement_list(payload) + if replacements: + return replacements + + return [] + + +def _safe_load_json(raw: Optional[str]) -> Any: + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + + +def _coerce_replacement_list(raw: Any) -> List[Dict[str, Any]]: + if isinstance(raw, Mapping): + candidates = raw.get("replacements") + else: + candidates = raw + + if not isinstance(candidates, list): + return [] + + replacements: List[Dict[str, Any]] = [] + for item in candidates: + if not isinstance(item, Mapping): + continue + pattern = str(item.get("pattern") or "").strip() + if not pattern: + continue + replacement = str(item.get("replacement") or "") + entry: Dict[str, Any] = {"pattern": pattern, "replacement": replacement} + + flags = _normalize_flag_field(item.get("flags")) + if flags: + entry["flags"] = flags + + count = item.get("count") + if isinstance(count, int) and count >= 0: + entry["count"] = count + + replacements.append(entry) + + return replacements + + +def _normalize_flag_field(raw: Any) -> List[str]: + if not raw: + return [] + + if isinstance(raw, str): + raw_iterable: Iterable[Any] = [raw] + elif isinstance(raw, Iterable) and not isinstance(raw, (bytes, str, Mapping)): + raw_iterable = raw + else: + return [] + + normalized: List[str] = [] + seen: set[str] = set() + for value in raw_iterable: + candidate = str(value or "").strip().upper() + if not candidate or candidate not in _LLM_ALLOWED_REGEX_FLAGS or candidate in seen: + continue + seen.add(candidate) + normalized.append(candidate) + return normalized + + +def _apply_single_regex_replacement(text: str, spec: Mapping[str, Any]) -> str: + pattern = str(spec.get("pattern") or "") + replacement = str(spec.get("replacement") or "") + if not pattern: + return text + + flags_value = 0 + flag_names = spec.get("flags") + if isinstance(flag_names, str): + flag_iterable: Iterable[Any] = [flag_names] + elif isinstance(flag_names, Iterable) and not isinstance(flag_names, (bytes, str, Mapping)): + flag_iterable = flag_names + else: + flag_iterable = [] + + for flag_name in flag_iterable: + lookup = str(flag_name or "").strip().upper() + flags_value |= _LLM_ALLOWED_REGEX_FLAGS.get(lookup, 0) + + count = spec.get("count") + count_value = count if isinstance(count, int) and count >= 0 else 0 + + try: + return re.sub(pattern, replacement, text, count=count_value, flags=flags_value) + except re.error: + return text + + +def normalize_for_pipeline( + text: str, + *, + config: Optional[ApostropheConfig] = None, + settings: Optional[Mapping[str, Any]] = None, +) -> str: + """Normalize text for the synthesis pipeline with runtime settings.""" + + from abogen.normalization_settings import build_apostrophe_config, get_runtime_settings + from abogen.llm_client import LLMClientError + + runtime_settings = settings or get_runtime_settings() + base_config = config or DEFAULT_APOSTROPHE_CONFIG + cfg = build_apostrophe_config(settings=runtime_settings, base=base_config) + + mode = str(runtime_settings.get("normalization_apostrophe_mode", "spacy")).lower() + normalized = text + + # Pre-normalization that must happen before number/url parsing. + if runtime_settings.get("normalization_numbers", True): + normalized = _normalize_dates(normalized, cfg.number_lang) + normalized = _normalize_times(normalized) + normalized = _normalize_dotted_acronyms(normalized) + if runtime_settings.get("normalization_titles", True): + normalized = _normalize_address_abbreviations(normalized) + if runtime_settings.get("normalization_internet_slang", False): + normalized = _normalize_internet_slang(normalized) + + if mode == "off": + normalized = normalize_unicode_apostrophes(normalized) + if cfg.convert_numbers or cfg.convert_currency or getattr(cfg, "remove_footnotes", False): + normalized = _normalize_grouped_numbers(normalized, cfg) + normalized = _cleanup_spacing(normalized) + elif mode == "llm": + try: + normalized = _normalize_with_llm(normalized, settings=runtime_settings, config=cfg) + except LLMClientError: + raise + if cfg.convert_numbers or cfg.convert_currency or getattr(cfg, "remove_footnotes", False): + normalized = _normalize_grouped_numbers(normalized, cfg) + normalized = _cleanup_spacing(normalized) + else: + normalized, _ = normalize_apostrophes(normalized, cfg) + + if runtime_settings.get("normalization_titles", True): + normalized = expand_titles_and_suffixes(normalized) + if runtime_settings.get("normalization_terminal", True): + normalized = ensure_terminal_punctuation(normalized) + if runtime_settings.get("normalization_caps_quotes", True): + normalized = _normalize_all_caps_quotes(normalized) + + if cfg.add_phoneme_hints: + normalized = apply_phoneme_hints(normalized, iz_marker=cfg.sibilant_iz_marker) + + return normalized + +# ---------- Example Usage ---------- + +if __name__ == "__main__": + sample = "Bob's boss's chair. The dogs' collars. It's cold. Ta'veren and Sha'hal. O'Brien's code in the '90s. Boss's orders." + config = ApostropheConfig() + norm_text, details = normalize_apostrophes(sample, config) + norm_text = apply_phoneme_hints(norm_text) + print("Original:", sample) + print("Normalized:", norm_text) + for orig, cat, norm in details: + print(f"{orig:15} -> {norm:15} [{cat}]") \ No newline at end of file diff --git a/abogen/llm_client.py b/abogen/llm_client.py new file mode 100644 index 0000000..b6c075b --- /dev/null +++ b/abogen/llm_client.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple +from urllib import error, parse, request + + +class LLMClientError(RuntimeError): + """Raised when an LLM request fails.""" + + +@dataclass(frozen=True) +class LLMConfiguration: + base_url: str + api_key: str + model: str + timeout: float = 30.0 + + def is_configured(self) -> bool: + return bool(self.base_url.strip() and self.model.strip()) + + +@dataclass(frozen=True) +class LLMToolCall: + name: str + arguments: str + + +@dataclass(frozen=True) +class LLMCompletion: + content: Optional[str] + tool_calls: Tuple[LLMToolCall, ...] + + +_DEFAULT_HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", +} + + +def _normalized_base_url(base_url: str) -> str: + trimmed = (base_url or "").strip() + if not trimmed: + raise LLMClientError("LLM base URL is required") + if not trimmed.endswith("/"): + trimmed += "/" + return trimmed + + +def _build_url(base_url: str, path: str) -> str: + normalized = _normalized_base_url(base_url) + trimmed_path = path.lstrip("/") + parsed = parse.urlparse(normalized) + if parsed.path.rstrip("/").lower().endswith("/v1") and trimmed_path.startswith("v1/"): + trimmed_path = trimmed_path[len("v1/"):] + return parse.urljoin(normalized, trimmed_path) + + +def _build_headers(api_key: str) -> Dict[str, str]: + headers = dict(_DEFAULT_HEADERS) + token = (api_key or "").strip() + if token and token.lower() != "ollama": + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _perform_request( + method: str, + url: str, + *, + headers: Optional[Mapping[str, str]] = None, + payload: Optional[Mapping[str, Any]] = None, + timeout: float = 30.0, +) -> Any: + data_bytes: Optional[bytes] = None + if payload is not None: + data_bytes = json.dumps(payload).encode("utf-8") + request_headers = dict(headers or {}) + req = request.Request(url, data=data_bytes, headers=request_headers, method=method.upper()) + try: + with request.urlopen(req, timeout=timeout) as response: + body = response.read() + except error.HTTPError as exc: # pragma: no cover - defensive network guard + message = exc.read().decode("utf-8", "ignore") if exc.fp else exc.reason + raise LLMClientError(f"LLM request failed ({exc.code}): {message}") from exc + except error.URLError as exc: # pragma: no cover - defensive network guard + raise LLMClientError(f"LLM request failed: {exc.reason}") from exc + except Exception as exc: # pragma: no cover - defensive network guard + raise LLMClientError("LLM request failed") from exc + + if not body: + return None + try: + return json.loads(body.decode("utf-8")) + except json.JSONDecodeError as exc: + raise LLMClientError("LLM response was not valid JSON") from exc + + +def list_models(configuration: LLMConfiguration) -> List[Dict[str, str]]: + if not configuration.is_configured() and not configuration.base_url.strip(): + raise LLMClientError("LLM configuration is incomplete") + url = _build_url(configuration.base_url, "v1/models") + headers = _build_headers(configuration.api_key) + payload = _perform_request("GET", url, headers=headers, timeout=configuration.timeout) + if not isinstance(payload, Mapping): + raise LLMClientError("Unexpected response when listing models") + data = payload.get("data") + if not isinstance(data, list): + return [] + models: List[Dict[str, str]] = [] + for entry in data: + if not isinstance(entry, Mapping): + continue + identifier = str(entry.get("id") or "").strip() + if not identifier: + continue + description = str(entry.get("name") or entry.get("description") or identifier) + models.append({"id": identifier, "label": description}) + return models + + +def generate_completion( + configuration: LLMConfiguration, + *, + system_message: str, + user_message: str, + temperature: float = 0.2, + max_tokens: Optional[int] = None, + tools: Optional[Sequence[Mapping[str, Any]]] = None, + tool_choice: Optional[Mapping[str, Any]] = None, + response_format: Optional[Mapping[str, Any]] = None, +) -> LLMCompletion: + if not configuration.is_configured(): + raise LLMClientError("LLM configuration is incomplete") + + url = _build_url(configuration.base_url, "v1/chat/completions") + headers = _build_headers(configuration.api_key) + payload: Dict[str, Any] = { + "model": configuration.model, + "messages": [ + {"role": "system", "content": system_message}, + {"role": "user", "content": user_message}, + ], + "temperature": temperature, + } + if max_tokens is not None: + payload["max_tokens"] = max_tokens + if tools: + payload["tools"] = list(tools) + if tool_choice: + payload["tool_choice"] = dict(tool_choice) + if response_format: + payload["response_format"] = dict(response_format) + + response = _perform_request("POST", url, headers=headers, payload=payload, timeout=configuration.timeout) + if not isinstance(response, Mapping): + raise LLMClientError("Unexpected response from LLM") + choices = response.get("choices") + if not isinstance(choices, list) or not choices: + raise LLMClientError("LLM response did not include choices") + first = choices[0] + if not isinstance(first, Mapping): + raise LLMClientError("LLM response choice was invalid") + message = first.get("message") + content: Optional[str] = None + tool_calls: List[LLMToolCall] = [] + if isinstance(message, Mapping): + content = message.get("content") + if isinstance(content, str): + stripped = content.strip() + if stripped: + content = stripped + else: + content = None + tool_call_entries = message.get("tool_calls") + if isinstance(tool_call_entries, list): + for entry in tool_call_entries: + if not isinstance(entry, Mapping): + continue + fn = entry.get("function") + if not isinstance(fn, Mapping): + continue + name = str(fn.get("name") or "").strip() + if not name: + continue + args = fn.get("arguments", "") + if isinstance(args, (dict, list)): + arguments = json.dumps(args) + else: + arguments = str(args) + tool_calls.append(LLMToolCall(name=name, arguments=arguments)) + if content: + return LLMCompletion(content=content, tool_calls=tuple(tool_calls)) + text = first.get("text") + if isinstance(text, str): + stripped = text.strip() + if stripped: + content = stripped + if content or tool_calls: + return LLMCompletion(content=content, tool_calls=tuple(tool_calls)) + raise LLMClientError("LLM response did not include text content") diff --git a/abogen/main.py b/abogen/main.py index ad80b20..90a55e1 100644 --- a/abogen/main.py +++ b/abogen/main.py @@ -1,116 +1,36 @@ -import os -import sys -import platform +"""Backwards-compatible entry point that now launches the web UI.""" + +from __future__ import annotations + import atexit +import os +import platform import signal -from abogen.utils import get_resource_path, load_config, prevent_sleep_end +import sys +from abogen.utils import load_config, prevent_sleep_end +from abogen.webui.app import main as _run_web_ui -# Fix PyTorch DLL loading issue ([WinError 1114]) on Windows before importing PyQt6 -if platform.system() == "Windows": - import ctypes - from importlib.util import find_spec - - try: - if ( - (spec := find_spec("torch")) - and spec.origin - and os.path.exists( - dll_path := os.path.join(os.path.dirname(spec.origin), "lib", "c10.dll") - ) - ): - ctypes.CDLL(os.path.normpath(dll_path)) - except Exception: - pass - - -# Qt platform plugin detection (fixes #59) -try: - from PyQt6.QtCore import QLibraryInfo - - # Get the path to the plugins directory - plugins = QLibraryInfo.path(QLibraryInfo.LibraryPath.PluginsPath) - - # Normalize path to use the OS-native separators and absolute path - platform_dir = os.path.normpath(os.path.join(plugins, "platforms")) - - # Ensure we work with an absolute path for clarity - platform_dir = os.path.abspath(platform_dir) - - if os.path.isdir(platform_dir): - os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = platform_dir - print("QT_QPA_PLATFORM_PLUGIN_PATH set to:", platform_dir) - else: - print("PyQt6 platform plugins not found at", platform_dir) -except ImportError: - print("PyQt6 not installed.") - - -# Pre-load "libxcb-cursor" on Linux (fixes #101) -if platform.system() == "Linux": - arch = platform.machine().lower() - lib_filename = {"x86_64": "libxcb-cursor-amd64.so.0", "amd64": "libxcb-cursor-amd64.so.0", "aarch64": "libxcb-cursor-arm64.so.0", "arm64": "libxcb-cursor-arm64.so.0"}.get(arch) - if lib_filename: - import ctypes - try: - # Try to load the system libxcb-cursor.so.0 first - ctypes.CDLL('libxcb-cursor.so.0', mode=ctypes.RTLD_GLOBAL) - except OSError: - # System lib not available, load the bundled version - lib_path = get_resource_path('abogen.libs', lib_filename) - if lib_path: - try: - ctypes.CDLL(lib_path, mode=ctypes.RTLD_GLOBAL) - except OSError: - # If it fails (e.g. wrong glibc version on very old systems), - # we simply ignore it and hope the system has the library. - pass - - -# Set application ID for Windows taskbar icon -if platform.system() == "Windows": - try: - from abogen.constants import PROGRAM_NAME, VERSION - import ctypes - - app_id = f"{PROGRAM_NAME}.{VERSION}" - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) - except Exception as e: - print("Warning: failed to set AppUserModelID:", e) - -from PyQt6.QtWidgets import QApplication -from PyQt6.QtGui import QIcon -from PyQt6.QtCore import ( - QLibraryInfo, - qInstallMessageHandler, - QtMsgType, -) - -# Add the directory to Python path -sys.path.insert(0, os.path.join(os.path.dirname(__file__))) - -# Set Hugging Face Hub environment variables -os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry -os.environ["HF_HUB_ETAG_TIMEOUT"] = "10" # Metadata request timeout (seconds) -os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds) -os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Disable symlinks warning +# Configure Hugging Face Hub behaviour (mirrors legacy GUI defaults). +os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1") +os.environ.setdefault("HF_HUB_ETAG_TIMEOUT", "10") +os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "10") +os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1") if load_config().get("disable_kokoro_internet", False): - print("INFO: Kokoro's internet access is disabled.") - os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access + os.environ["HF_HUB_OFFLINE"] = "1" -from abogen.gui import abogen -from abogen.constants import PROGRAM_NAME, VERSION +# Prefer faster ROCm tuning defaults when available. +os.environ.setdefault("MIOPEN_FIND_MODE", "FAST") +os.environ.setdefault("MIOPEN_CONV_PRECISE_ROCM_TUNING", "0") -# Set environment variables for AMD ROCm -os.environ["MIOPEN_FIND_MODE"] = "FAST" -os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0" +# Enable MPS GPU acceleration on Apple Silicon. +if platform.system() == "Darwin" and platform.processor() == "arm": + os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1") -# Reset sleep states atexit.register(prevent_sleep_end) -# Also handle signals (Ctrl+C, kill, etc.) -def _cleanup_sleep(signum, frame): +def _cleanup_sleep(signum, _frame): prevent_sleep_end() sys.exit(0) @@ -118,70 +38,12 @@ def _cleanup_sleep(signum, frame): signal.signal(signal.SIGINT, _cleanup_sleep) signal.signal(signal.SIGTERM, _cleanup_sleep) -# Ensure sys.stdout and sys.stderr are valid in GUI mode -if sys.stdout is None: - sys.stdout = open(os.devnull, "w") -if sys.stderr is None: - sys.stderr = open(os.devnull, "w") -# Enable MPS GPU acceleration on Mac Apple Silicon -if platform.system() == "Darwin" and platform.processor() == "arm": - os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" +def main() -> None: + """Launch the Flask-based web UI.""" + + _run_web_ui() -# Custom message handler to filter out specific Qt warnings -def qt_message_handler(mode, context, message): - # In PyQt6, the mode is an enum, so we compare with the enum members - if "Wayland does not support QWindow::requestActivate()" in message: - return # Suppress this specific message - if "setGrabPopup called with a parent, QtWaylandClient" in message: - return - - if mode == QtMsgType.QtWarningMsg: - print(f"Qt Warning: {message}") - elif mode == QtMsgType.QtCriticalMsg: - print(f"Qt Critical: {message}") - elif mode == QtMsgType.QtFatalMsg: - print(f"Qt Fatal: {message}") - elif mode == QtMsgType.QtInfoMsg: - print(f"Qt Info: {message}") - - -# Install the custom message handler -qInstallMessageHandler(qt_message_handler) - -# Handle Wayland on Linux GNOME -if platform.system() == "Linux": - xdg_session = os.environ.get("XDG_SESSION_TYPE", "").lower() - desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() - if ( - "gnome" in desktop - and xdg_session == "wayland" - and "QT_QPA_PLATFORM" not in os.environ - ): - os.environ["QT_QPA_PLATFORM"] = "wayland" - - -def main(): - """Main entry point for console usage.""" - app = QApplication(sys.argv) - - # Set application icon using get_resource_path from utils - icon_path = get_resource_path("abogen.assets", "icon.ico") - if icon_path: - app.setWindowIcon(QIcon(icon_path)) - - # Set the .desktop name on Linux - if platform.system() == "Linux": - try: - app.setDesktopFileName("abogen") - except AttributeError: - pass - - ex = abogen() - ex.show() - sys.exit(app.exec()) - - -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover - manual execution hook main() diff --git a/abogen/normalization_settings.py b/abogen/normalization_settings.py new file mode 100644 index 0000000..fcbe58e --- /dev/null +++ b/abogen/normalization_settings.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import os +from dataclasses import replace +from functools import lru_cache +from typing import Any, Dict, Mapping, Optional + +from abogen.kokoro_text_normalization import ApostropheConfig, CONTRACTION_CATEGORY_DEFAULTS +from abogen.llm_client import LLMConfiguration +from abogen.utils import load_config + +DEFAULT_LLM_PROMPT = ( + "You are assisting with audiobook preparation. Analyze the sentence and identify any apostrophes or " + "contractions that should be expanded for clarity. Call the apply_regex_replacements tool with precise " + "regex substitutions for only the words that need adjustment. If no changes are required, return an empty list.\n" + "Sentence: {{ sentence }}" +) + +_LEGACY_REWRITE_ONLY_PROMPT = ( + "You are assisting with audiobook preparation. Rewrite the provided sentence so apostrophes and " + "contractions are unambiguous for text-to-speech. Respond with only the rewritten sentence.\n" + "Sentence: {{ sentence }}\n" + "Context: {{ paragraph }}" +) + +_SETTINGS_DEFAULTS: Dict[str, Any] = { + "llm_base_url": "", + "llm_api_key": "", + "llm_model": "", + "llm_timeout": 30.0, + "llm_prompt": DEFAULT_LLM_PROMPT, + "llm_context_mode": "sentence", + "normalization_numbers": True, + "normalization_numbers_year_style": "american", + "normalization_currency": True, + "normalization_footnotes": True, + "normalization_titles": True, + "normalization_terminal": True, + "normalization_phoneme_hints": True, + "normalization_caps_quotes": True, + "normalization_internet_slang": False, + "normalization_apostrophes_contractions": True, + "normalization_apostrophes_plural_possessives": True, + "normalization_apostrophes_sibilant_possessives": True, + "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, +} + +_CONTRACTION_SETTING_MAP: Dict[str, str] = { + "normalization_contraction_aux_be": "contraction_aux_be", + "normalization_contraction_aux_have": "contraction_aux_have", + "normalization_contraction_modal_will": "contraction_modal_will", + "normalization_contraction_modal_would": "contraction_modal_would", + "normalization_contraction_negation_not": "contraction_negation_not", + "normalization_contraction_let_us": "contraction_let_us", +} + +_ENVIRONMENT_KEYS: Dict[str, str] = { + "llm_base_url": "ABOGEN_LLM_BASE_URL", + "llm_api_key": "ABOGEN_LLM_API_KEY", + "llm_model": "ABOGEN_LLM_MODEL", + "llm_timeout": "ABOGEN_LLM_TIMEOUT", + "llm_prompt": "ABOGEN_LLM_PROMPT", + "llm_context_mode": "ABOGEN_LLM_CONTEXT_MODE", +} + +NORMALIZATION_SAMPLE_TEXTS: Dict[str, str] = { + "apostrophes": "I've heard the captain'll arrive by dusk, but they'd said the same yesterday.", + "numbers": "The ledger listed 1,204 outstanding debts totaling $57,890.", + "titles": "Dr. Smith met Mr. O'Leary outside St. John's Church on Jan. 4th.", + "punctuation": "Meet me at the docks tonight We'll decide then", # missing punctuation +} + + +@lru_cache(maxsize=1) +def _environment_defaults() -> Dict[str, Any]: + overrides: Dict[str, Any] = {} + for key, env_var in _ENVIRONMENT_KEYS.items(): + default = _SETTINGS_DEFAULTS.get(key) + if default is None: + continue + value = os.environ.get(env_var) + if value is None or value == "": + continue + if isinstance(default, bool): + overrides[key] = _coerce_bool(value, default) + elif isinstance(default, float): + overrides[key] = _coerce_float(value, float(default)) + else: + overrides[key] = value + return overrides + + +def environment_llm_defaults() -> Dict[str, Any]: + defaults = dict(_environment_defaults()) + if defaults: + _apply_llm_migrations(defaults) + return defaults + + +def _coerce_bool(value: Any, default: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"1", "true", "yes", "on"}: + return True + if lowered in {"0", "false", "no", "off"}: + return False + return default + + +def _coerce_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _apply_llm_migrations(settings: Dict[str, Any]) -> None: + prompt_value = str(settings.get("llm_prompt") or "") + if prompt_value.strip() == _LEGACY_REWRITE_ONLY_PROMPT.strip(): + settings["llm_prompt"] = DEFAULT_LLM_PROMPT + + context_mode = str(settings.get("llm_context_mode") or "").strip().lower() + if context_mode != "sentence": + settings["llm_context_mode"] = "sentence" + + +def _extract_settings(source: Mapping[str, Any]) -> Dict[str, Any]: + env_defaults = _environment_defaults() + extracted: Dict[str, Any] = {} + for key, default in _SETTINGS_DEFAULTS.items(): + if key in source: + raw_value = source.get(key) + elif key in env_defaults: + raw_value = env_defaults[key] + else: + raw_value = default + if isinstance(default, bool): + extracted[key] = _coerce_bool(raw_value, default) + elif isinstance(default, float): + extracted[key] = _coerce_float(raw_value, default) + else: + extracted[key] = str(raw_value or "") if isinstance(default, str) else raw_value + _apply_llm_migrations(extracted) + return extracted + + +@lru_cache(maxsize=1) +def _cached_settings() -> Dict[str, Any]: + config = load_config() or {} + return _extract_settings(config) + + +def get_runtime_settings() -> Dict[str, Any]: + return dict(_cached_settings()) + + +def clear_cached_settings() -> None: + _cached_settings.cache_clear() + + +def build_apostrophe_config( + *, + settings: Mapping[str, Any], + base: Optional[ApostropheConfig] = None, +) -> ApostropheConfig: + config = replace(base or ApostropheConfig()) + config.convert_numbers = bool(settings.get("normalization_numbers", True)) + config.convert_currency = bool(settings.get("normalization_currency", True)) + config.remove_footnotes = bool(settings.get("normalization_footnotes", True)) + config.year_pronunciation_mode = str(settings.get("normalization_numbers_year_style", "american") or "").strip().lower() + config.add_phoneme_hints = bool(settings.get("normalization_phoneme_hints", True)) + config.contraction_mode = "expand" if settings.get("normalization_apostrophes_contractions", True) else "keep" + config.plural_possessive_mode = ( + "collapse" if settings.get("normalization_apostrophes_plural_possessives", True) else "keep" + ) + config.sibilant_possessive_mode = ( + "mark" if settings.get("normalization_apostrophes_sibilant_possessives", True) else "keep" + ) + config.decades_mode = "expand" if settings.get("normalization_apostrophes_decades", True) else "keep" + config.leading_elision_mode = ( + "expand" if settings.get("normalization_apostrophes_leading_elisions", True) else "keep" + ) + config.ambiguous_past_modal_mode = "contextual" if config.contraction_mode == "expand" else "keep" + category_flags = dict(CONTRACTION_CATEGORY_DEFAULTS) + for setting_key, category in _CONTRACTION_SETTING_MAP.items(): + default_value = bool(_SETTINGS_DEFAULTS.get(setting_key, True)) + raw_value = settings.get(setting_key, default_value) + category_flags[category] = _coerce_bool(raw_value, default_value) + config.contraction_categories = category_flags + return config + + +def build_llm_configuration(settings: Mapping[str, Any]) -> LLMConfiguration: + return LLMConfiguration( + base_url=str(settings.get("llm_base_url") or ""), + api_key=str(settings.get("llm_api_key") or ""), + model=str(settings.get("llm_model") or ""), + timeout=_coerce_float(settings.get("llm_timeout"), float(_SETTINGS_DEFAULTS["llm_timeout"])), + ) + + +def apply_overrides(base: Mapping[str, Any], overrides: Mapping[str, Any]) -> Dict[str, Any]: + merged: Dict[str, Any] = dict(base) + for key, value in overrides.items(): + if key not in _SETTINGS_DEFAULTS: + continue + merged[key] = value + _apply_llm_migrations(merged) + return merged diff --git a/abogen/pronunciation_store.py b/abogen/pronunciation_store.py new file mode 100644 index 0000000..19e7f33 --- /dev/null +++ b/abogen/pronunciation_store.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import json +import sqlite3 +import shutil +import threading +import time +import uuid +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional + +from .entity_analysis import normalize_token +from .utils import get_internal_cache_path, get_user_settings_dir + +_DB_LOCK = threading.RLock() +_SCHEMA_VERSION = 1 + + +def _store_path() -> Path: + try: + base_dir = Path(get_user_settings_dir()) + except ModuleNotFoundError: + base_dir = Path(get_internal_cache_path("pronunciations")) + target = base_dir / "overrides.json" + target.parent.mkdir(parents=True, exist_ok=True) + return target + + +def _migrate_legacy_sqlite(target_json_path: Path) -> None: + try: + base_dir = Path(get_user_settings_dir()) + except ModuleNotFoundError: + base_dir = Path(get_internal_cache_path("pronunciations")) + + sqlite_path = base_dir / "pronunciations.db" + if not sqlite_path.exists(): + return + + try: + conn = sqlite3.connect(sqlite_path) + conn.row_factory = sqlite3.Row + + # Check if table exists + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='overrides'") + if not cursor.fetchone(): + conn.close() + return + + cursor = conn.execute("SELECT * FROM overrides") + rows = cursor.fetchall() + + data = {"version": _SCHEMA_VERSION, "overrides": {}} + + for row in rows: + lang = row["language"] + if lang not in data["overrides"]: + data["overrides"][lang] = {} + + entry = { + "id": str(row["id"]), + "normalized": row["normalized"], + "token": row["token"], + "language": row["language"], + "pronunciation": row["pronunciation"], + "voice": row["voice"], + "notes": row["notes"], + "context": row["context"], + "usage_count": row["usage_count"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + data["overrides"][lang][row["normalized"]] = entry + + conn.close() + + # Save to JSON + with open(target_json_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + # Rename old DB + sqlite_path.rename(sqlite_path.with_suffix(".db.bak")) + + except Exception: + pass + + +def _load_db() -> Dict[str, Any]: + path = _store_path() + if not path.exists(): + _migrate_legacy_sqlite(path) + if not path.exists(): + return {"version": _SCHEMA_VERSION, "overrides": {}} + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {"version": _SCHEMA_VERSION, "overrides": {}} + + +def _save_db(data: Dict[str, Any]) -> None: + path = _store_path() + # Atomic write + temp_path = path.with_suffix(".tmp") + with open(temp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + shutil.move(str(temp_path), str(path)) + + +def load_overrides(language: str, tokens: Iterable[str]) -> Dict[str, Dict[str, Any]]: + normalized_tokens = {normalize_token(token) for token in tokens if token} + if not normalized_tokens: + return {} + + with _DB_LOCK: + db = _load_db() + lang_overrides = db.get("overrides", {}).get(language, {}) + + results: Dict[str, Dict[str, Any]] = {} + for normalized in normalized_tokens: + if normalized in lang_overrides: + results[normalized] = lang_overrides[normalized] + return results + + +def search_overrides(language: str, query: str, *, limit: int = 15) -> List[Dict[str, Any]]: + if not query: + return [] + + query = query.lower() + with _DB_LOCK: + db = _load_db() + lang_overrides = db.get("overrides", {}).get(language, {}) + + matches = [] + for entry in lang_overrides.values(): + if query in entry["normalized"] or query in entry["token"].lower(): + matches.append(entry) + + # Sort by usage count desc, then updated_at desc + matches.sort(key=lambda x: (x.get("usage_count", 0), x.get("updated_at", 0)), reverse=True) + return matches[:limit] + + +def save_override( + *, + language: str, + token: str, + pronunciation: Optional[str] = None, + voice: Optional[str] = None, + notes: Optional[str] = None, + context: Optional[str] = None, +) -> Dict[str, Any]: + normalized = normalize_token(token) + if not normalized: + raise ValueError("Provide a token to override") + + timestamp = time.time() + with _DB_LOCK: + db = _load_db() + overrides = db.setdefault("overrides", {}) + lang_overrides = overrides.setdefault(language, {}) + + existing = lang_overrides.get(normalized) + + if existing: + entry = existing + entry["token"] = token + entry["pronunciation"] = pronunciation + entry["voice"] = voice + entry["notes"] = notes + entry["context"] = context + entry["updated_at"] = timestamp + else: + entry = { + "id": str(uuid.uuid4()), + "normalized": normalized, + "token": token, + "language": language, + "pronunciation": pronunciation, + "voice": voice, + "notes": notes, + "context": context, + "usage_count": 0, + "created_at": timestamp, + "updated_at": timestamp, + } + lang_overrides[normalized] = entry + + _save_db(db) + return entry + + +def delete_override(*, language: str, token: str) -> None: + normalized = normalize_token(token) + if not normalized: + return + + with _DB_LOCK: + db = _load_db() + lang_overrides = db.get("overrides", {}).get(language, {}) + + if normalized in lang_overrides: + del lang_overrides[normalized] + _save_db(db) + + +def all_overrides(language: str) -> List[Dict[str, Any]]: + with _DB_LOCK: + db = _load_db() + lang_overrides = db.get("overrides", {}).get(language, {}) + + results = list(lang_overrides.values()) + results.sort(key=lambda x: x.get("updated_at", 0), reverse=True) + return results + + +def increment_usage(*, language: str, token: str, amount: int = 1) -> None: + normalized = normalize_token(token) + if not normalized: + return + + with _DB_LOCK: + db = _load_db() + lang_overrides = db.get("overrides", {}).get(language, {}) + + if normalized in lang_overrides: + entry = lang_overrides[normalized] + entry["usage_count"] = entry.get("usage_count", 0) + amount + entry["updated_at"] = time.time() + _save_db(db) + + +def get_override_stats(language: str) -> Dict[str, int]: + with _DB_LOCK: + db = _load_db() + lang_overrides = db.get("overrides", {}).get(language, {}) + + total = len(lang_overrides) + with_pronunciation = sum(1 for x in lang_overrides.values() if x.get("pronunciation")) + with_voice = sum(1 for x in lang_overrides.values() if x.get("voice")) + + return { + "total": total, + "filtered": total, + "with_pronunciation": with_pronunciation, + "with_voice": with_voice, + } diff --git a/abogen/pyqt/__init__.py b/abogen/pyqt/__init__.py new file mode 100644 index 0000000..cc5cb14 --- /dev/null +++ b/abogen/pyqt/__init__.py @@ -0,0 +1,7 @@ +"""PyQt6 Desktop GUI for abogen. + +This package contains the traditional PyQt6-based desktop interface. +For the web-based interface, see abogen.webui. +""" + +from __future__ import annotations diff --git a/abogen/pyqt/book_handler.py b/abogen/pyqt/book_handler.py new file mode 100644 index 0000000..4fa63ba --- /dev/null +++ b/abogen/pyqt/book_handler.py @@ -0,0 +1,1654 @@ +import re +import base64 +from bs4 import BeautifulSoup, NavigableString +from PyQt6.QtGui import QMovie +from PyQt6.QtWidgets import ( + QDialog, + QTreeWidget, + QTreeWidgetItem, + QTextEdit, + QPushButton, + QVBoxLayout, + QHBoxLayout, + QDialogButtonBox, + QSplitter, + QWidget, + QCheckBox, + QTreeWidgetItemIterator, + QLabel, + QMenu, +) +from PyQt6.QtCore import ( + Qt, + QThread, + pyqtSignal, + QSize, +) +from abogen.utils import ( + detect_encoding, + get_resource_path, +) +from abogen.book_parser import get_book_parser + +from abogen.subtitle_utils import ( + clean_text, + calculate_text_length, +) + +import os +import logging +import urllib.parse +import textwrap + +# Setup logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) + +_HTML_TAG_PATTERN = re.compile(r"<[^>]+>") +_LEADING_DASH_PATTERN = re.compile(r"^\s*[-–—]\s*") +_LEADING_SIMPLE_DASH_PATTERN = re.compile(r"^\s*-\s*") + + +class HandlerDialog(QDialog): + # Class variables to remember checkbox states between dialog instances + _save_chapters_separately = False + _merge_chapters_at_end = True + _save_as_project = False # New class variable for save_as_project option + + # Cache for processed book content to avoid reprocessing + # Key: (book_path, modification_time, file_type) + # Value: dict with content_texts, content_lengths, doc_content (for epub), markdown_toc (for markdown) + _content_cache = {} + + class _LoaderThread(QThread): + """Minimal QThread that runs a callable and emits an error string on exception.""" + + error = pyqtSignal(str) + + def __init__(self, target_callable): + super().__init__() + self._target = target_callable + + def run(self): + try: + self._target() + except Exception as e: + self.error.emit(str(e)) + + @classmethod + def clear_content_cache(cls, book_path=None): + """Clear the content cache. If book_path is provided, only clear that book's cache.""" + if book_path is None: + cls._content_cache.clear() + logging.info("Cleared all content cache") + else: + keys_to_remove = [ + key for key in cls._content_cache.keys() if key[0] == book_path + ] + for key in keys_to_remove: + del cls._content_cache[key] + if keys_to_remove: + logging.info(f"Cleared content cache for {os.path.basename(book_path)}") + + def __init__(self, book_path, file_type=None, checked_chapters=None, parent=None): + super().__init__(parent) + + # Normalize path + book_path = os.path.normpath(os.path.abspath(book_path)) + self.book_path = book_path + + # Initialize Parser + try: + # Factory handles file type detection if file_type is None + self.parser = get_book_parser(book_path, file_type=file_type) + # Parser loads automatically in init now + except Exception as e: + logging.error(f"Failed to initialize parser for {book_path}: {e}") + raise + + # Extract book name from file path + book_name = os.path.splitext(os.path.basename(book_path))[0] + + # Set window title based on file type and book name + item_type = "Chapters" if self.parser.file_type in ["epub", "markdown"] else "Pages" + self.setWindowTitle(f"Select {item_type} - {book_name}") + self.resize(1200, 900) + self._block_signals = False # Flag to prevent recursive signals + # Configure window: remove help button and allow resizing + self.setWindowFlags( + Qt.WindowType.Window + | Qt.WindowType.WindowCloseButtonHint + | Qt.WindowType.WindowMaximizeButtonHint + ) + self.setWindowModality(Qt.WindowModality.NonModal) + # Initialize save chapters flags from class variables + self.save_chapters_separately = HandlerDialog._save_chapters_separately + self.merge_chapters_at_end = HandlerDialog._merge_chapters_at_end + self.save_as_project = HandlerDialog._save_as_project + + # Initialize metadata dict; will be populated in _preprocess_content by the background loader + self.book_metadata = {} + + # Initialize UI elements that are used in other methods + self.save_chapters_checkbox = None + self.merge_chapters_checkbox = None + + # Build treeview + self.treeWidget = QTreeWidget(self) + self.treeWidget.setHeaderHidden(True) + self.treeWidget.setSelectionMode(QTreeWidget.SelectionMode.ExtendedSelection) + self.treeWidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.treeWidget.customContextMenuRequested.connect(self.on_tree_context_menu) + + # Initialize checked_chapters set + self.checked_chapters = set(checked_chapters) if checked_chapters else set() + + # For storing content and lengths (will be filled by background loader) + self.content_texts = {} + self.content_lengths = {} + # Also maintain refs for structure + self.processed_nav_structure = [] + + # Add a placeholder "Information" item so the tree isn't empty immediately + info_item = QTreeWidgetItem(self.treeWidget, ["Information"]) + info_item.setData(0, Qt.ItemDataRole.UserRole, "info:bookinfo") + info_item.setFlags(info_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + font = info_item.font(0) + font.setBold(True) + info_item.setFont(0, font) + + # Setup UI now so dialog appears immediately + self._setup_ui() + + # Create a centered loading overlay and show it while background load runs + self._create_loading_overlay() + # Hide the main UI so only the overlay is visible initially + if getattr(self, "splitter", None) is not None: + self.splitter.setVisible(False) + self._show_loading_overlay("Loading...") + + # Start background loading of book content so the dialog opens immediately + self._start_background_load() + + # Hide expand/collapse decoration if there are no parent items + has_parents = False + for i in range(self.treeWidget.topLevelItemCount()): + if self.treeWidget.topLevelItem(i).childCount() > 0: + has_parents = True + break + self.treeWidget.setRootIsDecorated(has_parents) + + def _create_loading_overlay(self): + """Create a centered loading indicator with a GIF on the left and text on the right. + + The indicator is added to the dialog's main layout above the splitter so + when the splitter is hidden only the indicator is visible. + """ + try: + # Container to hold gif + text and allow centering via stretches + container = QWidget(self) + container.setVisible(False) + h = QHBoxLayout(container) + h.setContentsMargins(0, 8, 0, 8) + h.setSpacing(10) + + # Left: GIF label (animated) + gif_label = QLabel(container) + gif_label.setVisible(False) + + loading_gif_path = get_resource_path("abogen.assets", "loading.gif") + movie = None + if loading_gif_path: + try: + movie = QMovie(loading_gif_path) + # Make GIF smaller so it doesn't dominate the text + movie.setScaledSize(QSize(25, 25)) + gif_label.setMovie(movie) + gif_label.setFixedSize(25, 25) + gif_label.setVisible(True) + except Exception: + movie = None + + # Right: Text label + text_label = QLabel(container) + text_label.setStyleSheet("font-size: 14pt;") + + # Add stretches to center the content horizontally + h.addStretch(1) + h.addWidget(gif_label, 0, Qt.AlignmentFlag.AlignVCenter) + h.addWidget(text_label, 0, Qt.AlignmentFlag.AlignVCenter) + h.addStretch(1) + + # Insert at top of main layout if present, otherwise keep as child + try: + layout = self.layout() + if layout is not None: + layout.insertWidget(0, container) + except Exception: + pass + + # Store refs + self._loading_container = container + self._loading_gif_label = gif_label + self._loading_text_label = text_label + self._loading_movie = movie + except Exception: + self._loading_container = None + self._loading_gif_label = None + self._loading_text_label = None + self._loading_movie = None + + def _show_loading_overlay(self, text: str): + container = getattr(self, "_loading_container", None) + text_lbl = getattr(self, "_loading_text_label", None) + movie = getattr(self, "_loading_movie", None) + gif_lbl = getattr(self, "_loading_gif_label", None) + if container is None or text_lbl is None: + return + text_lbl.setText(text) + if movie is not None and gif_lbl is not None: + try: + movie.start() + gif_lbl.setVisible(True) + except Exception: + pass + container.setVisible(True) + + def _hide_loading_overlay(self): + container = getattr(self, "_loading_container", None) + movie = getattr(self, "_loading_movie", None) + if container is None: + return + if movie is not None: + try: + movie.stop() + except Exception: + pass + container.setVisible(False) + + def _start_background_load(self): + """Start a QThread that runs the preprocessing in background.""" + # Start a minimal QThread which executes _preprocess_content + self._loader_thread = HandlerDialog._LoaderThread(self._preprocess_content) + self._loader_thread.finished.connect(self._on_load_finished) + self._loader_thread.error.connect(self._on_load_error) + # ensure thread instance is deleted when done + self._loader_thread.finished.connect(self._loader_thread.deleteLater) + self._loader_thread.start() + + def _on_load_error(self, err_msg): + logging.error(f"Error loading book in background: {err_msg}") + if getattr(self, "previewEdit", None) is not None: + self.previewEdit.setPlainText(f"Error loading book: {err_msg}") + if getattr(self, "splitter", None) is not None: + self.splitter.setVisible(True) + self._hide_loading_overlay() + + def _on_load_finished(self): + """Called in the main thread when background loading finished.""" + # Build the tree now that content_texts/content_lengths/etc. are ready + try: + # Rebuild tree based on file type + self._build_tree() + + # Run auto-check if no provided checks are relevant + if not self._are_provided_checks_relevant(): + self._run_auto_check() + + # Connect signals (after tree exists) + self.treeWidget.currentItemChanged.connect(self.update_preview) + self.treeWidget.itemChanged.connect(self.handle_item_check) + self.treeWidget.itemChanged.connect( + lambda _: self._update_checkbox_states() + ) + self.treeWidget.itemDoubleClicked.connect(self.handle_item_double_click) + + # Expand and select first item + self.treeWidget.expandAll() + if self.treeWidget.topLevelItemCount() > 0: + self.treeWidget.setCurrentItem(self.treeWidget.topLevelItem(0)) + self.treeWidget.setFocus() + + # Update checkbox states + self._update_checkbox_states() + + # Update preview for the current selection + current = self.treeWidget.currentItem() + self.update_preview(current) + + except Exception as e: + logging.error(f"Error finalizing book load: {e}") + # Show the main UI and hide loading text + if getattr(self, "splitter", None) is not None: + self.splitter.setVisible(True) + self._hide_loading_overlay() + + def _preprocess_content(self): + """Pre-process content from the document""" + # Create cache key from file path, modification time, file type, and replace_single_newlines setting + try: + mod_time = os.path.getmtime(self.book_path) + except Exception: + mod_time = 0 + + # Include replace_single_newlines in cache key since it affects text cleaning + from abogen.utils import load_config + + cfg = load_config() + replace_single_newlines = cfg.get("replace_single_newlines", True) + + cache_key = (self.book_path, mod_time, self.parser.file_type, replace_single_newlines) + + # Check if content is already cached + if cache_key in HandlerDialog._content_cache: + cached_data = HandlerDialog._content_cache[cache_key] + self.content_texts = cached_data["content_texts"] + self.content_lengths = cached_data["content_lengths"] + if "processed_nav_structure" in cached_data: + self.processed_nav_structure = cached_data["processed_nav_structure"] + if "book_metadata" in cached_data: + self.book_metadata = cached_data["book_metadata"] + + # Apply to parser so it stays in sync if used elsewhere + self.parser.content_texts = self.content_texts + self.parser.content_lengths = self.content_lengths + self.parser.processed_nav_structure = self.processed_nav_structure + self.parser.book_metadata = self.book_metadata + + logging.info(f"Using cached content for {os.path.basename(self.book_path)}") + return + + # Process content if not cached + try: + self.parser.process_content(replace_single_newlines=replace_single_newlines) + self.content_texts = self.parser.content_texts + self.content_lengths = self.parser.content_lengths + self.processed_nav_structure = self.parser.processed_nav_structure + self.book_metadata = self.parser.get_metadata() + except Exception as e: + logging.error(f"Error processing content: {e}", exc_info=True) + # Handle empty/failure case + self.content_texts = {} + self.content_lengths = {} + + # Cache the processed content + cache_data = { + "content_texts": self.content_texts, + "content_lengths": self.content_lengths, + "processed_nav_structure": self.processed_nav_structure, + "book_metadata": self.book_metadata, + } + + HandlerDialog._content_cache[cache_key] = cache_data + logging.info(f"Cached content for {os.path.basename(self.book_path)}") + + + def _build_tree(self): + self.treeWidget.clear() + + info_item = QTreeWidgetItem(self.treeWidget, ["Information"]) + info_item.setData(0, Qt.ItemDataRole.UserRole, "info:bookinfo") + info_item.setFlags(info_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + font = info_item.font(0) + font.setBold(True) + info_item.setFont(0, font) + + # TODO look into why we need this here and not just rely on logic in PDFParser + if self.parser.file_type == "pdf": + self._build_pdf_tree() + elif self.processed_nav_structure: + self._build_tree_from_nav( + self.processed_nav_structure, self.treeWidget + ) + else: + # If no structure found but content exists (rare fallback), list flat + for ch_id, ch_len in self.content_lengths.items(): + # Simple flat list + item = QTreeWidgetItem(self.treeWidget, [ch_id]) + item.setData(0, Qt.ItemDataRole.UserRole, ch_id) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + if self.content_texts.get(ch_id): + item.setCheckState(0, Qt.CheckState.Checked if ch_id in self.checked_chapters else Qt.CheckState.Unchecked) + + has_parents = False + iterator = QTreeWidgetItemIterator( + self.treeWidget, QTreeWidgetItemIterator.IteratorFlag.HasChildren + ) + if iterator.value(): + has_parents = True + self.treeWidget.setRootIsDecorated(has_parents) + + def _update_checkbox_states(self): + """Update the checkbox states based on the current checked chapters.""" + for i in range(self.treeWidget.topLevelItemCount()): + item = self.treeWidget.topLevelItem(i) + self._update_item_checkbox_state(item) + + def _build_tree_from_nav( + self, nav_nodes, parent_item, seen_content_hashes=None + ): + if seen_content_hashes is None: + seen_content_hashes = set() + for node in nav_nodes: + title = node.get("title", "Unknown") + src = node.get("src") + children = node.get("children", []) + + item = QTreeWidgetItem(parent_item, [title]) + item.setData(0, Qt.ItemDataRole.UserRole, src) + + is_empty = ( + src + and (src in self.content_texts) + and (not self.content_texts[src].strip()) + ) + is_duplicate = False + if src and src in self.content_texts and self.content_texts[src].strip(): + content_hash = hash(self.content_texts[src]) + if content_hash in seen_content_hashes: + is_duplicate = True + else: + seen_content_hashes.add(content_hash) + + if src and not is_empty and not is_duplicate: + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + is_checked = src in self.checked_chapters + item.setCheckState( + 0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked + ) + elif is_duplicate: + # Mark as duplicate and remove checkbox + item.setText(0, f"{title} (Duplicate)") + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + elif children: + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState(0, Qt.CheckState.Unchecked) + else: + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + + if children: + self._build_tree_from_nav(children, item, seen_content_hashes) + + + # TODO look into why we need this here and not just rely on logic in PDFParser + def _build_pdf_tree(self): + pdf_doc = getattr(self.parser, "pdf_doc", None) + if not pdf_doc: + return + + outline = pdf_doc.get_toc() + self.has_pdf_bookmarks = bool(outline) + self.bookmark_items_map = {} + + if not outline: + self._build_pdf_pages_tree() + return + + bookmark_pages = [] + page_to_bookmark = {} + next_page_boundaries = {} + added_pages = set() + + def extract_page_numbers(entries): + for entry in entries: + if len(entry) >= 3: + _, title, page = entry[:3] + page_num = ( + page - 1 + if isinstance(page, int) + else pdf_doc.resolve_link(page)[0] + ) + bookmark_pages.append((page_num, title)) + + if len(entry) > 3 and isinstance(entry[3], list): + extract_page_numbers(entry[3]) + + extract_page_numbers(outline) + bookmark_pages.sort() + + for i, (page_num, title) in enumerate(bookmark_pages): + if i < len(bookmark_pages) - 1: + next_page_boundaries[page_num] = bookmark_pages[i + 1][0] + page_to_bookmark[page_num] = title + + def build_outline_tree(entries, parent_item): + for entry in entries: + if len(entry) >= 3: + entry_level, title, page = entry[:3] + page_num = ( + page - 1 + if isinstance(page, int) + else pdf_doc.resolve_link(page)[0] + ) + page_id = f"page_{page_num + 1}" + # attach chapters on same page under original + if page_num in self.bookmark_items_map: + orig = self.bookmark_items_map[page_num] + child = QTreeWidgetItem(orig, [f"{title} (Same page)"]) + child.setData(0, Qt.ItemDataRole.UserRole, page_id) + child.setFlags(child.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + continue + bookmark_item = QTreeWidgetItem(parent_item, [title]) + bookmark_item.setData(0, Qt.ItemDataRole.UserRole, page_id) + # only allow checking if this chapter has content + if self.content_lengths.get(page_id, 0) > 0: + bookmark_item.setFlags( + bookmark_item.flags() | Qt.ItemFlag.ItemIsUserCheckable + ) + bookmark_item.setCheckState( + 0, + ( + Qt.CheckState.Checked + if page_id in self.checked_chapters + else Qt.CheckState.Unchecked + ), + ) + else: + bookmark_item.setFlags( + bookmark_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable + ) + # map for uncategorized pages + self.bookmark_items_map[page_num] = bookmark_item + + added_pages.add(page_num) + + next_page = next_page_boundaries.get(page_num, len(pdf_doc)) + for sub_page_num in range(page_num + 1, next_page): + if ( + sub_page_num in page_to_bookmark + or sub_page_num in added_pages + ): + continue + + page_id = f"page_{sub_page_num + 1}" + page_title = f"Page {sub_page_num + 1}" + + page_text = self.content_texts.get(page_id, "").strip() + if page_text: + first_line = page_text.split("\n", 1)[0].strip() + if first_line and len(first_line) < 100: + page_title += f" - {first_line}" + + page_item = QTreeWidgetItem(bookmark_item, [page_title]) + page_item.setData(0, Qt.ItemDataRole.UserRole, page_id) + # only allow checking if this sub-page has content + if self.content_lengths.get(page_id, 0) > 0: + page_item.setFlags( + page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable + ) + page_item.setCheckState( + 0, + ( + Qt.CheckState.Checked + if page_id in self.checked_chapters + else Qt.CheckState.Unchecked + ), + ) + else: + page_item.setFlags( + page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable + ) + + added_pages.add(sub_page_num) + + build_outline_tree(outline, self.treeWidget) + + covered_pages = set(added_pages) + # attach any pages without direct bookmarks under nearest preceding chapter + uncategorized_pages = [ + i for i in range(len(pdf_doc)) if i not in covered_pages + ] + for page_num in uncategorized_pages: + # find nearest previous bookmark + prev_nums = [n for n in sorted(self.bookmark_items_map) if n < page_num] + parent_item = ( + self.bookmark_items_map[prev_nums[-1]] if prev_nums else self.treeWidget + ) + page_id = f"page_{page_num + 1}" + title = f"Page {page_num + 1}" + text = self.content_texts.get(page_id, "").strip() + if text: + first = text.split("\n", 1)[0].strip() + if first and len(first) < 100: + title += f" - {first}" + page_item = QTreeWidgetItem(parent_item, [title]) + page_item.setData(0, Qt.ItemDataRole.UserRole, page_id) + # only allow checking if uncategorized page has content + if self.content_lengths.get(page_id, 0) > 0: + page_item.setFlags(page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + page_item.setCheckState( + 0, + ( + Qt.CheckState.Checked + if page_id in self.checked_chapters + else Qt.CheckState.Unchecked + ), + ) + else: + page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + + def _build_pdf_pages_tree(self): + pdf_doc = getattr(self.parser, "pdf_doc", None) + if not pdf_doc: + return + + pages_item = QTreeWidgetItem(self.treeWidget, ["Pages"]) + pages_item.setFlags(pages_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + font = pages_item.font(0) + font.setBold(True) + pages_item.setFont(0, font) + + for page_num in range(len(pdf_doc)): + page_id = f"page_{page_num + 1}" + page_title = f"Page {page_num + 1}" + + page_text = self.content_texts.get(page_id, "").strip() + if page_text: + first_line = page_text.split("\n", 1)[0].strip() + if first_line and len(first_line) < 100: + page_title += f" - {first_line}" + + page_item = QTreeWidgetItem(pages_item, [page_title]) + page_item.setData(0, Qt.ItemDataRole.UserRole, page_id) + # only allow checking if standalone page has content + if self.content_lengths.get(page_id, 0) > 0: + page_item.setFlags(page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + page_item.setCheckState( + 0, + ( + Qt.CheckState.Checked + if page_id in self.checked_chapters + else Qt.CheckState.Unchecked + ), + ) + else: + page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + + def _are_provided_checks_relevant(self): + if not self.checked_chapters: + return False + + all_identifiers = set() + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + identifier = item.data(0, Qt.ItemDataRole.UserRole) + if identifier: + all_identifiers.add(identifier) + iterator += 1 + + return bool(self.checked_chapters.intersection(all_identifiers)) + + def _setup_ui(self): + self.previewEdit = QTextEdit(self) + self.previewEdit.setReadOnly(True) + self.previewEdit.setMinimumWidth(300) + self.previewEdit.setStyleSheet("QTextEdit { border: none; }") + + self.previewInfoLabel = QLabel( + '*Note: You can modify the content later using the "Edit" button in the input box or by accessing the temporary files directory through settings (if not saved in a project folder).', + self, + ) + self.previewInfoLabel.setWordWrap(True) + self.previewInfoLabel.setStyleSheet( + "QLabel { color: #666; font-style: italic; }" + ) + + previewLayout = QVBoxLayout() + previewLayout.setContentsMargins(0, 0, 0, 0) + previewLayout.addWidget(self.previewEdit, 1) + previewLayout.addWidget(self.previewInfoLabel, 0) + + rightWidget = QWidget() + rightWidget.setLayout(previewLayout) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + self, + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + item_type = "chapters" if self.parser.file_type in ["epub", "markdown"] else "pages" + + self.auto_select_btn = QPushButton(f"Auto-select {item_type}", self) + self.auto_select_btn.clicked.connect(self.auto_select_chapters) + self.auto_select_btn.setToolTip(f"Automatically select main {item_type}") + + buttons_layout = QVBoxLayout() + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.setSpacing(10) + + auto_select_layout = QHBoxLayout() + auto_select_layout.addWidget(self.auto_select_btn) + buttons_layout.addLayout(auto_select_layout) + + select_layout = QHBoxLayout() + self.select_all_btn = QPushButton("Select all", self) + self.select_all_btn.clicked.connect(self.select_all_chapters) + self.deselect_all_btn = QPushButton("Clear all", self) + self.deselect_all_btn.clicked.connect(self.deselect_all_chapters) + select_layout.addWidget(self.select_all_btn) + select_layout.addWidget(self.deselect_all_btn) + buttons_layout.addLayout(select_layout) + + parent_layout = QHBoxLayout() + self.select_parents_btn = QPushButton("Select parents", self) + self.select_parents_btn.clicked.connect(self.select_parent_chapters) + self.deselect_parents_btn = QPushButton("Unselect parents", self) + self.deselect_parents_btn.clicked.connect(self.deselect_parent_chapters) + parent_layout.addWidget(self.select_parents_btn) + parent_layout.addWidget(self.deselect_parents_btn) + buttons_layout.addLayout(parent_layout) + + expand_layout = QHBoxLayout() + self.expand_all_btn = QPushButton("Expand All", self) + self.expand_all_btn.clicked.connect(self.treeWidget.expandAll) + self.collapse_all_btn = QPushButton("Collapse All", self) + self.collapse_all_btn.clicked.connect(self.treeWidget.collapseAll) + expand_layout.addWidget(self.expand_all_btn) + expand_layout.addWidget(self.collapse_all_btn) + buttons_layout.addLayout(expand_layout) + + leftLayout = QVBoxLayout() + leftLayout.setContentsMargins(0, 0, 5, 0) + leftLayout.addLayout(buttons_layout) + leftLayout.addWidget(self.treeWidget) + + checkbox_text = ( + "Save each chapter separately" + if self.parser.file_type in ["epub", "markdown"] + else "Save each page separately" + ) + self.save_chapters_checkbox = QCheckBox(checkbox_text, self) + self.save_chapters_checkbox.setChecked(self.save_chapters_separately) + self.save_chapters_checkbox.stateChanged.connect(self.on_save_chapters_changed) + leftLayout.addWidget(self.save_chapters_checkbox) + self.merge_chapters_checkbox = QCheckBox( + "Create a merged version at the end", self + ) + self.merge_chapters_checkbox.setChecked(self.merge_chapters_at_end) + self.merge_chapters_checkbox.stateChanged.connect( + self.on_merge_chapters_changed + ) + leftLayout.addWidget(self.merge_chapters_checkbox) + + self.save_as_project_checkbox = QCheckBox( + "Save in a project folder with metadata", self + ) + self.save_as_project_checkbox.setToolTip( + "Save the converted item in a project folder with metadata files. " + "(Useful if you want to work with converted items in the future.)" + ) + self.save_as_project_checkbox.setChecked(self.save_as_project) + self.save_as_project_checkbox.stateChanged.connect( + self.on_save_as_project_changed + ) + leftLayout.addWidget(self.save_as_project_checkbox) + + leftLayout.addWidget(buttons) + + leftWidget = QWidget() + leftWidget.setLayout(leftLayout) + + self.splitter = QSplitter(Qt.Orientation.Horizontal) + self.splitter.addWidget(leftWidget) + self.splitter.addWidget(rightWidget) + self.splitter.setSizes([280, 420]) + + mainLayout = QVBoxLayout(self) + mainLayout.addWidget(self.splitter) + self.setLayout(mainLayout) + + def _update_checkbox_states(self): + if ( + not hasattr(self, "save_chapters_checkbox") + or not self.save_chapters_checkbox + ): + return + + if ( + self.parser.file_type == "pdf" + and hasattr(self, "has_pdf_bookmarks") + and not self.has_pdf_bookmarks + ): + self.save_chapters_checkbox.setEnabled(False) + self.merge_chapters_checkbox.setEnabled(False) + return + + checked_count = 0 + + if self.parser.file_type in ["epub", "markdown"]: + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if ( + item.flags() & Qt.ItemFlag.ItemIsUserCheckable + and item.checkState(0) == Qt.CheckState.Checked + ): + checked_count += 1 + if checked_count >= 2: + break + iterator += 1 + + else: + parent_groups = set() + + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if ( + item.flags() & Qt.ItemFlag.ItemIsUserCheckable + and item.checkState(0) == Qt.CheckState.Checked + ): + parent = item.parent() + if parent and parent != self.treeWidget.invisibleRootItem(): + parent_groups.add(id(parent)) + else: + parent_groups.add(id(item)) + iterator += 1 + + checked_count = len(parent_groups) + + min_groups_required = 2 + self.save_chapters_checkbox.setEnabled(checked_count >= min_groups_required) + + self.merge_chapters_checkbox.setEnabled( + self.save_chapters_checkbox.isEnabled() + and self.save_chapters_checkbox.isChecked() + ) + + def select_all_chapters(self): + self._block_signals = True + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + item.setCheckState(0, Qt.CheckState.Checked) + iterator += 1 + self._block_signals = False + self._update_checked_set_from_tree() + + def deselect_all_chapters(self): + self._block_signals = True + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + item.setCheckState(0, Qt.CheckState.Unchecked) + iterator += 1 + self._block_signals = False + self._update_checked_set_from_tree() + + def select_parent_chapters(self): + self._block_signals = True + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable and item.childCount() > 0: + item.setCheckState(0, Qt.CheckState.Checked) + iterator += 1 + self._block_signals = False + self._update_checked_set_from_tree() + + def deselect_parent_chapters(self): + self._block_signals = True + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable and item.childCount() > 0: + item.setCheckState(0, Qt.CheckState.Unchecked) + iterator += 1 + self._block_signals = False + self._update_checked_set_from_tree() + + def auto_select_chapters(self): + self._run_auto_check() + + def _run_auto_check(self): + self._block_signals = True + + if self.parser.file_type == "epub": + self._run_epub_auto_check() + elif self.parser.file_type == "markdown": + self._run_markdown_auto_check() + else: + self._run_pdf_auto_check() + + self._block_signals = False + self._update_checked_set_from_tree() + + def _run_epub_auto_check(self): + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if not (item.flags() & Qt.ItemFlag.ItemIsUserCheckable): + iterator += 1 + continue + + src = item.data(0, Qt.ItemDataRole.UserRole) + + has_significant_content = src and self.content_lengths.get(src, 0) > 1000 + is_parent = item.childCount() > 0 + + if has_significant_content or is_parent: + item.setCheckState(0, Qt.CheckState.Checked) + if is_parent: + for i in range(item.childCount()): + child = item.child(i) + if child.flags() & Qt.ItemFlag.ItemIsUserCheckable: + child_src = child.data(0, Qt.ItemDataRole.UserRole) + child_has_content = ( + child_src and self.content_lengths.get(child_src, 0) > 0 + ) + child_is_parent = child.childCount() > 0 + if child_has_content or child_is_parent: + child.setCheckState(0, Qt.CheckState.Checked) + else: + item.setCheckState(0, Qt.CheckState.Unchecked) + + iterator += 1 + + def _run_markdown_auto_check(self): + """Auto-select markdown chapters with significant content""" + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if not (item.flags() & Qt.ItemFlag.ItemIsUserCheckable): + iterator += 1 + continue + + identifier = item.data(0, Qt.ItemDataRole.UserRole) + + # Select chapters with content > 500 characters or parent items + has_significant_content = ( + identifier and self.content_lengths.get(identifier, 0) > 500 + ) + is_parent = item.childCount() > 0 + + if has_significant_content or is_parent: + item.setCheckState(0, Qt.CheckState.Checked) + # Also check children if this is a parent + if is_parent: + for i in range(item.childCount()): + child = item.child(i) + if child.flags() & Qt.ItemFlag.ItemIsUserCheckable: + child_identifier = child.data(0, Qt.ItemDataRole.UserRole) + child_has_content = ( + child_identifier + and self.content_lengths.get(child_identifier, 0) > 0 + ) + child_is_parent = child.childCount() > 0 + if child_has_content or child_is_parent: + child.setCheckState(0, Qt.CheckState.Checked) + else: + item.setCheckState(0, Qt.CheckState.Unchecked) + + iterator += 1 + + def _run_pdf_auto_check(self): + if hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks: + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + item.setCheckState(0, Qt.CheckState.Checked) + iterator += 1 + return + + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if not (item.flags() & Qt.ItemFlag.ItemIsUserCheckable): + iterator += 1 + continue + + identifier = item.data(0, Qt.ItemDataRole.UserRole) + + if not identifier: + iterator += 1 + continue + + if ( + not identifier.startswith("page_") + or self.content_lengths.get(identifier, 0) > 0 + ): + item.setCheckState(0, Qt.CheckState.Checked) + + iterator += 1 + + def _update_checked_set_from_tree(self): + self.checked_chapters.clear() + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.checkState(0) == Qt.CheckState.Checked: + identifier = item.data(0, Qt.ItemDataRole.UserRole) + if identifier: + self.checked_chapters.add(identifier) + iterator += 1 + if hasattr(self, "save_chapters_checkbox") and self.save_chapters_checkbox: + self._update_checkbox_states() + + def handle_item_check(self, item): + if self._block_signals: + return + + self._block_signals = True + + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + for i in range(item.childCount()): + child = item.child(i) + if child.flags() & Qt.ItemFlag.ItemIsUserCheckable: + child.setCheckState(0, item.checkState(0)) + + self._block_signals = False + self._update_checked_set_from_tree() + + def handle_item_double_click(self, item, column=0): + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable and item.childCount() == 0: + rect = self.treeWidget.visualItemRect(item) + checkbox_width = 20 + + mouse_pos = self.treeWidget.mapFromGlobal(self.treeWidget.cursor().pos()) + + if mouse_pos.x() > rect.x() + checkbox_width: + new_state = ( + Qt.CheckState.Unchecked + if item.checkState(0) == Qt.CheckState.Checked + else Qt.CheckState.Checked + ) + item.setCheckState(0, new_state) + + def update_preview(self, current): + if not current: + self.previewEdit.clear() + return + + identifier = current.data(0, Qt.ItemDataRole.UserRole) + + if identifier == "info:bookinfo": + self._display_book_info() + return + + text = None + if self.parser.file_type == "epub": + text = self.content_texts.get(identifier) + else: + text = self.content_texts.get(identifier) + + if text is None: + title = current.text(0) + self.previewEdit.setPlainText( + f"{title}\n\n(No content available for this item)" + ) + elif not text.strip(): + title = current.text(0) + self.previewEdit.setPlainText(f"{title}\n\n(This item is empty)") + else: + # Apply clean_text to preview so replace_single_newlines setting is respected + cleaned_text = clean_text(text) + self.previewEdit.setPlainText(cleaned_text) + + def _display_book_info(self): + self.previewEdit.clear() + html_content = "" + + cover_image = self.book_metadata.get("cover_image") + if cover_image: + try: + image_data = base64.b64encode(cover_image).decode("utf-8") + + image_type = "jpeg" + if cover_image.startswith(b"\x89PNG"): + image_type = "png" + elif cover_image.startswith(b"GIF"): + image_type = "gif" + + html_content += ( + f"
    " + ) + html_content += ( + f"Error displaying cover image: {str(e)}

    " + + title = self.book_metadata.get("title") + if title: + html_content += ( + f"

    {title}

    " + ) + + authors = self.book_metadata.get("authors") + if authors: + authors_text = ", ".join(authors) + html_content += f"

    By {authors_text}

    " + + publisher = self.book_metadata.get("publisher") + pub_year = self.book_metadata.get("publication_year") + + if publisher or pub_year: + pub_info = [] + if publisher: + pub_info.append(f"Published by {publisher}") + if pub_year: + pub_info.append(f"Year: {pub_year}") + html_content += f"

    {' | '.join(pub_info)}

    " + + html_content += "
    " + + description = self.book_metadata.get("description") + if description: + # Use pre-compiled pattern for better performance + desc = _HTML_TAG_PATTERN.sub("", description) + html_content += f"

    Description:

    {desc}

    " + + if self.parser.file_type == "pdf": + # Access pdf_doc from parser if available + pdf_doc = getattr(self.parser, "pdf_doc", None) + page_count = len(pdf_doc) if pdf_doc else 0 + html_content += f"

    File type: PDF
    Page count: {page_count}

    " + + html_content += "" + self.previewEdit.setHtml(html_content) + + def _extract_book_metadata(self): + metadata = { + "title": None, + "authors": [], + "description": None, + "cover_image": None, + "publisher": None, + "publication_year": None, + } + + if self.parser.file_type == "epub": + try: + title_items = self.book.get_metadata("DC", "title") + if title_items and len(title_items) > 0: + metadata["title"] = title_items[0][0] + except Exception as e: + logging.warning(f"Error extracting title metadata: {e}") + + try: + author_items = self.book.get_metadata("DC", "creator") + if author_items: + metadata["authors"] = [ + author[0] for author in author_items if len(author) > 0 + ] + except Exception as e: + logging.warning(f"Error extracting author metadata: {e}") + + try: + desc_items = self.book.get_metadata("DC", "description") + if desc_items and len(desc_items) > 0: + metadata["description"] = desc_items[0][0] + except Exception as e: + logging.warning(f"Error extracting description metadata: {e}") + + try: + publisher_items = self.book.get_metadata("DC", "publisher") + if publisher_items and len(publisher_items) > 0: + metadata["publisher"] = publisher_items[0][0] + except Exception as e: + logging.warning(f"Error extracting publisher metadata: {e}") + + # Try to extract publication year + try: + date_items = self.book.get_metadata("DC", "date") + if date_items and len(date_items) > 0: + date_str = date_items[0][0] + # Try to extract just the year from the date string + year_match = re.search(r"\b(19|20)\d{2}\b", date_str) + if year_match: + metadata["publication_year"] = year_match.group(0) + else: + metadata["publication_year"] = date_str + except Exception as e: + logging.warning(f"Error extracting publication date metadata: {e}") + + for item in self.book.get_items_of_type(ebooklib.ITEM_COVER): + metadata["cover_image"] = item.get_content() + break + + if not metadata["cover_image"]: + for item in self.book.get_items_of_type(ebooklib.ITEM_IMAGE): + if "cover" in item.get_name().lower(): + metadata["cover_image"] = item.get_content() + break + elif self.parser.file_type == "markdown": + # Extract metadata from markdown frontmatter or first heading + if self.markdown_text: + # Try to extract YAML frontmatter + frontmatter_match = re.match( + r"^---\s*\n(.*?)\n---\s*\n", self.markdown_text, re.DOTALL + ) + if frontmatter_match: + try: + frontmatter = frontmatter_match.group(1) + # Simple YAML-like parsing for common fields + title_match = re.search( + r"^title:\s*(.+)$", + frontmatter, + re.MULTILINE | re.IGNORECASE, + ) + if title_match: + metadata["title"] = ( + title_match.group(1).strip().strip("\"'") + ) + + author_match = re.search( + r"^author:\s*(.+)$", + frontmatter, + re.MULTILINE | re.IGNORECASE, + ) + if author_match: + metadata["authors"] = [ + author_match.group(1).strip().strip("\"'") + ] + + desc_match = re.search( + r"^description:\s*(.+)$", + frontmatter, + re.MULTILINE | re.IGNORECASE, + ) + if desc_match: + metadata["description"] = ( + desc_match.group(1).strip().strip("\"'") + ) + + date_match = re.search( + r"^date:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE + ) + if date_match: + date_str = date_match.group(1).strip().strip("\"'") + year_match = re.search(r"\b(19|20)\d{2}\b", date_str) + if year_match: + metadata["publication_year"] = year_match.group(0) + except Exception as e: + logging.warning(f"Error parsing markdown frontmatter: {e}") + + # Fallback: use first H1 header as title if no frontmatter title + if not metadata["title"] and self.markdown_toc: + # Find the first level 1 header + first_h1 = next( + (h for h in self.markdown_toc if h["level"] == 1), None + ) + if first_h1: + metadata["title"] = first_h1["name"] + else: + pdf_info = self.pdf_doc.metadata + if pdf_info: + metadata["title"] = pdf_info.get("title", None) + + author = pdf_info.get("author", None) + if author: + metadata["authors"] = [author] + + metadata["description"] = pdf_info.get("subject", None) + + keywords = pdf_info.get("keywords", None) + if keywords: + if metadata["description"]: + metadata["description"] += f"\n\nKeywords: {keywords}" + else: + metadata["description"] = f"Keywords: {keywords}" + + metadata["publisher"] = pdf_info.get("creator", None) + + # Try to extract publication date from PDF metadata + if "creationDate" in pdf_info: + date_str = pdf_info["creationDate"] + year_match = re.search(r"D:(\d{4})", date_str) + if year_match: + metadata["publication_year"] = year_match.group(1) + elif "modDate" in pdf_info: + date_str = pdf_info["modDate"] + year_match = re.search(r"D:(\d{4})", date_str) + if year_match: + metadata["publication_year"] = year_match.group(1) + + if len(self.pdf_doc) > 0: + try: + pix = self.pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2)) + metadata["cover_image"] = pix.tobytes("png") + except Exception: + pass + + return metadata + + def get_selected_text(self): + # If a background loader thread is running, wait for it to finish to + # preserve compatibility with callers that expect content to be ready + # when they create a HandlerDialog and immediately request selected text. + try: + if ( + hasattr(self, "_loader_thread") + and getattr(self, "_loader_thread") is not None + ): + # Wait for thread to finish (blocks until done) + if self._loader_thread.isRunning(): + self._loader_thread.wait() + except Exception: + pass + + if self.parser.file_type == "epub": + return self._get_epub_selected_text() + elif self.parser.file_type == "markdown": + return self._get_markdown_selected_text() + else: + return self._get_pdf_selected_text() + + def _format_metadata_tags(self): + """Format metadata tags for insertion at the beginning of the text""" + import datetime + from abogen.utils import get_user_cache_path + + metadata = self.book_metadata + filename = os.path.splitext(os.path.basename(self.book_path))[0] + current_year = str(datetime.datetime.now().year) + + # Get values with fallbacks + title = metadata.get("title") or filename + authors = metadata.get("authors") or ["Unknown"] + authors_text = ", ".join(authors) + album_artist = authors_text or "Unknown" + year = ( + metadata.get("publication_year") or current_year + ) # Use publication year if available + + # Count chapters/pages + total_chapters = len(self.checked_chapters) + chapter_text = ( + f"{total_chapters} {'Chapters' if self.parser.file_type == 'epub' else 'Pages'}" + ) + + # Handle cover image + cover_tag = "" + if metadata.get("cover_image"): + try: + import uuid + + cache_dir = get_user_cache_path() + cover_path = os.path.join(cache_dir, f"cover_{uuid.uuid4()}.jpg") + cover_path = os.path.normpath(cover_path) + with open(cover_path, "wb") as f: + f.write(metadata["cover_image"]) + cover_tag = f"<>" + except Exception as e: + logging.warning(f"Failed to save cover image: {e}") + + # Format metadata tags + metadata_tags = [ + f"<>", + f"<>", + f"<>", + f"<>", + f"<>", + f"<>", + f"<>", + ] + + if cover_tag: + metadata_tags.append(cover_tag) + + return "\n".join(metadata_tags) + + def _get_markdown_selected_text(self): + """Get selected text from markdown chapters""" + all_checked_identifiers = set() + chapter_texts = [] + + # Add metadata tags at the beginning + metadata_tags = self._format_metadata_tags() + + item_order_counter = 0 + ordered_checked_items = [] + + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + item_order_counter += 1 + if item.checkState(0) == Qt.CheckState.Checked: + identifier = item.data(0, Qt.ItemDataRole.UserRole) + + if identifier and identifier != "info:bookinfo": + all_checked_identifiers.add(identifier) + ordered_checked_items.append((item_order_counter, item, identifier)) + iterator += 1 + + ordered_checked_items.sort(key=lambda x: x[0]) + + for order, item, identifier in ordered_checked_items: + text = self.content_texts.get(identifier) + if text and text.strip(): + title = item.text(0) + # Remove leading dashes from title using pre-compiled pattern + title = _LEADING_DASH_PATTERN.sub("", title).strip() + marker = f"<>" + chapter_texts.append(marker + "\n" + text) + + full_text = metadata_tags + "\n\n" + "\n\n".join(chapter_texts) + return full_text, all_checked_identifiers + + def _get_epub_selected_text(self): + all_checked_identifiers = set() + chapter_texts = [] + + # Add metadata tags at the beginning + metadata_tags = self._format_metadata_tags() + + item_order_counter = 0 + ordered_checked_items = [] + + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + item_order_counter += 1 + if item.checkState(0) == Qt.CheckState.Checked: + identifier = item.data(0, Qt.ItemDataRole.UserRole) + if identifier and identifier != "info:bookinfo": + all_checked_identifiers.add(identifier) + ordered_checked_items.append((item_order_counter, item, identifier)) + iterator += 1 + + ordered_checked_items.sort(key=lambda x: x[0]) + + for order, item, identifier in ordered_checked_items: + text = self.content_texts.get(identifier) + if text and text.strip(): + title = item.text(0) + # Use pre-compiled pattern for better performance + title = _LEADING_DASH_PATTERN.sub("", title).strip() + marker = f"<>" + chapter_texts.append(marker + "\n" + text) + + full_text = metadata_tags + "\n\n" + "\n\n".join(chapter_texts) + return full_text, all_checked_identifiers + + def _get_pdf_selected_text(self): + all_checked_identifiers = set() + included_text_ids = set() + section_titles = [] + all_content = [] + + # Add metadata tags at the beginning + metadata_tags = self._format_metadata_tags() + + pdf_has_no_bookmarks = ( + hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks + ) + + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.checkState(0) == Qt.CheckState.Checked: + identifier = item.data(0, Qt.ItemDataRole.UserRole) + if identifier: + all_checked_identifiers.add(identifier) + iterator += 1 + + if pdf_has_no_bookmarks: + sorted_page_ids = sorted( + [id for id in all_checked_identifiers if id.startswith("page_")], + key=lambda x: int(x.split("_")[1]) if x.split("_")[1].isdigit() else 0, + ) + for page_id in sorted_page_ids: + if page_id not in included_text_ids: + text = self.content_texts.get(page_id, "") + if text: + all_content.append(text) + included_text_ids.add(page_id) + return ( + metadata_tags + "\n\n" + "\n\n".join(all_content), + all_checked_identifiers, + ) + + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.childCount() > 0: + parent_checked = item.checkState(0) == Qt.CheckState.Checked + parent_id = item.data(0, Qt.ItemDataRole.UserRole) + parent_title = item.text(0) + checked_children = [] + for i in range(item.childCount()): + child = item.child(i) + child_id = child.data(0, Qt.ItemDataRole.UserRole) + if ( + child.checkState(0) == Qt.CheckState.Checked + and child_id + and child_id not in included_text_ids + ): + checked_children.append((child, child_id)) + if parent_checked and parent_id and parent_id not in included_text_ids: + combined_text = self.content_texts.get(parent_id, "") + for child, child_id in checked_children: + child_text = self.content_texts.get(child_id, "") + if child_text: + combined_text += "\n\n" + child_text + included_text_ids.add(child_id) + if combined_text.strip(): + # Use pre-compiled pattern for better performance + title = _LEADING_SIMPLE_DASH_PATTERN.sub( + "", parent_title + ).strip() + marker = f"<>" + section_titles.append((title, marker + "\n" + combined_text)) + included_text_ids.add(parent_id) + elif not parent_checked and checked_children: + # Use pre-compiled pattern for better performance + title = _LEADING_SIMPLE_DASH_PATTERN.sub("", parent_title).strip() + marker = f"<>" + for idx, (child, child_id) in enumerate(checked_children): + text = self.content_texts.get(child_id, "") + if text: + if idx == 0: + section_titles.append((title, marker + "\n" + text)) + else: + section_titles.append((title, text)) + included_text_ids.add(child_id) + elif item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + identifier = item.data(0, Qt.ItemDataRole.UserRole) + if ( + identifier + and identifier not in included_text_ids + and item.checkState(0) == Qt.CheckState.Checked + ): + text = self.content_texts.get(identifier, "") + if text: + title = item.text(0) + # Use pre-compiled pattern for better performance + title = _LEADING_SIMPLE_DASH_PATTERN.sub("", title).strip() + marker = f"<>" + section_titles.append((title, marker + "\n" + text)) + included_text_ids.add(identifier) + iterator += 1 + + return ( + metadata_tags + "\n\n" + "\n\n".join([t[1] for t in section_titles]), + all_checked_identifiers, + ) + + def on_save_chapters_changed(self, state): + self.save_chapters_separately = bool(state) + self.merge_chapters_checkbox.setEnabled(self.save_chapters_separately) + HandlerDialog._save_chapters_separately = self.save_chapters_separately + + def on_merge_chapters_changed(self, state): + self.merge_chapters_at_end = bool(state) + HandlerDialog._merge_chapters_at_end = self.merge_chapters_at_end + + def on_save_as_project_changed(self, state): + self.save_as_project = bool(state) + HandlerDialog._save_as_project = self.save_as_project + + def get_save_chapters_separately(self): + return ( + self.save_chapters_separately + if self.save_chapters_checkbox.isEnabled() + else False + ) + + def get_merge_chapters_at_end(self): + return self.merge_chapters_at_end + + def get_save_as_project(self): + return self.save_as_project + + def check_selected_items(self): + self.set_selected_items_checked(True) + + def uncheck_selected_items(self): + self.set_selected_items_checked(False) + + def set_selected_items_checked(self, state: bool): + print(f"Checking selected items: {state}") + self.treeWidget.blockSignals(True) + for item in self.treeWidget.selectedItems(): + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + item.setCheckState( + 0, Qt.CheckState.Checked if state else Qt.CheckState.Unchecked + ) + self.treeWidget.blockSignals(False) + self._update_checked_set_from_tree() + + def on_tree_context_menu(self, pos): + item = self.treeWidget.itemAt(pos) + # multi-select context menu + if self.treeWidget.selectedItems() and len(self.treeWidget.selectedItems()) > 1: + menu = QMenu(self) + action = menu.addAction("Select") + action.triggered.connect(self.check_selected_items) + action = menu.addAction("Clear") + action.triggered.connect(self.uncheck_selected_items) + menu.exec(self.treeWidget.mapToGlobal(pos)) + return + + if ( + not item + or item.childCount() == 0 + or not (item.flags() & Qt.ItemFlag.ItemIsUserCheckable) + ): + return + + menu = QMenu(self) + checked = item.checkState(0) == Qt.CheckState.Checked + text = "Unselect only this" if checked else "Select only this" + action = menu.addAction(text) + + def do_toggle(): + self.treeWidget.blockSignals(True) + new_state = Qt.CheckState.Unchecked if checked else Qt.CheckState.Checked + item.setCheckState(0, new_state) + self.treeWidget.blockSignals(False) + self._update_checked_set_from_tree() + + action.triggered.connect(do_toggle) + menu.exec(self.treeWidget.mapToGlobal(pos)) + + diff --git a/abogen/pyqt/conversion.py b/abogen/pyqt/conversion.py new file mode 100644 index 0000000..2195b8a --- /dev/null +++ b/abogen/pyqt/conversion.py @@ -0,0 +1,2477 @@ +import os +import re +import time +import hashlib # For generating unique cache filenames +from platformdirs import user_desktop_dir +from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer +from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox +import soundfile as sf +from abogen.utils import ( + create_process, + get_user_cache_path, + detect_encoding, +) +from abogen.constants import ( + LANGUAGE_DESCRIPTIONS, + SAMPLE_VOICE_TEXTS, + COLORS, + CHAPTER_OPTIONS_COUNTDOWN, + SUBTITLE_FORMATS, + SUPPORTED_SOUND_FORMATS, + SUPPORTED_SUBTITLE_FORMATS, +) +from abogen.voice_formulas import get_new_voice +import abogen.hf_tracker as hf_tracker +import static_ffmpeg +import threading # for efficient waiting +import subprocess +import platform + +# Configuration constants +_USER_RESPONSE_TIMEOUT = ( + 0.1 # Timeout in seconds for checking user response/cancellation +) + +from abogen.subtitle_utils import ( + clean_text, + parse_srt_file, + parse_vtt_file, + detect_timestamps_in_text, + parse_timestamp_text_file, + parse_ass_file, + get_sample_voice_text, + sanitize_name_for_os, + _CHAPTER_MARKER_SEARCH_PATTERN, +) + +class CountdownDialog(QDialog): + """Base dialog with auto-accept countdown functionality""" + + def __init__(self, title, countdown_seconds, parent=None): + super().__init__(parent) + self.setWindowTitle(title) + self.setMinimumWidth(350) + self.setWindowFlags( + self.windowFlags() + & ~Qt.WindowType.WindowCloseButtonHint + & ~Qt.WindowType.WindowContextHelpButtonHint + ) + + self.countdown_seconds = countdown_seconds + self.layout = QVBoxLayout(self) + self._timer = None + self._button_box = None + + def add_countdown_and_buttons(self): + """Add countdown label and OK button - call this after adding custom content""" + self.countdown_label = QLabel( + f"Auto-accepting in {self.countdown_seconds} seconds..." + ) + self.countdown_label.setStyleSheet(f"color: {COLORS['GREEN']};") + self.layout.addWidget(self.countdown_label) + + self._button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) + self._button_box.accepted.connect(self.accept) + self.layout.addWidget(self._button_box) + + self._timer = QTimer(self) + self._timer.timeout.connect(self._on_timer_tick) + self._timer.start(1000) + + def _on_timer_tick(self): + self.countdown_seconds -= 1 + if self.countdown_seconds > 0: + self.countdown_label.setText( + f"Auto-accepting in {self.countdown_seconds} seconds..." + ) + else: + self._timer.stop() + self._button_box.accepted.emit() + + def closeEvent(self, event): + event.ignore() + + def keyPressEvent(self, event): + if event.key() == Qt.Key.Key_Escape: + event.ignore() + else: + super().keyPressEvent(event) + + +class ChapterOptionsDialog(CountdownDialog): + def __init__(self, chapter_count, parent=None): + super().__init__("Chapter Options", CHAPTER_OPTIONS_COUNTDOWN, parent) + + self.layout.addWidget( + QLabel(f"Detected {chapter_count} chapters in the text file.") + ) + self.layout.addWidget(QLabel("How would you like to process these chapters?")) + + self.save_separately_checkbox = QCheckBox("Save each chapter separately") + self.merge_at_end_checkbox = QCheckBox("Create a merged version at the end") + + self.save_separately_checkbox.setChecked(False) + self.merge_at_end_checkbox.setChecked(True) + + self.save_separately_checkbox.stateChanged.connect( + self.update_merge_checkbox_state + ) + + self.layout.addWidget(self.save_separately_checkbox) + self.layout.addWidget(self.merge_at_end_checkbox) + + self.add_countdown_and_buttons() + self.update_merge_checkbox_state() + + def update_merge_checkbox_state(self): + self.merge_at_end_checkbox.setEnabled(self.save_separately_checkbox.isChecked()) + + def get_options(self): + return { + "save_chapters_separately": self.save_separately_checkbox.isChecked(), + "merge_chapters_at_end": self.merge_at_end_checkbox.isChecked() + and self.merge_at_end_checkbox.isEnabled(), + } + + +class TimestampDetectionDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Timestamps Detected") + self.setMinimumWidth(350) + self.use_timestamps_result = True + self.countdown_seconds = CHAPTER_OPTIONS_COUNTDOWN + + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("This file contains timestamps in HH:MM:SS format.")) + layout.addWidget( + QLabel("Do you want to use these timestamps for precise audio timing?") + ) + + yes_label = QLabel( + "• Yes: Generate audio that matches each timestamp (subtitle mode will be ignored)" + ) + yes_label.setStyleSheet(f"color: {COLORS['BLUE_BORDER_HOVER']};") + layout.addWidget(yes_label) + + no_label = QLabel("• No: Ignore timestamps and process as regular text") + no_label.setStyleSheet(f"color: {COLORS['ORANGE']};") + layout.addWidget(no_label) + + # Countdown label + self.countdown_label = QLabel( + f"Auto-accepting in {self.countdown_seconds} seconds..." + ) + self.countdown_label.setStyleSheet(f"color: {COLORS['GREEN']};") + layout.addWidget(self.countdown_label) + + button_box = QDialogButtonBox() + yes_button = button_box.addButton("Yes", QDialogButtonBox.ButtonRole.AcceptRole) + no_button = button_box.addButton("No", QDialogButtonBox.ButtonRole.RejectRole) + + yes_button.clicked.connect(lambda: self._set_result(True)) + no_button.clicked.connect(lambda: self._set_result(False)) + + layout.addWidget(button_box) + + # Timer for countdown + self._timer = QTimer(self) + self._timer.timeout.connect(self._on_timer_tick) + self._timer.start(1000) + + def _on_timer_tick(self): + self.countdown_seconds -= 1 + if self.countdown_seconds > 0: + self.countdown_label.setText( + f"Auto-accepting in {self.countdown_seconds} seconds..." + ) + else: + self._timer.stop() + self._set_result(True) + + def _set_result(self, use_timestamps): + if self._timer: + self._timer.stop() + self.use_timestamps_result = use_timestamps + self.accept() + + def use_timestamps(self): + return self.use_timestamps_result + + +class ConversionThread(QThread): + progress_updated = pyqtSignal(int, str) # Add str for ETR + conversion_finished = pyqtSignal(object, object) # Pass output path as second arg + log_updated = pyqtSignal(object) # Updated signal for log updates + chapters_detected = pyqtSignal(int) # Signal for chapter detection + + # Punctuation constants for unified handling across languages + PUNCTUATION_SENTENCE = ".!?।。!?" + PUNCTUATION_SENTENCE_COMMA = ".!?,।。!?、," + PUNCTUATION_COMMAS = ",,、" + + def _get_split_pattern(self, lang_code, subtitle_mode): + """ + Get the appropriate split pattern based on language and subtitle mode. + + Args: + lang_code: Language code (a, b, e, f, etc.) + subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.) + + Returns: + Split pattern string + """ + # For English, always use newline splitting only + if lang_code in ["a", "b"]: + return "\n" + + # Determine spacing pattern based on language + spacing_pattern = r"\s*" if lang_code in ["z", "j"] else r"\s+" + + # For Chinese/Japanese, when subtitle mode is Disabled or Line, prefer + # punctuation-based splitting instead of plain newline splitting. + if subtitle_mode in ("Disabled", "Line") and lang_code in ["z", "j"]: + return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE, spacing_pattern) + + if subtitle_mode == "Line": + return "\n" + elif subtitle_mode == "Sentence": + return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE, spacing_pattern) + elif subtitle_mode == "Sentence + Comma": + return r"(?<=[{}]){}|\n+".format( + self.PUNCTUATION_SENTENCE_COMMA, spacing_pattern + ) + else: + return r"\n+" # Default to line breaks + + def __init__( + self, + file_name, + lang_code, + speed, + voice, + save_option, + output_folder, + subtitle_mode, + output_format, + np_module, + kpipeline_class, + start_time, + total_char_count, + use_gpu=True, + from_queue=False, + save_base_path=None, + ): # Add use_gpu parameter + super().__init__() + self._chapter_options_event = threading.Event() + self._timestamp_response_event = threading.Event() + self.np = np_module + self.KPipeline = kpipeline_class + self.file_name = file_name + self.lang_code = lang_code + self.speed = speed + self.voice = voice + self.save_option = save_option + self.output_folder = output_folder + self.subtitle_mode = subtitle_mode + self.cancel_requested = False + self.should_cancel = False + self.process = None + self.output_format = output_format + self.from_queue = from_queue + self.start_time = start_time # Store start_time + self.total_char_count = total_char_count # Use passed total character count + self.processed_char_count = 0 # Initialize processed character count + self.display_path = None # Add variable for display path + self.save_base_path = save_base_path # Store the save base path + self.is_direct_text = ( + False # Flag to indicate if input is from textbox rather than file + ) + self.chapter_options_set = False + self.waiting_for_user_input = False + self.use_gpu = use_gpu # Store the GPU setting + self.max_subtitle_words = 50 # Default value, will be overridden from GUI + self.silence_duration = 2.0 # Default value, will be overridden from GUI + self.use_spacy_segmentation = True # Default, will be overridden from GUI + # Set split pattern based on language and subtitle mode + self.split_pattern = self._get_split_pattern(lang_code, subtitle_mode) + + def _stream_audio_in_chunks( + self, segments, process_func, progress_prefix="Processing" + ): + """ + Process audio segments in memory-efficient chunks + + Args: + segments: List of audio segments to process + process_func: Function that takes (segment_bytes, is_last) and processes a chunk + progress_prefix: Prefix for progress messages + + Returns: + Total samples processed + """ + # Calculate total size for progress reporting + total_samples = sum(len(segment) for segment in segments) + samples_processed = 0 + + self.log_updated.emit((f"\n{progress_prefix} segments...", "grey")) + + # Stream each segment individually + for i, segment in enumerate(segments): + try: + # Handle both NumPy arrays and PyTorch tensors + if hasattr(segment, "astype"): + segment_bytes = segment.astype("float32").tobytes() + else: + segment_bytes = segment.cpu().numpy().astype("float32").tobytes() + is_last = i == len(segments) - 1 + + # Update progress periodically - skip if there's only one segment + if (i % 20 == 0 or is_last) and len(segments) > 1: + progress_percent = int((samples_processed / total_samples) * 100) + self.log_updated.emit( + f"{progress_prefix} segment {i+1}/{len(segments)} ({progress_percent}% complete)" + ) + + # Process this segment + process_func(segment_bytes, is_last) + + # Update samples processed + samples_processed += len(segment) + + # Clear segment bytes from memory + del segment_bytes + except Exception as e: + self.log_updated.emit( + (f"Error processing segment {i}: {str(e)}", "red") + ) + raise + + return samples_processed + + def run(self): + print( + f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nFile: {self.file_name}\nSubtitle mode: {self.subtitle_mode}\nOutput format: {self.output_format}\nSave option: {self.save_option}\n" + ) + try: + hf_tracker.set_log_callback(lambda msg: self.log_updated.emit(msg)) + # Show configuration + self.log_updated.emit("Configuration:") + + # Determine input file and processing file + if getattr(self, "from_queue", False): + input_file = self.save_base_path or self.file_name + processing_file = self.file_name + else: + input_file = self.display_path if self.display_path else self.file_name + processing_file = self.file_name + + # Normalize paths for consistent display (fixes Windows path separator issues) + input_file = os.path.normpath(input_file) if input_file else input_file + processing_file = ( + os.path.normpath(processing_file) + if processing_file + else processing_file + ) + + self.log_updated.emit(f"- Input File: {input_file}") + if input_file != processing_file: + self.log_updated.emit(f"- Processing File: {processing_file}") + + # Use file_name for logs if from_queue, otherwise use display_path if available + if getattr(self, "from_queue", False): + base_path = ( + self.save_base_path or self.file_name + ) # Use save_base_path if available + else: + base_path = self.display_path if self.display_path else self.file_name + + # Use file size string passed from GUI + if hasattr(self, "file_size_str"): + self.log_updated.emit(f"- File size: {self.file_size_str}") + + self.log_updated.emit(f"- Total characters: {int(self.total_char_count):,}") + + self.log_updated.emit( + f"- Language: {self.lang_code} ({LANGUAGE_DESCRIPTIONS.get(self.lang_code, 'Unknown')})" + ) + self.log_updated.emit(f"- Voice: {self.voice}") + self.log_updated.emit(f"- Speed: {self.speed}") + self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}") + self.log_updated.emit(f"- Output format: {self.output_format}") + self.log_updated.emit( + f"- Subtitle format: {next((label for value, label in SUBTITLE_FORMATS if value == getattr(self, 'subtitle_format', 'srt')), getattr(self, 'subtitle_format', 'srt'))}" + ) + self.log_updated.emit( + f"- Use spaCy for sentence segmentation: {'Yes' if getattr(self, 'use_spacy_segmentation', False) else 'No'}" + ) + self.log_updated.emit(f"- Save option: {self.save_option}") + if self.replace_single_newlines: + self.log_updated.emit(f"- Replace single newlines: Yes") + + # Check if input is a subtitle file for additional configuration + is_subtitle_input = False + if not self.is_direct_text and self.file_name: + file_ext = os.path.splitext(self.file_name)[1].lower() + if file_ext in [".srt", ".ass", ".vtt"]: + is_subtitle_input = True + + # Display subtitle-specific options if processing subtitle file + if is_subtitle_input: + if getattr(self, "use_silent_gaps", False): + self.log_updated.emit("- Use silent gaps: Yes") + speed_method = getattr(self, "subtitle_speed_method", "tts") + method_label = ( + "TTS Regeneration" + if speed_method == "tts" + else "FFmpeg Time-stretch" + ) + self.log_updated.emit(f"- Speed adjustment method: {method_label}") + + # Display save_chapters_separately flag if it's set + if hasattr(self, "save_chapters_separately"): + self.log_updated.emit( + ( + f"- Save chapters separately: {'Yes' if self.save_chapters_separately else 'No'}" + ) + ) + # Display merge_chapters_at_end flag if save_chapters_separately is True + if self.save_chapters_separately: + merge_at_end = getattr(self, "merge_chapters_at_end", True) + self.log_updated.emit( + f"- Merge chapters at the end: {'Yes' if merge_at_end else 'No'}" + ) + # Display the separate chapters format if it's set + separate_format = getattr(self, "separate_chapters_format", "wav") + self.log_updated.emit( + f"- Separate chapters format: {separate_format}" + ) + + # If merge_at_end is True, display the silence duration + if getattr(self, "merge_chapters_at_end", True): + self.log_updated.emit( + f"- Silence between chapters: {self.silence_duration} seconds" + ) + + if self.save_option == "Choose output folder": + self.log_updated.emit( + f"- Output folder: {self.output_folder or os.getcwd()}" + ) + + self.log_updated.emit(("\nInitializing TTS pipeline...", "grey")) + + # Set device based on use_gpu setting and platform + if self.use_gpu: + if platform.system() == "Darwin" and platform.processor() == "arm": + device = "mps" # Use MPS for Apple Silicon + else: + device = "cuda" # Use CUDA for other platforms + else: + device = "cpu" + + tts = self.KPipeline( + lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device + ) + + # Check if the input is a subtitle file or timestamp text file + is_subtitle_file = False + is_timestamp_text = False + if not self.is_direct_text and self.file_name: + file_ext = os.path.splitext(self.file_name)[1].lower() + if file_ext in [".srt", ".ass", ".vtt"]: + is_subtitle_file = True + self.log_updated.emit( + f"\nDetected subtitle file format: {file_ext}" + ) + elif file_ext == ".txt" and detect_timestamps_in_text(self.file_name): + is_timestamp_text = True + self.log_updated.emit( + ("\nDetected timestamps in text file", "grey") + ) + # Signal to ask user (-1 indicates timestamp detection) + self.chapters_detected.emit(-1) + # Wait for user response using event with timeout for responsive cancellation + while not self._timestamp_response_event.wait( + timeout=_USER_RESPONSE_TIMEOUT + ): + if self.cancel_requested: + self.conversion_finished.emit("Cancelled", None) + return + # Check cancellation one more time after event is set + if self.cancel_requested: + self.conversion_finished.emit("Cancelled", None) + return + if not self._timestamp_response: + is_timestamp_text = False + delattr(self, "_timestamp_response") + self._timestamp_response_event.clear() + + # Process subtitle files separately + if is_subtitle_file or is_timestamp_text: + self._process_subtitle_file(tts, base_path, is_timestamp_text) + return + + if self.is_direct_text: + text = self.file_name # Treat file_name as direct text input + else: + encoding = detect_encoding(self.file_name) + with open( + self.file_name, "r", encoding=encoding, errors="replace" + ) as file: + text = file.read() + + # Clean up text using utility function + text = clean_text(text) + + + # --- Chapter splitting logic --- + # Use pre-compiled pattern for better performance + chapter_splits = list(_CHAPTER_MARKER_SEARCH_PATTERN.finditer(text)) + chapters = [] + if chapter_splits: + # prepend Introduction for content before first marker + first_start = chapter_splits[0].start() + if first_start > 0: + intro_text = text[:first_start].strip() + if intro_text: + chapters.append(("Introduction", intro_text)) + for idx, match in enumerate(chapter_splits): + start = match.end() + end = ( + chapter_splits[idx + 1].start() + if idx + 1 < len(chapter_splits) + else len(text) + ) + chapter_name = match.group(1).strip() + chapter_text = text[start:end].strip() + chapters.append((chapter_name, chapter_text)) + else: + chapters = [("text", text)] + total_chapters = len(chapters) + + # For text files with chapters, prompt user for options if not already set + is_txt_file = not self.is_direct_text and ( + self.file_name.lower().endswith(".txt") + or (self.display_path and self.display_path.lower().endswith(".txt")) + ) + + if ( + is_txt_file + and total_chapters > 1 + and ( + not hasattr(self, "save_chapters_separately") + or not hasattr(self, "merge_chapters_at_end") + ) + and not self.chapter_options_set + ): + + # Emit signal to main thread and wait + self.chapters_detected.emit(total_chapters) + self._chapter_options_event.wait() + if self.cancel_requested: + self.conversion_finished.emit("Cancelled", None) + return + self.chapter_options_set = True + + # Log all detected chapters at the beginning + if total_chapters > 1: + chapter_list = "\n".join( + [f"{i+1}) {c[0]}" for i, c in enumerate(chapters)] + ) + self.log_updated.emit( + (f"\nDetected chapters ({total_chapters}):\n" + chapter_list) + ) + else: + self.log_updated.emit((f"\nProcessing {chapters[0][0]}...", "grey")) + + # If save_chapters_separately is enabled, find a unique suffix ONCE and use for both folder and merged file + save_chapters_separately = getattr(self, "save_chapters_separately", False) + merge_chapters_at_end = getattr(self, "merge_chapters_at_end", True) + + # Ensure merge_chapters_at_end is True if not saving chapters separately + if not save_chapters_separately: + merge_chapters_at_end = True + + chapters_out_dir = None + suffix = "" + + # Use file_name for logs if from_queue, otherwise use display_path if available + if getattr(self, "from_queue", False): + base_path = ( + self.save_base_path or self.file_name + ) # Use save_base_path if available + else: + base_path = self.display_path if self.display_path else self.file_name + + base_name = os.path.splitext(os.path.basename(base_path))[0] + # Sanitize base_name for folder/file creation based on OS + sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True) + + if self.save_option == "Save to Desktop": + parent_dir = user_desktop_dir() + elif self.save_option == "Save next to input file": + parent_dir = os.path.dirname(base_path) + else: + parent_dir = self.output_folder or os.getcwd() + # Ensure the output folder exists, error if it doesn't + if not os.path.exists(parent_dir): + self.log_updated.emit( + ( + f"Output folder does not exist: {parent_dir}", + "red", + ) + ) + # Find a unique suffix for both folder and merged file, always + counter = 1 + allowed_exts = set(SUPPORTED_SOUND_FORMATS + SUPPORTED_SUBTITLE_FORMATS) + while True: + suffix = f"_{counter}" if counter > 1 else "" + chapters_out_dir_candidate = os.path.join( + parent_dir, f"{sanitized_base_name}{suffix}_chapters" + ) + # Only check for files with allowed extensions (extension without dot, case-insensitive) + # Use generator expression to avoid processing all files upfront + file_parts = ( + os.path.splitext(fname) for fname in os.listdir(parent_dir) + ) + clash = any( + name == f"{sanitized_base_name}{suffix}" + and ext[1:].lower() in allowed_exts + for name, ext in file_parts + ) + if not os.path.exists(chapters_out_dir_candidate) and not clash: + break + counter += 1 + if save_chapters_separately and total_chapters > 1: + separate_chapters_format = getattr( + self, "separate_chapters_format", "wav" + ) + chapters_out_dir = chapters_out_dir_candidate + os.makedirs(chapters_out_dir, exist_ok=True) + self.log_updated.emit( + (f"\nChapters output folder: {chapters_out_dir}", "grey") + ) + + # Prepare merged output file for incremental writing ONLY if merge_chapters_at_end is True + if merge_chapters_at_end: + out_dir = parent_dir + base_filepath_no_ext = os.path.join( + out_dir, f"{sanitized_base_name}{suffix}" + ) + merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" + subtitle_entries = [] + current_time = 0.0 + rate = 24000 + subtitle_mode = self.subtitle_mode + self.etr_start_time = time.time() + self.processed_char_count = 0 + current_segment = 0 + chapters_time = [ + {"chapter": chapter[0], "start": 0.0, "end": 0.0} + for chapter in chapters + ] + # SRT numbering fix: use a global counter + merged_srt_index = 1 # SRT numbering for merged file + # Prepare output file/ffmpeg process for merged output + if self.output_format in ["wav", "mp3", "flac"]: + merged_out_file = sf.SoundFile( + merged_out_path, + "w", + samplerate=24000, + channels=1, + format=self.output_format, + ) + ffmpeg_proc = None + elif self.output_format == "m4b": + # Real-time M4B generation using FFmpeg pipe + static_ffmpeg.add_paths() + merged_out_file = None + ffmpeg_proc = None + metadata_options, cover_path = ( + self._extract_and_add_metadata_tags_to_ffmpeg_cmd() + ) + # Prepare ffmpeg command for m4b output + cmd = [ + "ffmpeg", + "-y", + "-thread_queue_size", + "32768", + "-f", + "f32le", + "-ar", + "24000", + "-ac", + "1", + "-i", + "pipe:0", + ] + if cover_path and os.path.exists(cover_path): + cmd.extend( + [ + "-i", + cover_path, + "-map", + "0:a", + "-map", + "1", + "-c:v", + "copy", + "-disposition:v", + "attached_pic", + ] + ) + cmd.extend( + [ + "-c:a", + "aac", + "-q:a", + "2", + "-movflags", + "+faststart+use_metadata_tags", + ] + ) + cmd += metadata_options + cmd.append(merged_out_path) + ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) + elif self.output_format == "opus": + static_ffmpeg.add_paths() + cmd = [ + "ffmpeg", + "-y", + "-thread_queue_size", + "32768", + "-f", + "f32le", + "-ar", + "24000", + "-ac", + "1", + "-i", + "pipe:0", + ] + cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) + cmd.append(merged_out_path) + ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) + merged_out_file = None + else: + self.log_updated.emit( + (f"Unsupported output format: {self.output_format}", "red") + ) + self.conversion_finished.emit( + ("Audio generation failed.", "red"), None + ) + return + # Open merged subtitle file for incremental writing if needed + merged_subtitle_file = None + if self.subtitle_mode != "Disabled": + subtitle_format = getattr(self, "subtitle_format", "srt") + file_extension = "ass" if "ass" in subtitle_format else "srt" + merged_subtitle_path = ( + os.path.splitext(merged_out_path)[0] + f".{file_extension}" + ) + # Default subtitle layout flags/strings so they exist regardless + # of whether ASS-specific handling runs. This prevents runtime + # errors when non-ASS formats (like SRT) are selected. + is_centered = False + is_narrow = False + merged_subtitle_margin = "" + merged_subtitle_alignment_tag = "" + if "ass" in subtitle_format: + merged_subtitle_file = open( + merged_subtitle_path, + "w", + encoding="utf-8", + errors="replace", + ) + # Minimal ASS header + merged_subtitle_file.write("[Script Info]\n") + merged_subtitle_file.write("Title: Generated by Abogen\n") + merged_subtitle_file.write("ScriptType: v4.00+\n\n") + # Add style definitions for karaoke highlighting + if self.subtitle_mode == "Sentence + Highlighting": + merged_subtitle_file.write("[V4+ Styles]\n") + merged_subtitle_file.write( + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" + ) + merged_subtitle_file.write( + "Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n" + ) + merged_subtitle_file.write("[Events]\n") + merged_subtitle_file.write( + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" + ) + # Set margin/alignment for ASS + is_centered = subtitle_format in ( + "ass_centered_wide", + "ass_centered_narrow", + ) + is_narrow = subtitle_format in ( + "ass_narrow", + "ass_centered_narrow", + ) + merged_subtitle_margin = "90" if is_narrow else "" + merged_subtitle_alignment_tag = ( + f"{{\\an5}}" if is_centered else "" + ) + else: + merged_subtitle_file = open( + merged_subtitle_path, + "w", + encoding="utf-8", + errors="replace", + ) + else: + merged_subtitle_path = None + merged_subtitle_file = None + else: + # If not merging, set merged_out_file and related variables to None + merged_out_file = None + ffmpeg_proc = None + merged_out_path = None + subtitle_entries = [] + current_time = 0.0 + rate = 24000 + subtitle_mode = self.subtitle_mode + self.etr_start_time = time.time() + self.processed_char_count = 0 + current_segment = 0 + chapters_time = [ + {"chapter": chapter[0], "start": 0.0, "end": 0.0} + for chapter in chapters + ] + srt_index = 1 # SRT numbering fix for chapter-only mode + # Instead of processing the whole text, process by chapter + for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1): + chapter_out_path = None + chapter_out_file = None + chapter_ffmpeg_proc = None + chapter_subtitle_file = None + chapter_subtitle_path = None + if total_chapters > 1: + self.log_updated.emit( + ( + f"\nChapter {chapter_idx}/{total_chapters}: {chapter_name}", + "blue", + ) + ) + chapter_subtitle_entries = [] + chapter_current_time = 0.0 + # Set chapter start time before processing + chapter_time = chapters_time[chapter_idx - 1] + if merge_chapters_at_end: + chapter_time["start"] = current_time + + # Check if the voice is a formula and load it if necessary + if "*" in self.voice: + loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) + else: + loaded_voice = self.voice + # Prepare per-chapter output file if needed + if save_chapters_separately and total_chapters > 1: + # First pass: keep alphanumeric, spaces, hyphens, and underscores + sanitized = re.sub(r"[^\w\s\-]", "", chapter_name) + # Replace multiple spaces/hyphens with single underscore + sanitized = re.sub(r"[\s\-]+", "_", sanitized).strip("_") + # Apply OS-specific sanitization + sanitized = sanitize_name_for_os(sanitized, is_folder=False) + # Limit length (leaving room for the chapter number prefix) + MAX_LEN = 80 + if len(sanitized) > MAX_LEN: + pos = sanitized[:MAX_LEN].rfind("_") + sanitized = sanitized[: pos if pos > 0 else MAX_LEN].rstrip("_") + chapter_filename = f"{chapter_idx:02d}_{sanitized}" + chapter_out_path = os.path.join( + chapters_out_dir, + f"{chapter_filename}.{separate_chapters_format}", + ) + if separate_chapters_format in ["wav", "mp3", "flac"]: + chapter_out_file = sf.SoundFile( + chapter_out_path, + "w", + samplerate=24000, + channels=1, + format=separate_chapters_format, + ) + chapter_ffmpeg_proc = None + elif separate_chapters_format == "opus": + static_ffmpeg.add_paths() + cmd = [ + "ffmpeg", + "-y", + "-thread_queue_size", + "32768", + "-f", + "f32le", + "-ar", + "24000", + "-ac", + "1", + "-i", + "pipe:0", + ] + cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) + cmd.append(chapter_out_path) + chapter_ffmpeg_proc = create_process( + cmd, stdin=subprocess.PIPE, text=False + ) + chapter_out_file = None + else: + self.log_updated.emit( + ( + f"Unsupported chapter format: {separate_chapters_format}", + "red", + ) + ) + continue + # Open chapter subtitle file for incremental writing if needed + chapter_subtitle_file = None + chapter_srt_index = ( + 1 # Initialize SRT numbering for this chapter file + ) + if self.subtitle_mode != "Disabled": + subtitle_format = getattr(self, "subtitle_format", "srt") + file_extension = "ass" if "ass" in subtitle_format else "srt" + chapter_subtitle_path = os.path.join( + chapters_out_dir, f"{chapter_filename}.{file_extension}" + ) + # Ensure these variables exist even when not using ASS so + # later code can safely reference them. + is_centered = False + is_narrow = False + chapter_subtitle_margin = "" + chapter_subtitle_alignment_tag = "" + # Open the chapter subtitle file for writing for both SRT and ASS + chapter_subtitle_file = open( + chapter_subtitle_path, + "w", + encoding="utf-8", + errors="replace", + ) + if "ass" in subtitle_format: + # Minimal ASS header + chapter_subtitle_file.write("[Script Info]\n") + chapter_subtitle_file.write("Title: Generated by Abogen\n") + chapter_subtitle_file.write("ScriptType: v4.00+\n\n") + + # Add style definitions for karaoke highlighting + if self.subtitle_mode == "Sentence + Highlighting": + chapter_subtitle_file.write("[V4+ Styles]\n") + chapter_subtitle_file.write( + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" + ) + chapter_subtitle_file.write( + "Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n" + ) + + chapter_subtitle_file.write("[Events]\n") + chapter_subtitle_file.write( + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" + ) + is_centered = subtitle_format in ( + "ass_centered_wide", + "ass_centered_narrow", + ) + is_narrow = subtitle_format in ( + "ass_narrow", + "ass_centered_narrow", + ) + chapter_subtitle_margin = "90" if is_narrow else "" + chapter_subtitle_alignment_tag = ( + f"{{\\an5}}" if is_centered else "" + ) + else: + chapter_subtitle_file = None + else: + chapter_subtitle_path = None + chapter_subtitle_file = None + + # Determine if spaCy segmentation should be used for PRE-TTS segmentation + # Only non-English languages use spaCy for pre-segmentation + # English uses spaCy only for subtitle generation (post-TTS) + # spaCy is disabled when subtitle mode is "Disabled" or "Line" + # spaCy is also disabled when input is a subtitle file + is_subtitle_input = ( + not self.is_direct_text + and self.file_name + and os.path.splitext(self.file_name)[1].lower() + in [".srt", ".ass", ".vtt"] + ) + use_spacy = ( + getattr(self, "use_spacy_segmentation", False) + and self.subtitle_mode not in ["Disabled", "Line"] + and not is_subtitle_input + ) + spacy_sentences = None + active_split_pattern = self.split_pattern + spacing_pattern = r"\s*" if self.lang_code in ["z", "j"] else r"\s+" + + # Pre-load spaCy model for English if it will be needed for subtitle generation + if ( + use_spacy + and self.lang_code in ["a", "b"] + and self.subtitle_mode in ["Sentence", "Sentence + Comma"] + ): + from abogen.spacy_utils import get_spacy_model + + nlp = get_spacy_model( + self.lang_code, + log_callback=lambda msg: self.log_updated.emit(msg), + ) + if nlp: + self.log_updated.emit( + ( + "\nUsing spaCy for sentence segmentation (only for subtitles)...", + "grey", + ) + ) + + if use_spacy and self.lang_code not in ["a", "b"]: + # Non-English: use spaCy for pre-TTS segmentation + self.log_updated.emit( + ("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey") + ) + from abogen.spacy_utils import segment_sentences + + spacy_sentences = segment_sentences( + chapter_text, + self.lang_code, + log_callback=lambda msg: self.log_updated.emit(msg), + ) + if spacy_sentences: + self.log_updated.emit( + ( + f"\nspaCy: Text segmented into {len(spacy_sentences)} sentences...", + "grey", + ) + ) + # For Sentence + Comma mode, still split on commas within spaCy sentences + if self.subtitle_mode == "Sentence + Comma": + active_split_pattern = r"(?<=[{}]){}|\n+".format( + self.PUNCTUATION_COMMAS, spacing_pattern + ) + else: + active_split_pattern = ( + "\n" # Use newline splitting for Sentence mode + ) + else: + self.log_updated.emit( + ("\nspaCy: Fallback to default segmentation...", "grey") + ) + + # Process text - either as spaCy sentences or as single text + text_segments = spacy_sentences if spacy_sentences else [chapter_text] + + # Print active split pattern used by the TTS engine once for this batch + try: + print(f"Using split pattern: {active_split_pattern!r}") + except Exception: + # Print must never break processing + print("Using split pattern: (unprintable)") + + for text_segment in text_segments: + for result in tts( + text_segment, + voice=loaded_voice, + speed=self.speed, + split_pattern=active_split_pattern, + ): + # Print the result for debugging + # print(f"Result: {result}") + if self.cancel_requested: + if chapter_out_file: + chapter_out_file.close() + if merged_out_file: + merged_out_file.close() + self.conversion_finished.emit("Cancelled", None) + return + current_segment += 1 + grapheme_len = len(result.graphemes) + self.processed_char_count += grapheme_len + # Log progress with both character counts and the graphemes content + self.log_updated.emit( + f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}" + ) + + chunk_dur = len(result.audio) / rate + chunk_start = current_time + # Write audio directly to merged file ONLY if merging + if merge_chapters_at_end and merged_out_file: + merged_out_file.write(result.audio) + elif merge_chapters_at_end and ffmpeg_proc: + if hasattr(result.audio, "numpy"): + audio_bytes = ( + result.audio.numpy().astype("float32").tobytes() + ) + else: + audio_bytes = result.audio.astype("float32").tobytes() + ffmpeg_proc.stdin.write(audio_bytes) + if chapter_out_file: + chapter_out_file.write(result.audio) + elif chapter_ffmpeg_proc: + if hasattr(result.audio, "numpy"): + audio_bytes = ( + result.audio.numpy().astype("float32").tobytes() + ) + else: + audio_bytes = result.audio.astype("float32").tobytes() + chapter_ffmpeg_proc.stdin.write(audio_bytes) + # Subtitle logic + if self.subtitle_mode != "Disabled": + tokens_list = getattr(result, "tokens", []) + + # Fallback for languages without token support (non-English) + # Create a single token representing the entire segment duration + if not tokens_list and result.graphemes: + + class FakeToken: + def __init__(self, text, start, end): + self.text = text + self.start_ts = start + self.end_ts = end + self.whitespace = "" + + tokens_list = [ + FakeToken(result.graphemes, 0, chunk_dur) + ] + + tokens_with_timestamps = [] + chapter_tokens_with_timestamps = [] + + # Process every token, regardless of text or timestamps + for tok in tokens_list: + tokens_with_timestamps.append( + { + "start": chunk_start + (tok.start_ts or 0), + "end": chunk_start + (tok.end_ts or 0), + "text": tok.text, + "whitespace": tok.whitespace, + } + ) + if chapter_out_file or chapter_ffmpeg_proc: + chapter_tokens_with_timestamps.append( + { + "start": chapter_current_time + + (tok.start_ts or 0), + "end": chapter_current_time + + (tok.end_ts or 0), + "text": tok.text, + "whitespace": tok.whitespace, + } + ) + # Process tokens according to subtitle mode + # Global subtitle processing ONLY if merging + if merge_chapters_at_end: + # Incremental subtitle writing for merged output + new_entries = [] + self._process_subtitle_tokens( + tokens_with_timestamps, + new_entries, + self.max_subtitle_words, + fallback_end_time=chunk_start + chunk_dur, + ) + if merged_subtitle_file: + subtitle_format = getattr( + self, "subtitle_format", "srt" + ) + if "ass" in subtitle_format: + for start, end, text in new_entries: + start_time = self._ass_time(start) + end_time = self._ass_time(end) + # Use karaoke effect for highlighting mode + effect = ( + "karaoke" + if self.subtitle_mode + == "Sentence + Highlighting" + else "" + ) + merged_subtitle_file.write( + f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n" + ) + else: + for entry in new_entries: + start, end, text = entry + merged_subtitle_file.write( + f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" + ) + merged_srt_index += 1 + # Per-chapter subtitle processing for both file and ffmpeg_proc + if chapter_out_file or chapter_ffmpeg_proc: + new_chapter_entries = [] + self._process_subtitle_tokens( + chapter_tokens_with_timestamps, + new_chapter_entries, + self.max_subtitle_words, + fallback_end_time=chapter_current_time + chunk_dur, + ) + if chapter_subtitle_file: + subtitle_format = getattr( + self, "subtitle_format", "srt" + ) + if "ass" in subtitle_format: + for start, end, text in new_chapter_entries: + start_time = self._ass_time(start) + end_time = self._ass_time(end) + # Use karaoke effect for highlighting mode + effect = ( + "karaoke" + if self.subtitle_mode + == "Sentence + Highlighting" + else "" + ) + chapter_subtitle_file.write( + f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n" + ) + else: + for entry in new_chapter_entries: + start, end, text = entry + chapter_subtitle_file.write( + f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" + ) + chapter_srt_index += 1 + if merge_chapters_at_end: + current_time += chunk_dur + if chapter_out_file or chapter_ffmpeg_proc: + chapter_current_time += chunk_dur + else: + if chapter_out_file or chapter_ffmpeg_proc: + chapter_current_time += chunk_dur + # Calculate percentage based on characters processed + percent = min( + int( + self.processed_char_count / self.total_char_count * 100 + ), + 99, + ) + + # Calculate ETR based on characters processed + etr_str = "Processing..." + chars_done = self.processed_char_count + elapsed = time.time() - self.etr_start_time + + # Calculate ETR if enough data is available + if ( + chars_done > 0 and elapsed > 0.5 + ): # Check elapsed > 0.5 to avoid instability + avg_time_per_char = elapsed / chars_done + remaining = ( + self.total_char_count - self.processed_char_count + ) + if remaining > 0: + secs = avg_time_per_char * remaining + h = int(secs // 3600) + m = int((secs % 3600) // 60) + s = int(secs % 60) + etr_str = f"{h:02d}:{m:02d}:{s:02d}" + + # Update progress more frequently (after each result) + self.progress_updated.emit(percent, etr_str) + + # Add silence between chapters for merged output (except after the last chapter) + if merge_chapters_at_end and chapter_idx < total_chapters: + silence_samples = int( + self.silence_duration * 24000 + ) # Silence duration at 24,000 Hz + silence_audio = self.np.zeros(silence_samples, dtype="float32") + silence_bytes = silence_audio.tobytes() + + if merged_out_file: + merged_out_file.write(silence_audio) + elif ffmpeg_proc: + ffmpeg_proc.stdin.write(silence_bytes) + + # Update timing for the silence + current_time += self.silence_duration + if chapter_out_file or chapter_ffmpeg_proc: + chapter_current_time += self.silence_duration + + # Set chapter end time after processing + if merge_chapters_at_end: + chapter_time["end"] = current_time + # Finalize chapter file for ffmpeg formats + if chapter_out_file or chapter_ffmpeg_proc: + self.log_updated.emit(("\nProcessing chapter audio...", "grey")) + if chapter_ffmpeg_proc: + chapter_ffmpeg_proc.stdin.close() + chapter_ffmpeg_proc.wait() + if chapter_out_file: + chapter_out_file.close() + # Close chapter subtitle file if open + if chapter_subtitle_file: + chapter_subtitle_file.close() + if ( + save_chapters_separately + and total_chapters > 1 + and self.subtitle_mode != "Disabled" + and chapter_subtitle_path + ): + self.log_updated.emit( + ( + f"\nChapter {chapter_idx} saved to: {chapter_out_path}\n\nChapter subtitle saved to: {chapter_subtitle_path}", + "green", + ) + ) + elif chapter_out_path: + self.log_updated.emit( + ( + f"\nChapter {chapter_idx} saved to: {chapter_out_path}", + "green", + ) + ) + # Finalize merged output file ONLY if merging + if merge_chapters_at_end: + self.log_updated.emit(("\nFinalizing audio. Please wait...", "grey")) + if self.output_format in ["wav", "mp3", "flac"]: + merged_out_file.close() + elif self.output_format == "m4b": + ffmpeg_proc.stdin.close() + ffmpeg_proc.wait() + # Add chapters via fast post-processing + if total_chapters > 1: + chapters_info_path = f"{base_filepath_no_ext}_chapters.txt" + with open(chapters_info_path, "w", encoding="utf-8") as f: + f.write(";FFMETADATA1\n") + for chapter in chapters_time: + chapter_title = chapter["chapter"].replace("=", "\\=") + f.write(f"[CHAPTER]\n") + f.write(f"TIMEBASE=1/1000\n") + f.write(f"START={int(chapter['start']*1000)}\n") + f.write(f"END={int(chapter['end']*1000)}\n") + f.write(f"title={chapter_title}\n\n") + # Fast mux chapters into m4b (write to temp file, then replace original) + static_ffmpeg.add_paths() + orig_path = merged_out_path + root, ext = os.path.splitext(orig_path) + tmp_path = root + ".tmp" + ext + metadata_options, cover_path = ( + self._extract_and_add_metadata_tags_to_ffmpeg_cmd() + ) + cmd = [ + "ffmpeg", + "-y", + "-i", + orig_path, + "-i", + chapters_info_path, + ] + if cover_path and os.path.exists(cover_path): + cmd.extend( + [ + "-i", + cover_path, + "-map", + "0:a", + "-map", + "2", + "-c:v", + "copy", + "-disposition:v", + "attached_pic", + ] + ) + else: + cmd.extend(["-map", "0:a"]) + + cmd.extend( + [ + "-map_metadata", + "1", + "-map_chapters", + "1", + "-c:a", + "copy", + ] + ) + cmd += metadata_options + cmd.append(tmp_path) + proc = create_process(cmd) + proc.wait() + os.replace(tmp_path, orig_path) + os.remove(chapters_info_path) + elif self.output_format in ["opus"]: + ffmpeg_proc.stdin.close() + ffmpeg_proc.wait() + self.progress_updated.emit(100, "00:00:00") + # Close merged subtitle file if open + if merged_subtitle_file: + merged_subtitle_file.close() + # Subtitle and final message logic + if merge_chapters_at_end: + if self.subtitle_mode != "Disabled": + self.conversion_finished.emit( + ( + f"\nAudio saved to: {merged_out_path}\n\nSubtitle saved to: {merged_subtitle_path}", + "green", + ), + merged_out_path, + ) + else: + self.conversion_finished.emit( + (f"\nAudio saved to: {merged_out_path}", "green"), + merged_out_path, + ) + else: + # If not merging, report the folder that holds the chapter files + self.progress_updated.emit(100, "00:00:00") + chapters_dir = os.path.abspath(chapters_out_dir or parent_dir) + self.conversion_finished.emit( + (f"\nAll chapters saved to: {chapters_dir}", "green"), + chapters_dir, + ) + except Exception as e: + # Cleanup ffmpeg subprocesses on error + try: + if "ffmpeg_proc" in locals() and ffmpeg_proc: + ffmpeg_proc.stdin.close() + ffmpeg_proc.terminate() + ffmpeg_proc.wait() + except Exception: + pass + try: + if "chapter_ffmpeg_proc" in locals() and chapter_ffmpeg_proc: + chapter_ffmpeg_proc.stdin.close() + chapter_ffmpeg_proc.terminate() + chapter_ffmpeg_proc.wait() + except Exception: + pass + self.log_updated.emit((f"Error occurred: {str(e)}", "red")) + self.conversion_finished.emit(("Audio generation failed.", "red"), None) + + def _process_subtitle_file(self, tts, base_path, is_timestamp_text=False): + """Process subtitle files with precise timing and generate output subtitles.""" + try: + # Parse subtitle file + if is_timestamp_text: + subtitles = parse_timestamp_text_file(self.file_name) + else: + file_ext = os.path.splitext(self.file_name)[1].lower() + if file_ext == ".srt": + subtitles = parse_srt_file(self.file_name) + elif file_ext == ".vtt": + subtitles = parse_vtt_file(self.file_name) + else: + subtitles = parse_ass_file(self.file_name) + + if not subtitles: + self.log_updated.emit(("No valid subtitle entries found.", "red")) + self.conversion_finished.emit( + ("No subtitle entries to process.", "red"), None + ) + return + + self.log_updated.emit( + (f"\nFound {len(subtitles)} subtitle entries", "grey") + ) + + # Setup output paths + base_name = os.path.splitext(os.path.basename(base_path))[0] + sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True) + parent_dir = ( + user_desktop_dir() + if self.save_option == "Save to Desktop" + else ( + os.path.dirname(base_path) + if self.save_option == "Save next to input file" + else self.output_folder or os.getcwd() + ) + ) + + if not os.path.exists(parent_dir): + self.log_updated.emit( + (f"Output folder does not exist: {parent_dir}", "red") + ) + return + + # Find unique filename + counter = 1 + allowed_exts = set(SUPPORTED_SOUND_FORMATS + SUPPORTED_SUBTITLE_FORMATS) + while True: + suffix = f"_{counter}" if counter > 1 else "" + # Use generator expression to avoid processing all files upfront + file_parts = (os.path.splitext(f) for f in os.listdir(parent_dir)) + if not any( + name == f"{sanitized_base_name}{suffix}" + and ext[1:].lower() in allowed_exts + for name, ext in file_parts + ): + break + counter += 1 + + base_filepath_no_ext = os.path.join( + parent_dir, f"{sanitized_base_name}{suffix}" + ) + merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" + rate = 24000 + + # Setup audio output + merged_out_file, ffmpeg_proc = None, None + if self.output_format in ["wav", "mp3", "flac"]: + merged_out_file = sf.SoundFile( + merged_out_path, + "w", + samplerate=rate, + channels=1, + format=self.output_format, + ) + else: + static_ffmpeg.add_paths() + cmd = [ + "ffmpeg", + "-y", + "-thread_queue_size", + "32768", + "-f", + "f32le", + "-ar", + str(rate), + "-ac", + "1", + "-i", + "pipe:0", + ] + if self.output_format == "m4b": + metadata_options, cover_path = ( + self._extract_and_add_metadata_tags_to_ffmpeg_cmd() + ) + if cover_path and os.path.exists(cover_path): + cmd.extend( + [ + "-i", + cover_path, + "-map", + "0:a", + "-map", + "1", + "-c:v", + "copy", + "-disposition:v", + "attached_pic", + ] + ) + cmd.extend( + [ + "-c:a", + "aac", + "-q:a", + "2", + "-movflags", + "+faststart+use_metadata_tags", + ] + ) + cmd.extend(metadata_options) + elif self.output_format == "opus": + cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) + else: + self.log_updated.emit( + (f"Unsupported output format: {self.output_format}", "red") + ) + return + cmd.append(merged_out_path) + ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) + + # Always generate subtitles for subtitle input files + subtitle_file, subtitle_path = None, None + subtitle_format = getattr(self, "subtitle_format", "srt") + file_extension = "ass" if "ass" in subtitle_format else "srt" + subtitle_path = f"{base_filepath_no_ext}.{file_extension}" + subtitle_file = open(subtitle_path, "w", encoding="utf-8", errors="replace") + + if "ass" in subtitle_format: + # Write ASS header + subtitle_file.write( + "[Script Info]\nTitle: Generated by Abogen\nScriptType: v4.00+\n\n" + ) + if self.subtitle_mode == "Sentence + Highlighting": + subtitle_file.write( + "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" + ) + subtitle_file.write( + "Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n" + ) + subtitle_file.write( + "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" + ) + + is_narrow = subtitle_format in ("ass_narrow", "ass_centered_narrow") + is_centered = subtitle_format in ( + "ass_centered_wide", + "ass_centered_narrow", + ) + margin = "90" if is_narrow else "" + alignment = "{\\an5}" if is_centered else "" + + # Load voice + loaded_voice = ( + get_new_voice(tts, self.voice, self.use_gpu) + if "*" in self.voice + else self.voice + ) + + # Calculate initial audio buffer size from timed subtitles only + max_end_time = max( + (end for _, end, _ in subtitles if end is not None), default=0 + ) + audio_buffer = self.np.zeros( + int(max_end_time * rate) + rate, dtype="float32" + ) + + # Process each subtitle and mix into buffer + self.etr_start_time = time.time() + srt_index = 1 + + for idx, (start_time, end_time, text) in enumerate(subtitles, 1): + if self.cancel_requested: + if subtitle_file: + subtitle_file.close() + self.conversion_finished.emit("Cancelled", None) + return + + # Process text and timing + replace_nl = getattr(self, "replace_single_newlines", True) + processed_text = text.replace("\n", " ") if replace_nl else text + use_gaps = getattr(self, "use_silent_gaps", False) + next_start = ( + subtitles[idx][0] + if (use_gaps and idx < len(subtitles)) + else float("inf") + ) + subtitle_duration = None if end_time is None else end_time - start_time + + h1, m1, s1 = ( + int(start_time // 3600), + int(start_time % 3600 // 60), + int(start_time % 60), + ) + ms1 = int((start_time - int(start_time)) * 1000) + is_last = ( + is_timestamp_text + or (use_gaps and idx == len(subtitles)) + or end_time is None + ) + if is_last: + time_str = ( + f"{h1:02d}:{m1:02d}:{s1:02d}" + + (f",{ms1:03d}" if ms1 > 0 else "") + + " - AUTO" + ) + else: + h2, m2, s2 = ( + int(end_time // 3600), + int(end_time % 3600 // 60), + int(end_time % 60), + ) + ms2 = int((end_time - int(end_time)) * 1000) + time_str = ( + f"{h1:02d}:{m1:02d}:{s1:02d}" + + (f",{ms1:03d}" if ms1 > 0 else "") + + " - " + + f"{h2:02d}:{m2:02d}:{s2:02d}" + + (f",{ms2:03d}" if ms2 > 0 else "") + ) + self.log_updated.emit( + f"\n[{idx}/{len(subtitles)}] {time_str}: {processed_text}" + ) + + # Generate TTS audio + tts_results = [ + r + for r in tts( + processed_text, + voice=loaded_voice, + speed=self.speed, + split_pattern=None, + ) + if not self.cancel_requested + ] + audio_chunks = [r.audio for r in tts_results] + + if self.cancel_requested: + if subtitle_file: + subtitle_file.close() + self.conversion_finished.emit("Cancelled", None) + return + + # Concatenate audio and determine duration + full_audio = ( + self.np.concatenate( + [a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks] + ) + if audio_chunks + else self.np.zeros( + int((subtitle_duration or 0) * rate), dtype="float32" + ) + ) + audio_duration = len(full_audio) / rate + + # Use actual audio length for timing + if is_timestamp_text: + end_time = start_time + audio_duration + subtitle_duration = audio_duration + elif use_gaps: + end_time = min(start_time + audio_duration, next_start) + subtitle_duration = end_time - start_time + elif subtitle_duration is None: + subtitle_duration = audio_duration + end_time = start_time + audio_duration + + # Speed up if needed + speedup_threshold = ( + next_start - start_time if use_gaps else subtitle_duration + ) + if audio_duration > speedup_threshold: + speed_factor = audio_duration / speedup_threshold + + if getattr(self, "subtitle_speed_method", "tts") == "ffmpeg": + # FFmpeg time-stretch (faster processing) + self.log_updated.emit( + (f" -> FFmpeg time-stretch: {speed_factor:.2f}x", "grey") + ) + + static_ffmpeg.add_paths() + num_stages = max( + 1, + int( + self.np.ceil( + self.np.log(speed_factor) / self.np.log(2.0) + ) + ), + ) + tempo = speed_factor ** (1.0 / num_stages) + filter_str = ",".join([f"atempo={tempo:.6f}"] * num_stages) + + speed_proc = subprocess.Popen( + [ + "ffmpeg", + "-y", + "-f", + "f32le", + "-ar", + str(rate), + "-ac", + "1", + "-i", + "pipe:0", + "-filter:a", + filter_str, + "-f", + "f32le", + "-ar", + str(rate), + "-ac", + "1", + "pipe:1", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + full_audio = self.np.frombuffer( + speed_proc.communicate(input=full_audio.tobytes())[0], + dtype="float32", + ) + audio_duration = len(full_audio) / rate + else: + # TTS regeneration (better quality) + new_speed = self.speed * speed_factor + self.log_updated.emit( + (f" -> Regenerating at {new_speed:.2f}x speed", "grey") + ) + + tts_results = [ + r + for r in tts( + processed_text, + voice=loaded_voice, + speed=new_speed, + split_pattern=None, + ) + if not self.cancel_requested + ] + audio_chunks = [r.audio for r in tts_results] + + full_audio = ( + self.np.concatenate( + [ + a.numpy() if hasattr(a, "numpy") else a + for a in audio_chunks + ] + ) + if audio_chunks + else self.np.zeros( + int(subtitle_duration * rate), dtype="float32" + ) + ) + audio_duration = len(full_audio) / rate + + # Adjust duration after potential speed changes + if use_gaps: + end_time = min(start_time + audio_duration, next_start) + subtitle_duration = end_time - start_time + elif subtitle_duration is None: + subtitle_duration = audio_duration + end_time = start_time + audio_duration + + # Pad or trim to subtitle duration + target_samples = int(subtitle_duration * rate) + if len(full_audio) < target_samples: + full_audio = self.np.concatenate( + [ + full_audio, + self.np.zeros( + target_samples - len(full_audio), dtype="float32" + ), + ] + ) + elif len(full_audio) > target_samples: + full_audio = full_audio[:target_samples] + + # Mix audio into buffer at the correct position (handles overlaps) + start_sample = int(start_time * rate) + end_sample = start_sample + len(full_audio) + if end_sample > len(audio_buffer): + # Extend buffer if needed + audio_buffer = self.np.concatenate( + [ + audio_buffer, + self.np.zeros( + end_sample - len(audio_buffer), dtype="float32" + ), + ] + ) + + # Mix (add) the audio - this handles overlaps by combining them + audio_buffer[start_sample:end_sample] += full_audio + + # Write subtitle + if subtitle_file: + if "ass" in subtitle_format: + effect = ( + "karaoke" + if self.subtitle_mode == "Sentence + Highlighting" + else "" + ) + ass_text = ( + processed_text + if replace_nl + else processed_text.replace("\n", "\\N") + ) + subtitle_file.write( + f"Dialogue: 0,{self._ass_time(start_time)},{self._ass_time(end_time)},Default,,{margin},{margin},0,{effect},{alignment}{ass_text}\n" + ) + else: + subtitle_file.write( + f"{srt_index}\n{self._srt_time(start_time)} --> {self._srt_time(end_time)}\n{processed_text}\n\n" + ) + srt_index += 1 + + # Update progress + percent = min(int(idx / len(subtitles) * 100), 99) + elapsed = time.time() - self.etr_start_time + etr_str = ( + "Processing..." + if elapsed <= 0.5 + else f"{int(elapsed*(len(subtitles)-idx)/idx)//3600:02d}:{(int(elapsed*(len(subtitles)-idx)/idx)%3600)//60:02d}:{int(elapsed*(len(subtitles)-idx)/idx)%60:02d}" + ) + self.progress_updated.emit(percent, etr_str) + + # Normalize audio buffer to prevent clipping from mixed overlaps + max_amplitude = self.np.abs(audio_buffer).max() + if max_amplitude > 1.0: + self.log_updated.emit( + f"\n -> Normalizing audio (peak: {max_amplitude:.2f})" + ) + audio_buffer = audio_buffer / max_amplitude + + # Write the complete audio buffer + self.log_updated.emit(("\nFinalizing audio. Please wait...", "grey")) + if merged_out_file: + merged_out_file.write(audio_buffer) + merged_out_file.close() + elif ffmpeg_proc: + ffmpeg_proc.stdin.write(audio_buffer.astype("float32").tobytes()) + ffmpeg_proc.stdin.close() + ffmpeg_proc.wait() + + if subtitle_file: + subtitle_file.close() + + self.progress_updated.emit(100, "00:00:00") + result_msg = f"\nAudio saved to: {merged_out_path}" + ( + f"\n\nSubtitle saved to: {subtitle_path}" if subtitle_path else "" + ) + self.conversion_finished.emit((result_msg, "green"), merged_out_path) + + except Exception as e: + try: + if "ffmpeg_proc" in locals() and ffmpeg_proc: + ffmpeg_proc.stdin.close() + ffmpeg_proc.terminate() + ffmpeg_proc.wait() + if "subtitle_file" in locals() and subtitle_file: + subtitle_file.close() + except: + pass + self.log_updated.emit((f"Error processing subtitle file: {str(e)}", "red")) + self.conversion_finished.emit(("Audio generation failed.", "red"), None) + + def set_chapter_options(self, options): + """Set chapter options from the dialog and resume processing""" + self.save_chapters_separately = options["save_chapters_separately"] + self.merge_chapters_at_end = options["merge_chapters_at_end"] + self.waiting_for_user_input = False + self._chapter_options_event.set() + + def set_timestamp_response(self, treat_as_subtitle): + """Set whether to treat timestamp text file as subtitle.""" + self._timestamp_response = treat_as_subtitle + self._timestamp_response_event.set() + + def _extract_and_add_metadata_tags_to_ffmpeg_cmd(self): + """Extract metadata tags from text content and add them to ffmpeg command""" + metadata_options = [] + + # Get the input text (either direct or from file) + text = "" + if self.is_direct_text: + text = self.file_name + else: + try: + encoding = detect_encoding(self.file_name) + with open( + self.file_name, "r", encoding=encoding, errors="replace" + ) as file: + text = file.read() + except Exception as e: + self.log_updated.emit( + f"Warning: Could not read file for metadata extraction: {e}" + ) + return [] + + # Extract metadata tags using regex + title_match = re.search(r"<]*)>>", text) + artist_match = re.search(r"<]*)>>", text) + album_match = re.search(r"<]*)>>", text) + year_match = re.search(r"<]*)>>", text) + album_artist_match = re.search(r"<]*)>>", text) + composer_match = re.search(r"<]*)>>", text) + genre_match = re.search(r"<]*)>>", text) + cover_match = re.search(r"<]*)>>", text) + cover_path = cover_match.group(1) if cover_match else None + + # Use display path or filename as fallback for title + + # Use file_name for logs if from_queue, otherwise use display_path if available + if getattr(self, "from_queue", False): + filename = os.path.splitext(os.path.basename(self.file_name))[0] + else: + filename = os.path.splitext( + os.path.basename( + self.display_path if self.display_path else self.file_name + ) + )[0] + + if title_match: + metadata_options.extend(["-metadata", f"title={title_match.group(1)}"]) + else: + metadata_options.extend(["-metadata", f"title={filename}"]) + + # Add artist metadata + if artist_match: + metadata_options.extend(["-metadata", f"artist={artist_match.group(1)}"]) + else: + metadata_options.extend(["-metadata", f"artist=Unknown"]) + + # Add album metadata + if album_match: + metadata_options.extend(["-metadata", f"album={album_match.group(1)}"]) + else: + metadata_options.extend(["-metadata", f"album={filename}"]) + + # Add year metadata + if year_match: + metadata_options.extend(["-metadata", f"date={year_match.group(1)}"]) + else: + # Use current year if year is not specified + import datetime + + current_year = datetime.datetime.now().year + metadata_options.extend(["-metadata", f"date={current_year}"]) + + # Add album artist metadata + if album_artist_match: + metadata_options.extend( + ["-metadata", f"album_artist={album_artist_match.group(1)}"] + ) + else: + metadata_options.extend(["-metadata", f"album_artist=Unknown"]) + + # Add composer metadata + if composer_match: + metadata_options.extend( + ["-metadata", f"composer={composer_match.group(1)}"] + ) + else: + metadata_options.extend(["-metadata", f"composer=Narrator"]) + + # Add genre metadata + if genre_match: + metadata_options.extend(["-metadata", f"genre={genre_match.group(1)}"]) + else: + metadata_options.extend(["-metadata", f"genre=Audiobook"]) + + # Add these to ffmpeg command + return metadata_options, cover_path + + def _srt_time(self, t): + """Helper function to format time for SRT files""" + h = int(t // 3600) + m = int((t % 3600) // 60) + s = int(t % 60) + ms = int((t - int(t)) * 1000) + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + def _ass_time(self, t): + """Helper function to format time for ASS files""" + h = int(t // 3600) + m = int((t % 3600) // 60) + s = int(t % 60) + cs = int((t - int(t)) * 100) # Centiseconds for ASS format + return f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}" + + def _process_subtitle_tokens( + self, + tokens_with_timestamps, + subtitle_entries, + max_subtitle_words, + fallback_end_time=None, + ): + """Helper function to process subtitle tokens according to the subtitle mode""" + if not tokens_with_timestamps: + return + + processed_tokens = tokens_with_timestamps # Use tokens directly + + # For English with spaCy enabled and sentence-based modes, use spaCy for sentence boundaries + # spaCy is disabled when subtitle mode is "Disabled" or "Line" + use_spacy_for_english = ( + getattr(self, "use_spacy_segmentation", False) + and self.subtitle_mode not in ["Disabled", "Line"] + and self.lang_code in ["a", "b"] + and self.subtitle_mode in ["Sentence", "Sentence + Comma"] + ) + # Use processed_tokens instead of tokens_with_timestamps for the rest of the method + if self.subtitle_mode == "Sentence + Highlighting": + # Sentence-based processing with karaoke highlighting + # Use punctuation without comma + separator = r"[{}]".format(self.PUNCTUATION_SENTENCE) + current_sentence = [] + word_count = 0 + + for token in processed_tokens: # Updated to use processed_tokens + current_sentence.append(token) + word_count += 1 + + # Split sentences based on separator or word count + if ( + re.search(separator, token["text"]) and token["whitespace"] == " " + ) or word_count >= max_subtitle_words: + if current_sentence: + # Create karaoke subtitle entry for this sentence + start_time = current_sentence[0]["start"] + end_time = current_sentence[-1]["end"] + + # Generate karaoke text with background highlighting + karaoke_text = "" + for t in current_sentence: + # Calculate duration in centiseconds + duration = ( + t["end"] - t["start"] + if t["end"] and t["start"] + else 0.5 + ) + duration_cs = int(duration * 100) + # Add karaoke effect - relies on style's SecondaryColour for highlighting + karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}" + + subtitle_entries.append( + (start_time, end_time, karaoke_text.strip()) + ) + current_sentence = [] + word_count = 0 + + # Add any remaining tokens as a sentence + if current_sentence: + start_time = current_sentence[0]["start"] + end_time = current_sentence[-1]["end"] + + # Generate karaoke text for remaining tokens + karaoke_text = "" + for t in current_sentence: + duration = t["end"] - t["start"] if t["end"] and t["start"] else 0.5 + duration_cs = int(duration * 100) + karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}" + subtitle_entries.append((start_time, end_time, karaoke_text.strip())) + + # Fallback for last entry + if subtitle_entries and fallback_end_time is not None: + last_entry = subtitle_entries[-1] + start, end, text = last_entry + if end is None or end <= start or end <= 0: + subtitle_entries[-1] = (start, fallback_end_time, text) + + elif self.subtitle_mode in ["Sentence", "Sentence + Comma", "Line"]: + # Check if we should use spaCy for English sentence boundaries + if use_spacy_for_english and self.subtitle_mode != "Line": + # Use spaCy for English sentence boundary detection (model already loaded) + from abogen.spacy_utils import get_spacy_model + + nlp = get_spacy_model( + self.lang_code + ) # No log_callback since model is already loaded + if nlp: + # Build full text and track character positions to token indices + full_text = "" + char_to_token = [] # Maps character index to token index + for idx, token in enumerate(processed_tokens): + start_char = len(full_text) + text_part = token["text"] + (token.get("whitespace", "") or "") + full_text += text_part + char_to_token.extend([idx] * len(text_part)) + + # Get sentence boundaries from spaCy + doc = nlp(full_text) + sentence_boundaries = [sent.end_char for sent in doc.sents] + + # For "Sentence + Comma" mode, also split on commas + if self.subtitle_mode == "Sentence + Comma": + comma_positions = [ + i + 1 for i, c in enumerate(full_text) if c == "," + ] + sentence_boundaries = sorted( + set(sentence_boundaries + comma_positions) + ) + + # Group tokens by sentence boundaries + current_sentence = [] + word_count = 0 + current_char_pos = 0 + boundary_idx = 0 + + for idx, token in enumerate(processed_tokens): + current_sentence.append(token) + word_count += 1 + text_len = len(token["text"]) + len( + token.get("whitespace", "") or "" + ) + current_char_pos += text_len + + # Check if we've hit a sentence boundary or max words + at_boundary = ( + boundary_idx < len(sentence_boundaries) + and current_char_pos >= sentence_boundaries[boundary_idx] + ) + if at_boundary or word_count >= max_subtitle_words: + if current_sentence: + start_time = current_sentence[0]["start"] + end_time = current_sentence[-1]["end"] + sentence_text = "".join( + t["text"] + (t.get("whitespace", "") or "") + for t in current_sentence + ) + subtitle_entries.append( + (start_time, end_time, sentence_text.strip()) + ) + current_sentence = [] + word_count = 0 + if at_boundary: + boundary_idx += 1 + + # Add remaining tokens + if current_sentence: + start_time = current_sentence[0]["start"] + end_time = current_sentence[-1]["end"] + sentence_text = "".join( + t["text"] + (t.get("whitespace", "") or "") + for t in current_sentence + ) + subtitle_entries.append( + (start_time, end_time, sentence_text.strip()) + ) + + # Fallback for last entry + if subtitle_entries and fallback_end_time is not None: + last_entry = subtitle_entries[-1] + start, end, text = last_entry + if end is None or end <= start or end <= 0: + subtitle_entries[-1] = (start, fallback_end_time, text) + return # Exit early, spaCy processing complete + + # Default regex-based processing (non-English or spaCy unavailable) + # Define separator pattern based on mode + if self.subtitle_mode == "Line": + separator = r"\n" + elif self.subtitle_mode == "Sentence": + # Use punctuation without comma + separator = r"[{}]".format(self.PUNCTUATION_SENTENCE) + else: # Sentence + Comma + # Use punctuation with comma + separator = r"[{}]".format(self.PUNCTUATION_SENTENCE_COMMA) + current_sentence = [] + word_count = 0 + + for token in processed_tokens: # Updated to use processed_tokens + current_sentence.append(token) + word_count += 1 + + # Split sentences based on separator or word count + if ( + re.search(separator, token["text"]) and token["whitespace"] == " " + ) or word_count >= max_subtitle_words: + if current_sentence: + # Create subtitle entry for this sentence + start_time = current_sentence[0]["start"] + end_time = current_sentence[-1]["end"] + + # Simplified text joining logic + sentence_text = "" + for t in current_sentence: + sentence_text += t["text"] + (t.get("whitespace", "") or "") + + subtitle_entries.append( + (start_time, end_time, sentence_text.strip()) + ) + current_sentence = [] + word_count = 0 + + # Add any remaining tokens as a sentence + if current_sentence: + start_time = current_sentence[0]["start"] + end_time = current_sentence[-1]["end"] + + # Simplified text joining logic + sentence_text = "" + for t in current_sentence: + sentence_text += t["text"] + (t.get("whitespace", "") or "") + subtitle_entries.append((start_time, end_time, sentence_text.strip())) + + # Fallback for last entry + if subtitle_entries and fallback_end_time is not None: + last_entry = subtitle_entries[-1] + start, end, text = last_entry + if end is None or end <= start or end <= 0: + subtitle_entries[-1] = (start, fallback_end_time, text) + + else: + # Word count-based grouping - simply count spaces and split after N spaces + try: + word_count = int(self.subtitle_mode.split()[0]) + word_count = min(word_count, max_subtitle_words) + except (ValueError, IndexError): + word_count = 1 + + current_group = [] + space_count = 0 + + for token in processed_tokens: + current_group.append(token) + + # Count spaces after tokens (in the whitespace field) + if token.get("whitespace", "") == " ": + space_count += 1 + + # Split after counting N spaces + if space_count >= word_count: + text = "".join( + t["text"] + (t.get("whitespace", "") or "") + for t in current_group + ) + subtitle_entries.append( + ( + current_group[0]["start"], + current_group[-1]["end"], + text.strip(), + ) + ) + current_group = [] + space_count = 0 + + # Add any remaining tokens + if current_group: + text = "".join( + t["text"] + (t.get("whitespace", "") or "") for t in current_group + ) + subtitle_entries.append( + (current_group[0]["start"], current_group[-1]["end"], text.strip()) + ) + + # Fallback for last entry + if subtitle_entries and fallback_end_time is not None: + last_entry = subtitle_entries[-1] + start, end, text = last_entry + if end is None or end <= start or end <= 0: + subtitle_entries[-1] = (start, fallback_end_time, text) + + def cancel(self): + self.cancel_requested = True + self.should_cancel = True + self.waiting_for_user_input = False + # Terminate subprocess if running + if self.process: + try: + self.process.terminate() + except Exception: + pass + # Terminate ffmpeg subprocesses if running + try: + if hasattr(self, "ffmpeg_proc") and self.ffmpeg_proc: + self.ffmpeg_proc.stdin.close() + self.ffmpeg_proc.terminate() + self.ffmpeg_proc.wait() + except Exception: + pass + try: + if hasattr(self, "chapter_ffmpeg_proc") and self.chapter_ffmpeg_proc: + self.chapter_ffmpeg_proc.stdin.close() + self.chapter_ffmpeg_proc.terminate() + self.chapter_ffmpeg_proc.wait() + except Exception: + pass + + +class VoicePreviewThread(QThread): + finished = pyqtSignal() + error = pyqtSignal(str) + + def __init__( + self, + np_module, + kpipeline_class, + lang_code, + voice, + speed, + use_gpu=False, + parent=None, + ): + super().__init__(parent) + self.np_module = np_module + self.kpipeline_class = kpipeline_class + self.lang_code = lang_code + self.voice = voice + self.speed = speed + self.use_gpu = use_gpu + + # Cache location for preview audio + self.cache_dir = get_user_cache_path("preview_cache") + + # Calculate cache path + self.cache_path = self._get_cache_path() + + def _get_cache_path(self): + """Generate a unique filename for the voice with its parameters""" + # For a voice formula, use a hash of the formula + if "*" in self.voice: + voice_id = ( + f"voice_formula_{hashlib.md5(self.voice.encode()).hexdigest()[:8]}" + ) + else: + voice_id = self.voice + + # Create a unique filename based on voice_id, language, and speed + filename = f"{voice_id}_{self.lang_code}_{self.speed:.2f}.wav" + return os.path.join(self.cache_dir, filename) + + def run(self): + print( + f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n" + ) + + # Generate the preview and save to cache + try: + + # Set device based on use_gpu setting and platform + if self.use_gpu: + if platform.system() == "Darwin" and platform.processor() == "arm": + device = "mps" # Use MPS for Apple Silicon + else: + device = "cuda" # Use CUDA for other platforms + else: + device = "cpu" + + tts = self.kpipeline_class( + lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device + ) + # Enable voice formula support for preview + if "*" in self.voice: + loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) + else: + loaded_voice = self.voice + sample_text = get_sample_voice_text(self.lang_code) + audio_segments = [] + for result in tts( + sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None + ): + audio_segments.append(result.audio) + if audio_segments: + audio = self.np_module.concatenate(audio_segments) + # Save directly to the cache path + sf.write(self.cache_path, audio, 24000) + self.temp_wav = self.cache_path + self.finished.emit() + except Exception as e: + self.error.emit(f"Voice preview error: {str(e)}") + + +class PlayAudioThread(QThread): + finished = pyqtSignal() + error = pyqtSignal(str) + + def __init__(self, wav_path, parent=None): + super().__init__(parent) + self.wav_path = wav_path + self.is_canceled = False + + def run(self): + try: + import pygame + import time as _time + + pygame.mixer.init() + pygame.mixer.music.load(self.wav_path) + pygame.mixer.music.play() + # Wait until playback is finished or canceled + while pygame.mixer.music.get_busy() and not self.is_canceled: + _time.sleep(0.2) + + # Make sure to clean up regardless of how we exited the loop + try: + pygame.mixer.music.stop() + pygame.mixer.music.unload() + pygame.mixer.quit() # Quit the mixer + except Exception: + # Ignore any errors during cleanup + pass + + self.finished.emit() + except Exception as e: + # Handle initialization errors separately to give better error messages + if "mixer not initialized" in str(e): + self.error.emit( + "Audio playback error: The audio system was not properly initialized" + ) + else: + self.error.emit(f"Audio playback error: {str(e)}") + + def stop(self): + """Safely stop playback""" + self.is_canceled = True + # Try to stop pygame if it's running, but catch all exceptions + try: + import pygame + + if pygame.mixer.get_init(): + if pygame.mixer.music.get_busy(): + pygame.mixer.music.stop() + pygame.mixer.music.unload() + except Exception: + # Ignore all errors when stopping since mixer might not be initialized + pass diff --git a/abogen/pyqt/gui.py b/abogen/pyqt/gui.py new file mode 100644 index 0000000..2916645 --- /dev/null +++ b/abogen/pyqt/gui.py @@ -0,0 +1,4049 @@ +import os +import time +import sys +import tempfile +import platform +import base64 +import re +from abogen.pyqt.queue_manager_gui import QueueManager +from abogen.pyqt.queued_item import QueuedItem +import abogen.hf_tracker as hf_tracker +import hashlib # Added for cache path generation +from PyQt6.QtWidgets import ( + QApplication, + QWidget, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QComboBox, + QTextEdit, + QLabel, + QSlider, + QMessageBox, + QFileDialog, + QProgressBar, + QFrame, + QStyleFactory, + QInputDialog, + QFileIconProvider, + QSizePolicy, + QDialog, + QCheckBox, + QMenu, +) +from PyQt6.QtGui import QAction, QActionGroup +from PyQt6.QtCore import ( + Qt, + QUrl, + QPoint, + QFileInfo, + QThread, + pyqtSignal, + QObject, + QBuffer, + QIODevice, + QSize, + QTimer, + QEvent, + QProcess, +) +from PyQt6.QtGui import ( + QTextCursor, + QDesktopServices, + QIcon, + QPixmap, + QPainter, + QPolygon, + QColor, + QMovie, + QPalette, +) +from abogen.utils import ( + load_config, + save_config, + get_gpu_acceleration, + prevent_sleep_start, + prevent_sleep_end, + get_resource_path, + get_user_cache_path, + LoadPipelineThread, +) + +from abogen.subtitle_utils import ( + clean_text, + calculate_text_length, +) + +from abogen.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread +from abogen.pyqt.book_handler import HandlerDialog +from abogen.constants import ( + PROGRAM_NAME, + VERSION, + GITHUB_URL, + PROGRAM_DESCRIPTION, + LANGUAGE_DESCRIPTIONS, + VOICES_INTERNAL, + SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, + COLORS, + SUBTITLE_FORMATS, +) +import threading +from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog +from abogen.voice_profiles import load_profiles + +# Import ctypes for Windows-specific taskbar icon +if platform.system() == "Windows": + import ctypes + + +class DarkTitleBarEventFilter(QObject): + def __init__(self, is_windows, get_dark_mode_func, set_title_bar_dark_mode_func): + super().__init__() + self.is_windows = is_windows + self.get_dark_mode = get_dark_mode_func + self.set_title_bar_dark_mode = set_title_bar_dark_mode_func + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.Show: + # Only apply to QWidget windows + if isinstance(obj, QWidget) and obj.isWindow(): + if self.is_windows and self.get_dark_mode(): + self.set_title_bar_dark_mode(obj, True) + return super().eventFilter(obj, event) + + +class ShowWarningSignalEmitter(QObject): # New class to handle signal emission + show_warning_signal = pyqtSignal(str, str) + + def emit(self, title, message): + self.show_warning_signal.emit(title, message) + + +class ThreadSafeLogSignal(QObject): + log_signal = pyqtSignal(object) + + def __init__(self, parent=None): + super().__init__(parent) + + def emit_log(self, message): + self.log_signal.emit(message) + + +class IconProvider(QFileIconProvider): + def icon(self, fileInfo): + return super().icon(fileInfo) + + +LOG_COLOR_MAP = { + True: COLORS["GREEN"], + False: COLORS["RED"], + "red": COLORS["RED"], + "green": COLORS["GREEN"], + "orange": COLORS["ORANGE"], + "blue": COLORS["BLUE"], + "grey": COLORS["LIGHT_DISABLED"], + None: COLORS["LIGHT_DISABLED"], +} + + +class InputBox(QLabel): + # Define CSS styles as class constants + STYLE_DEFAULT = f"border:2px dashed #aaa; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG']}; min-height:100px;" + STYLE_DEFAULT_HOVER = f"background:{COLORS['BLUE_BG_HOVER']}; border-color:{COLORS['BLUE_BORDER_HOVER']};" + + STYLE_ACTIVE = f"border:2px dashed {COLORS['GREEN']}; border-radius:5px; padding:20px; background:{COLORS['GREEN_BG']}; min-height:100px;" + STYLE_ACTIVE_HOVER = ( + f"background:{COLORS['GREEN_BG_HOVER']}; border-color:{COLORS['GREEN_BORDER']};" + ) + + STYLE_ERROR = f"border:2px dashed {COLORS['RED']}; border-radius:5px; padding:20px; background:{COLORS['RED_BG']}; min-height:100px; color:{COLORS['RED']};" + STYLE_ERROR_HOVER = ( + f"background:{COLORS['RED_BG_HOVER']}; border-color:{COLORS['RED']};" + ) + + def __init__(self, parent=None): + super().__init__(parent) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setAcceptDrops(True) + self.setText( + "Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf, .md, .srt, .ass, .vtt)" + ) + self.setStyleSheet( + f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" + ) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + # Add clear button + self.clear_btn = QPushButton("✕", self) + self.clear_btn.setFixedSize(28, 28) + self.clear_btn.hide() + self.clear_btn.clicked.connect(self.clear_input) + + # Add Chapters button + self.chapters_btn = QPushButton("Chapters", self) + self.chapters_btn.setStyleSheet("QPushButton { padding: 6px 10px; }") + self.chapters_btn.hide() + self.chapters_btn.clicked.connect(self.on_chapters_clicked) + + # Add Textbox button with no padding + self.textbox_btn = QPushButton("Textbox", self) + self.textbox_btn.setStyleSheet("QPushButton { padding: 6px 10px; }") + self.textbox_btn.setToolTip("Input text directly instead of using a file") + self.textbox_btn.clicked.connect(self.on_textbox_clicked) + + # Add Edit button matching the textbox button + self.edit_btn = QPushButton("Edit", self) + self.edit_btn.setStyleSheet("QPushButton { padding: 6px 10px; }") + self.edit_btn.setToolTip("Edit the current text file") + self.edit_btn.clicked.connect(self.on_edit_clicked) + self.edit_btn.hide() + + # Add Go to folder button + self.go_to_folder_btn = QPushButton("Go to folder", self) + self.go_to_folder_btn.setStyleSheet("QPushButton { padding: 6px 10px; }") + self.go_to_folder_btn.setToolTip( + "Open the folder that contains the converted file" + ) + self.go_to_folder_btn.clicked.connect(self.on_go_to_folder_clicked) + self.go_to_folder_btn.hide() + + def resizeEvent(self, event): + super().resizeEvent(event) + margin = 12 + self.clear_btn.move(self.width() - self.clear_btn.width() - margin, margin) + self.chapters_btn.move( + margin, self.height() - self.chapters_btn.height() - margin + ) + # Position textbox button at top left + self.textbox_btn.move(margin, margin) + self.edit_btn.move(margin, margin) + # Position go to folder button at bottom right with correct margins + self.go_to_folder_btn.move( + self.width() - self.go_to_folder_btn.width() - margin, + self.height() - self.go_to_folder_btn.height() - margin, + ) + + def set_file_info(self, file_path): + # get icon without resizing using custom provider + provider = IconProvider() + qicon = provider.icon(QFileInfo(file_path)) + size = QSize(32, 32) + pixmap = qicon.pixmap(size) + # convert to base64 PNG + buffer = QBuffer() + buffer.open(QIODevice.OpenModeFlag.WriteOnly) + pixmap.save(buffer, "PNG") + img_data = base64.b64encode(buffer.data()).decode() + + size_str = self._human_readable_size(os.path.getsize(file_path)) + name = os.path.basename(file_path) + char_count = 0 + window = self.window() + cache = getattr(window, "_char_count_cache", None) + + def parse_size(size_str): + # Use regex to extract the numeric part + match = re.match(r"([\d.]+)", size_str) + if match: + return float(match.group(1)) + raise ValueError(f"Invalid size format: {size_str}") + + # Format numbers with commas + def format_num(n): + try: + if isinstance(n, str): + size = int(parse_size(n)) + return f"{size:,}" + else: + return f"{n:,}" + except Exception: + return str(n) + + doc_extensions = (".epub", ".pdf", ".md", ".markdown", ".srt", ".ass", ".vtt") + char_source_path = file_path + cached_char_count = None + + if file_path.lower().endswith(doc_extensions): + selected_file_path = getattr(window, "selected_file", None) + if selected_file_path and os.path.exists(selected_file_path): + char_source_path = selected_file_path + else: + char_source_path = None + + if cache is not None: + cached_char_count = cache.get(file_path) + if ( + cached_char_count is None + and char_source_path + and char_source_path != file_path + ): + cached_char_count = cache.get(char_source_path) + + if cached_char_count is not None: + char_count = cached_char_count + elif char_source_path: + try: + with open( + char_source_path, "r", encoding="utf-8", errors="ignore" + ) as f: + text = f.read() + cleaned_text = clean_text(text) + char_count = calculate_text_length(cleaned_text) + except Exception: + char_count = "N/A" + else: + char_count = "N/A" + + if cache is not None and isinstance(char_count, int): + cache[file_path] = char_count + if char_source_path and char_source_path != file_path: + cache[char_source_path] = char_count + + # Store numeric char_count on window + try: + window.char_count = int(char_count) + except Exception: + window.char_count = 0 + # embed icon at native size with word-wrap for the filename + self.setText( + f'
    {name}
    Size: {size_str}
    Characters: {format_num(char_count)}' + ) + # Set fixed width to force wrapping + self.setWordWrap(True) + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }}" + ) + self.clear_btn.show() + is_document = window.selected_file_type in ["epub", "pdf", "md", "markdown"] + self.chapters_btn.setVisible(is_document) + if is_document: + chapter_count = len(window.selected_chapters) + file_type = window.selected_file_type + # Adjust button text based on file type + if file_type == "epub" or file_type == "md" or file_type == "markdown": + self.chapters_btn.setText(f"Chapters ({chapter_count})") + else: # PDF - always use Pages + self.chapters_btn.setText(f"Pages ({chapter_count})") + + # Hide textbox and show edit only for .txt, .srt, .ass, .vtt files + self.textbox_btn.hide() + # Show edit button for txt/subtitle files directly + # Or for epub/pdf files that have generated a temp txt file + should_show_edit = file_path.lower().endswith((".txt", ".srt", ".ass", ".vtt")) + + # For epub/pdf files, show edit if we have a selected_file (temp txt) + if ( + window.selected_file_type + in ["epub", "pdf", "md", "markdown", "md", "markdown"] + and window.selected_file + ): + should_show_edit = True + + self.edit_btn.setVisible(should_show_edit) + self.go_to_folder_btn.show() + + # Disable subtitle generation for subtitle input files + is_subtitle_input = file_path.lower().endswith((".srt", ".ass", ".vtt")) + if hasattr(window, "subtitle_combo"): + window.subtitle_combo.setEnabled(not is_subtitle_input) + + # Enable add to queue button only when file is accepted (input box is green) + self.resizeEvent(None) + if hasattr(window, "btn_add_to_queue"): + window.btn_add_to_queue.setEnabled(True) + + self.chapters_btn.adjustSize() + # Reset the input_box_cleared_by_queue flag after setting file info + if hasattr(window, "input_box_cleared_by_queue"): + window.input_box_cleared_by_queue = False + + def set_error(self, message): + self.setText(message) + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }}" + ) + # Show textbox button in error state as well + self.textbox_btn.show() + # Disable add to queue button on error + if hasattr(self.window(), "btn_add_to_queue"): + self.window().btn_add_to_queue.setEnabled(False) + + def clear_input(self): + self.window().selected_file = None + self.window().displayed_file_path = ( + None # Reset the displayed file path when clearing input + ) + # Reset book handler attributes + self.window().save_chapters_separately = None + self.window().merge_chapters_at_end = None + self.setText( + "Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf, .md, .srt, .ass, .vtt)" + ) + self.setStyleSheet( + f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" + ) + self.clear_btn.hide() + self.chapters_btn.hide() + self.chapters_btn.setText("Chapters") # Reset text + # Show textbox and hide edit when input is cleared + self.textbox_btn.show() + self.edit_btn.hide() + self.go_to_folder_btn.hide() + + # Re-enable subtitle and replace newlines controls when cleared + window = self.window() + if hasattr(window, "subtitle_combo"): + # Only enable if language supports it + current_lang = getattr(window, "selected_lang", "a") + window.subtitle_combo.setEnabled( + current_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + ) + if hasattr(window, "replace_newlines_combo"): + window.replace_newlines_combo.setEnabled(True) + + # Disable add to queue button when input is cleared + if hasattr(window, "btn_add_to_queue"): + window.btn_add_to_queue.setEnabled(False) + # Reset the input_box_cleared_by_queue flag after setting file info + if hasattr(self.window(), "input_box_cleared_by_queue"): + self.window().input_box_cleared_by_queue = True + + def _human_readable_size(self, size, decimal_places=2): + for unit in ["B", "KB", "MB", "GB", "TB"]: + if size < 1024.0: + return f"{size:.{decimal_places}f} {unit}" + size /= 1024.0 + return f"{size:.{decimal_places}f} PB" + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.window().open_file_dialog() + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + urls = event.mimeData().urls() + if urls: + ext = urls[0].toLocalFile().lower() + if ( + ext.endswith(".txt") + or ext.endswith(".epub") + or ext.endswith(".pdf") + or ext.endswith((".md", ".markdown")) + or ext.endswith((".srt", ".ass", ".vtt")) + ): + event.acceptProposedAction() + # Set hover style based on current state + if self.styleSheet().find(self.STYLE_ACTIVE) != -1: + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }} {self.STYLE_ACTIVE_HOVER}" + ) + elif self.styleSheet().find(self.STYLE_ERROR) != -1: + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }} {self.STYLE_ERROR_HOVER}" + ) + else: + self.setStyleSheet( + f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }} {self.STYLE_DEFAULT_HOVER}" + ) + return + event.ignore() + + def dragLeaveEvent(self, event): + # Restore the style based on current state + if self.styleSheet().find(self.STYLE_ACTIVE) != -1: + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }}" + ) + elif self.styleSheet().find(self.STYLE_ERROR) != -1: + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }}" + ) + else: + self.setStyleSheet( + f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" + ) + event.accept() + + def dropEvent(self, event): + if event.mimeData().hasUrls(): + urls = event.mimeData().urls() + if not urls: + event.ignore() + return + file_path = urls[0].toLocalFile() + win = self.window() + if file_path.lower().endswith(".txt"): + win.selected_file, win.selected_file_type = file_path, "txt" + win.displayed_file_path = ( + file_path # Set the displayed file path for text files + ) + self.set_file_info(file_path) + event.acceptProposedAction() + elif ( + file_path.lower().endswith(".epub") + or file_path.lower().endswith(".pdf") + or file_path.lower().endswith((".md", ".markdown")) + or file_path.lower().endswith((".srt", ".ass", ".vtt")) + ): + # Determine file type + if file_path.lower().endswith(".epub"): + file_type = "epub" + elif file_path.lower().endswith(".pdf"): + file_type = "pdf" + elif file_path.lower().endswith((".srt", ".ass", ".vtt")): + # For subtitle files, treat them like txt files (direct processing) + win.selected_file, win.selected_file_type = file_path, "txt" + win.displayed_file_path = file_path + self.set_file_info(file_path) + event.acceptProposedAction() + return + else: + file_type = "markdown" + + # Just store the file path but don't set the file info yet + win.selected_file_type = file_type + win.selected_book_path = file_path + win.open_book_file( + file_path # This will handle the dialog and setting file info + ) + event.acceptProposedAction() + else: + self.set_error( + "Please drop a .txt, .epub, .pdf, .md, .srt, .ass, or .vtt file." + ) + event.ignore() + else: + event.ignore() + + def on_chapters_clicked(self): + win = self.window() + if ( + win.selected_file_type in ["epub", "pdf", "md", "markdown"] + and win.selected_book_path + ): + # Call open_book_file which shows the dialog and updates selected_chapters + if win.open_book_file(win.selected_book_path): + # Refresh the info label and button text after dialog closes + self.set_file_info(win.selected_book_path) + + def on_textbox_clicked(self): + self.window().open_textbox_dialog() + + def on_edit_clicked(self): + win = self.window() + # For PDFs and EPUBs, use the temporary text file + if ( + win.selected_file_type in ["epub", "pdf", "md", "markdown"] + and win.selected_file + ): + # Use the temporary .txt file that was generated + win.open_textbox_dialog(win.selected_file) + else: + # For regular txt files + win.open_textbox_dialog() + + def on_go_to_folder_clicked(self): + win = self.window() + # win.selected_file holds the path to the text that is converted. + file_to_check = win.selected_file + + # If this is a converted document (epub/pdf/markdown) that was written to the + # user's cache directory, show a menu letting the user jump to either the + # processed (cached .txt) file or the original input file (epub/pdf/md). + try: + cache_dir = get_user_cache_path() + except Exception: + cache_dir = None + + is_cached_doc = False + if ( + file_to_check + and os.path.exists(file_to_check) + and os.path.isfile(file_to_check) + and cache_dir + ): + # Consider it cached when the file is under the cache directory and is a .txt + if file_to_check.endswith(".txt") and os.path.commonpath( + [os.path.abspath(file_to_check), os.path.abspath(cache_dir)] + ) == os.path.abspath(cache_dir): + # Only treat as document-cache when original type was a document + if getattr(win, "selected_file_type", None) in [ + "epub", + "pdf", + "md", + "markdown", + ]: + is_cached_doc = True + + if is_cached_doc: + menu = QMenu(self) + act_processed = QAction("Go to processed file", self) + + def open_processed(): + folder_path = os.path.dirname(file_to_check) + QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) + + act_processed.triggered.connect(open_processed) + menu.addAction(act_processed) + + act_input = QAction("Go to input file", self) + # Prefer displayed_file_path (original input path) then selected_book_path + input_path = getattr(win, "displayed_file_path", None) or getattr( + win, "selected_book_path", None + ) + if input_path and os.path.exists(input_path): + + def open_input(): + folder_path = os.path.dirname(input_path) + QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) + + act_input.triggered.connect(open_input) + else: + act_input.setEnabled(False) + + menu.addAction(act_input) + # Show the menu anchored to the button + menu.exec( + self.go_to_folder_btn.mapToGlobal( + QPoint(0, self.go_to_folder_btn.height()) + ) + ) + else: + if ( + file_to_check + and os.path.exists(file_to_check) + and os.path.isfile(file_to_check) + ): + folder_path = os.path.dirname(file_to_check) + QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) + else: + QMessageBox.warning(win, "Error", "Converted file not found.") + + +class TextboxDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Enter Text") + self.setWindowFlags( + Qt.WindowType.Window + | Qt.WindowType.WindowCloseButtonHint + | Qt.WindowType.WindowMaximizeButtonHint + ) + self.resize(700, 500) + + layout = QVBoxLayout(self) + + # Instructions + instructions = QLabel( + "Enter or paste the text you want to convert to audio:", self + ) + layout.addWidget(instructions) + + # Text edit area + self.text_edit = QTextEdit(self) + self.text_edit.setAcceptRichText(False) + self.text_edit.setPlaceholderText("Type or paste your text here...") + layout.addWidget(self.text_edit) + + # Character count label + self.char_count_label = QLabel("Characters: 0", self) + layout.addWidget(self.char_count_label) + + # Connect text changed signal to update character count + self.text_edit.textChanged.connect(self.update_char_count) + + # Buttons + button_layout = QHBoxLayout() + + self.save_as_button = QPushButton("Save as text", self) + self.save_as_button.clicked.connect(self.save_as_text) + self.save_as_button.setToolTip("Save the current text to a file") + + self.insert_chapter_btn = QPushButton("Insert Chapter Marker", self) + self.insert_chapter_btn.setToolTip("Insert a chapter marker at the cursor") + self.insert_chapter_btn.clicked.connect(self.insert_chapter_marker) + button_layout.addWidget(self.insert_chapter_btn) + + self.cancel_button = QPushButton("Cancel", self) + self.cancel_button.clicked.connect(self.reject) + + self.ok_button = QPushButton("OK", self) + self.ok_button.setDefault(True) + self.ok_button.clicked.connect(self.handle_ok) + + button_layout.addWidget(self.save_as_button) + button_layout.addWidget(self.cancel_button) + button_layout.addWidget(self.ok_button) + layout.addLayout(button_layout) + + # Store the original text to detect changes + self.original_text = "" + + def update_char_count(self): + text = self.text_edit.toPlainText() + count = calculate_text_length(text) + self.char_count_label.setText(f"Characters: {count:,}") + + def get_text(self): + return self.text_edit.toPlainText() + + def handle_ok(self): + text = self.text_edit.toPlainText() + # Check if text is empty based on character count + if calculate_text_length(text) == 0: + QMessageBox.warning(self, "Textbox Error", "Text cannot be empty.") + return + + # If the text hasn't changed, treat as cancel + if text == self.original_text: + self.reject() + else: + # Check if we need to warn about overwriting a non-temporary file + if hasattr(self, "is_non_cache_file") and self.is_non_cache_file: + msg_box = QMessageBox(self) + msg_box.setIcon(QMessageBox.Icon.Warning) + msg_box.setWindowTitle("File Overwrite Warning") + msg_box.setText( + f"You are about to overwrite the original file:\n{self.non_cache_file_path}" + ) + msg_box.setInformativeText("Do you want to continue?") + msg_box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + msg_box.setDefaultButton(QMessageBox.StandardButton.No) + + if msg_box.exec() != QMessageBox.StandardButton.Yes: + # User canceled, don't close the dialog + return + + self.accept() + + def save_as_text(self): + """Save the text content to a file chosen by the user""" + try: + text = self.text_edit.toPlainText() + if not text.strip(): + QMessageBox.warning(self, "Save Error", "There is no text to save.") + return + + # Get default filename from original file if editing + initial_path = "" + if hasattr(self, "non_cache_file_path") and self.non_cache_file_path: + initial_path = self.non_cache_file_path + + # For EPUB and PDF files, use the displayed_file_path from the main window + # This gives a better filename instead of the cache file path + main_window = self.parent() + if ( + hasattr(main_window, "displayed_file_path") + and main_window.displayed_file_path + ): + if main_window.selected_file_type in ["epub", "pdf", "md", "markdown"]: + # Use the base name of the displayed file but change extension to .txt + base_name = os.path.splitext(main_window.displayed_file_path)[0] + initial_path = base_name + ".txt" + + file_path, _ = QFileDialog.getSaveFileName( + self, "Save Text As", initial_path, "Text Files (*.txt);;All Files (*)" + ) + + if file_path: + # Add .txt extension if not specified and no other extension exists + if not os.path.splitext(file_path)[1]: + file_path += ".txt" + + with open(file_path, "w", encoding="utf-8") as f: + f.write(text) + + except Exception as e: + QMessageBox.critical(self, "Save Error", f"Could not save file:\n{e}") + + def insert_chapter_marker(self): + # Insert a fixed chapter marker without prompting + cursor = self.text_edit.textCursor() + cursor.insertText("\n<>\n") + self.text_edit.setTextCursor(cursor) + self.update_char_count() + self.text_edit.setFocus() + + +def migrate_subtitle_format(config): + """Convert old subtitle_format values to new internal keys.""" + old_to_new = { + "srt": "srt", + "ass (wide)": "ass_wide", + "ass (narrow)": "ass_narrow", + "ass (centered wide)": "ass_centered_wide", + "ass (centered narrow)": "ass_centered_narrow", + } + val = config.get("subtitle_format") + if val in old_to_new: + config["subtitle_format"] = old_to_new[val] + save_config(config) + + +class abogen(QWidget): + def __init__(self): + super().__init__() + self.config = load_config() + self.apply_theme(self.config.get("theme", "system")) + migrate_subtitle_format(self.config) + self.check_updates = self.config.get("check_updates", True) + self.save_option = self.config.get("save_option", "Save next to input file") + self.selected_output_folder = self.config.get("selected_output_folder", None) + self.selected_file = self.selected_file_type = self.selected_book_path = None + self.displayed_file_path = ( + None # Add new variable to track the displayed file path + ) + # Max log lines + self.log_window_max_lines = self.config.get("log_window_max_lines", 2000) + self.selected_chapters = set() + self.last_opened_book_path = None # Track the last opened book path + self.last_output_path = None + self.char_count = 0 + self._char_count_cache = {} + # Only one of selected_profile_name or selected_voice should be set + self.selected_profile_name = self.config.get("selected_profile_name") + self.selected_voice = None + self.selected_lang = None + self.mixed_voice_state = None + if self.selected_profile_name: + self.selected_voice = None + self.selected_lang = None + else: + self.selected_voice = self.config.get("selected_voice", "af_heart") + self.selected_lang = self.selected_voice[0] if self.selected_voice else None + self.is_converting = False + self.subtitle_mode = self.config.get("subtitle_mode", "Sentence") + self.max_subtitle_words = self.config.get( + "max_subtitle_words", 50 + ) # Default max words per subtitle + self.silence_duration = self.config.get( + "silence_duration", 2.0 + ) # Default silence duration + self.selected_format = self.config.get("selected_format", "wav") + self.separate_chapters_format = self.config.get( + "separate_chapters_format", "wav" + ) # Format for individual chapter files + self.use_gpu = self.config.get( + "use_gpu", True # Load GPU setting with default True + ) + self.replace_single_newlines = self.config.get("replace_single_newlines", True) + self.use_silent_gaps = self.config.get("use_silent_gaps", True) + self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts") + self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", True) + self._pending_close_event = None + self.gpu_ok = False # Initialize GPU availability status + + # Create thread-safe logging mechanism + self.log_signal = ThreadSafeLogSignal() + self.log_signal.log_signal.connect(self._update_log_main_thread) + + # Create warning signal emitter + self.warning_signal_emitter = ShowWarningSignalEmitter() + self.warning_signal_emitter.show_warning_signal.connect( + self.show_model_download_warning + ) + hf_tracker.set_show_warning_signal_emitter(self.warning_signal_emitter) + + # Set application icon + icon_path = get_resource_path("abogen.assets", "icon.ico") + if icon_path: + self.setWindowIcon(QIcon(icon_path)) + # Set taskbar icon for Windows + if platform.system() == "Windows": + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("abogen") + + # Queued items list + self.queued_items = [] + self.current_queue_index = 0 + + self.initUI() + self.speed_slider.setValue(int(self.config.get("speed", 1.00) * 100)) + self.update_speed_label() + # Set initial selection: prefer profile, else voice + idx = -1 + if self.selected_profile_name: + idx = self.voice_combo.findData(f"profile:{self.selected_profile_name}") + elif self.selected_voice: + idx = self.voice_combo.findData(self.selected_voice) + if idx >= 0: + self.voice_combo.setCurrentIndex(idx) + # If a profile is selected at startup, load voices and language + if self.selected_profile_name: + from abogen.voice_profiles import load_profiles + + entry = load_profiles().get(self.selected_profile_name, {}) + if isinstance(entry, dict): + self.mixed_voice_state = entry.get("voices", []) + self.selected_lang = entry.get("language") + else: + self.mixed_voice_state = entry + self.selected_lang = entry[0][0] if entry and entry[0] else None + if self.save_option == "Choose output folder" and self.selected_output_folder: + self.save_path_label.setText(self.selected_output_folder) + self.save_path_row_widget.show() + self.subtitle_combo.setCurrentText(self.subtitle_mode) + # Enable/disable subtitle options based on selected language (profile or voice) + enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + self.subtitle_combo.setEnabled(enable) + self.subtitle_format_combo.setEnabled(enable) + # loading gif for preview button + loading_gif_path = get_resource_path("abogen.assets", "loading.gif") + if loading_gif_path: + self.loading_movie = QMovie(loading_gif_path) + self.loading_movie.frameChanged.connect( + lambda: self.btn_preview.setIcon( + QIcon(self.loading_movie.currentPixmap()) + ) + ) + + # Check for updates at startup if enabled + if self.check_updates: + QTimer.singleShot(1000, self.check_for_updates_startup) + + # Set hf_tracker callbacks + hf_tracker.set_log_callback(self.update_log) + + def initUI(self): + self.setWindowTitle(f"{PROGRAM_NAME} v{VERSION}") + screen = QApplication.primaryScreen().geometry() + width, height = 500, 800 + x = (screen.width() - width) // 2 + # If desired height is larger than screen, fit to screen height + if height > screen.height() - 65: + height = screen.height() - 100 # Leave a margin for window borders + y = max((screen.height() - height) // 2, 0) + self.setGeometry(x, y, width, height) + outer_layout = QVBoxLayout() + outer_layout.setContentsMargins(15, 15, 15, 15) + container = QWidget(self) + container_layout = QVBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + container_layout.setSpacing(15) + self.input_box = InputBox(self) + container_layout.addWidget(self.input_box, 1) + # Manage queue button, start queue button + self.queue_row_widget = QWidget(self) # Make queue_row a QWidget + queue_row = QHBoxLayout(self.queue_row_widget) + queue_row.setContentsMargins(0, 0, 0, 0) + self.btn_add_to_queue = QPushButton("Add to Queue", self) + self.btn_add_to_queue.setFixedHeight(40) + self.btn_add_to_queue.setEnabled(False) + self.btn_add_to_queue.clicked.connect(self.add_to_queue) + queue_row.addWidget(self.btn_add_to_queue) + self.btn_manage_queue = QPushButton("Manage Queue", self) + self.btn_manage_queue.setFixedHeight(40) + self.btn_manage_queue.setEnabled(True) + self.btn_manage_queue.clicked.connect(self.manage_queue) + queue_row.addWidget(self.btn_manage_queue) + self.btn_clear_queue = QPushButton("Clear Queue", self) + self.btn_clear_queue.setFixedHeight(40) + self.btn_clear_queue.setEnabled(False) + self.btn_clear_queue.clicked.connect(self.clear_queue) + queue_row.addWidget(self.btn_clear_queue) + container_layout.addWidget(self.queue_row_widget) + self.log_text = QTextEdit(self) + self.log_text.setReadOnly(True) + self.log_text.setUndoRedoEnabled(False) + self.log_text.setFrameStyle(QFrame.Shape.NoFrame) + self.log_text.setStyleSheet("QTextEdit { border: none; }") + self.log_text.hide() + container_layout.addWidget(self.log_text, 1) + controls_layout = QVBoxLayout() + controls_layout.setContentsMargins(0, 10, 0, 0) + controls_layout.setSpacing(15) + # Speed controls + speed_layout = QVBoxLayout() + speed_layout.setSpacing(2) + speed_layout.addWidget(QLabel("Speed:", self)) + self.speed_slider = QSlider(Qt.Orientation.Horizontal, self) + self.speed_slider.setMinimum(10) + self.speed_slider.setMaximum(200) + self.speed_slider.setValue(100) + self.speed_slider.setTickPosition(QSlider.TickPosition.TicksBelow) + self.speed_slider.setTickInterval(5) + self.speed_slider.setSingleStep(5) + speed_layout.addWidget(self.speed_slider) + self.speed_label = QLabel("1.0", self) + speed_layout.addWidget(self.speed_label) + controls_layout.addLayout(speed_layout) + self.speed_slider.valueChanged.connect(self.update_speed_label) + # Voice selection + voice_layout = QHBoxLayout() + voice_layout.setSpacing(7) + voice_label = QLabel("Select voice:", self) + voice_layout.addWidget(voice_label) + self.voice_combo = QComboBox(self) + self.voice_combo.currentIndexChanged.connect(self.on_voice_combo_changed) + self.voice_combo.setStyleSheet( + "QComboBox { min-height: 20px; padding: 6px 12px; }" + ) + self.voice_combo.setToolTip( + "The first character represents the language:\n" + '"a" => American English\n"b" => British English\n"e" => Spanish\n"f" => French\n"h" => Hindi\n"i" => Italian\n"j" => Japanese\n"p" => Brazilian Portuguese\n"z" => Mandarin Chinese\nThe second character represents the gender:\n"m" => Male\n"f" => Female' + ) + self.voice_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + voice_layout.addWidget(self.voice_combo) + # Voice formula button + self.btn_voice_formula_mixer = QPushButton(self) + mixer_icon_path = get_resource_path("abogen.assets", "voice_mixer.png") + self.btn_voice_formula_mixer.setIcon(QIcon(mixer_icon_path)) + self.btn_voice_formula_mixer.setToolTip("Mix and match voices") + self.btn_voice_formula_mixer.setFixedSize(40, 36) + self.btn_voice_formula_mixer.setStyleSheet("QPushButton { padding: 6px 12px; }") + self.btn_voice_formula_mixer.clicked.connect(self.show_voice_formula_dialog) + voice_layout.addWidget(self.btn_voice_formula_mixer) + + # Play/Stop icons + def make_icon(color, shape): + pix = QPixmap(20, 20) + pix.fill(Qt.GlobalColor.transparent) + p = QPainter(pix) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.setBrush(QColor(*color)) + p.setPen(Qt.PenStyle.NoPen) + if shape == "play": + pts = [ + pix.rect().topLeft() + QPoint(4, 2), + pix.rect().bottomLeft() + QPoint(4, -2), + pix.rect().center() + QPoint(6, 0), + ] + p.drawPolygon(QPolygon(pts)) + else: + p.drawRect(5, 5, 10, 10) + p.end() + return QIcon(pix) + + self.play_icon = make_icon((40, 160, 40), "play") + self.stop_icon = make_icon((200, 60, 60), "stop") + self.btn_preview = QPushButton(self) + self.btn_preview.setIcon(self.play_icon) + self.btn_preview.setIconSize(QPixmap(20, 20).size()) + self.btn_preview.setToolTip("Preview selected voice") + self.btn_preview.setFixedSize(40, 36) + self.btn_preview.setStyleSheet("QPushButton { padding: 6px 12px; }") + self.btn_preview.clicked.connect(self.preview_voice) + voice_layout.addWidget(self.btn_preview) + self.preview_playing = False + self.play_audio_thread = None # Keep track of audio playing thread + controls_layout.addLayout(voice_layout) + + # Generate subtitles + subtitle_layout = QHBoxLayout() + subtitle_layout.setSpacing(7) + subtitle_label = QLabel("Generate subtitles:", self) + subtitle_layout.addWidget(subtitle_label) + self.subtitle_combo = QComboBox(self) + self.subtitle_combo.setToolTip( + "Choose how subtitles will be generated:\n" + "Disabled: No subtitles will be generated.\n" + "Line: Subtitles will be generated for each line.\n" + "Sentence: Subtitles will be generated for each sentence.\n" + "Sentence + Comma: Subtitles will be generated for each sentence and comma.\n" + "Sentence + Highlighting: Subtitles with word-by-word karaoke highlighting.\n" + "1+ word: Subtitles will be generated for each word(s).\n\n" + "Supported languages for subtitle generation:\n" + + "\n".join( + f'"{lang}" => {LANGUAGE_DESCRIPTIONS.get(lang, lang)}' + for lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + ) + ) + subtitle_options = [ + "Disabled", + "Line", + "Sentence", + "Sentence + Comma", + "Sentence + Highlighting", + ] + [f"{i} word" if i == 1 else f"{i} words" for i in range(1, 11)] + self.subtitle_combo.addItems(subtitle_options) + self.subtitle_combo.setStyleSheet( + "QComboBox { min-height: 20px; padding: 6px 12px; }" + ) + self.subtitle_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.subtitle_combo.setCurrentText(self.subtitle_mode) + self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed) + subtitle_layout.addWidget(self.subtitle_combo) + controls_layout.addLayout(subtitle_layout) + + # Output voice format + format_layout = QHBoxLayout() + format_layout.setSpacing(7) + format_label = QLabel("Output voice format:", self) + format_layout.addWidget(format_label) + self.format_combo = QComboBox(self) + self.format_combo.setStyleSheet( + "QComboBox { min-height: 20px; padding: 6px 12px; }" + ) + self.format_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + # Add items with display labels and underlying keys + for key, label in [ + ("wav", "wav"), + ("flac", "flac"), + ("mp3", "mp3"), + ("opus", "opus (best compression)"), + ("m4b", "m4b (with chapters)"), + ]: + self.format_combo.addItem(label, key) + # Initialize selection by matching saved key + idx = self.format_combo.findData(self.selected_format) + if idx >= 0: + self.format_combo.setCurrentIndex(idx) + # Map selection back to key on change + self.format_combo.currentIndexChanged.connect( + lambda i: self.on_format_changed(self.format_combo.itemData(i)) + ) + format_layout.addWidget(self.format_combo) + controls_layout.addLayout(format_layout) + + # Output subtitle format + subtitle_format_layout = QHBoxLayout() + subtitle_format_layout.setSpacing(7) + subtitle_format_label = QLabel("Output subtitle format:", self) + subtitle_format_layout.addWidget(subtitle_format_label) + self.subtitle_format_combo = QComboBox(self) + self.subtitle_format_combo.setStyleSheet( + "QComboBox { min-height: 20px; padding: 6px 12px; }" + ) + self.subtitle_format_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + for value, text in SUBTITLE_FORMATS: + self.subtitle_format_combo.addItem(text, value) + subtitle_format = self.config.get("subtitle_format", "ass_centered_narrow") + idx = self.subtitle_format_combo.findData(subtitle_format) + if idx >= 0: + self.subtitle_format_combo.setCurrentIndex(idx) + self.subtitle_format_combo.currentIndexChanged.connect( + lambda i: self.set_subtitle_format(self.subtitle_format_combo.itemData(i)) + ) + subtitle_format_layout.addWidget(self.subtitle_format_combo) + # If subtitle mode requires highlighting, SRT is not supported. Disable SRT item + # and auto-switch to a compatible ASS format if SRT is currently selected. + try: + if ( + hasattr(self, "subtitle_mode") + and self.subtitle_mode == "Sentence + Highlighting" + ): + idx_srt = self.subtitle_format_combo.findData("srt") + if idx_srt >= 0: + item = self.subtitle_format_combo.model().item(idx_srt) + if item is not None: + item.setEnabled(False) + # If current selection is SRT, switch to centered narrow ASS + if self.subtitle_format_combo.currentData() == "srt": + new_idx = self.subtitle_format_combo.findData("ass_centered_narrow") + if new_idx >= 0: + self.subtitle_format_combo.setCurrentIndex(new_idx) + # Persist the change + self.set_subtitle_format( + self.subtitle_format_combo.itemData(new_idx) + ) + except Exception: + # Fail-safe: don't crash UI if model manipulation isn't supported on some platforms + pass + + # Enable/disable subtitle options based on selected language (profile or voice) + self.update_subtitle_options_availability() + + controls_layout.addLayout(subtitle_format_layout) + + # Replace single newlines dropdown (acts like checkbox) + replace_newlines_layout = QHBoxLayout() + replace_newlines_layout.setSpacing(7) + replace_newlines_label = QLabel("Replace single newlines:", self) + replace_newlines_layout.addWidget(replace_newlines_label) + self.replace_newlines_combo = QComboBox(self) + self.replace_newlines_combo.addItems(["Disabled", "Enabled"]) + self.replace_newlines_combo.setToolTip( + "Replace single newlines in the input text with spaces before processing." + ) + self.replace_newlines_combo.setStyleSheet( + "QComboBox { min-height: 20px; padding: 6px 12px; }" + ) + self.replace_newlines_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + # Set initial value based on config + self.replace_newlines_combo.setCurrentIndex( + 1 if self.replace_single_newlines else 0 + ) + self.replace_newlines_combo.currentIndexChanged.connect( + lambda idx: self.toggle_replace_single_newlines(idx == 1) + ) + replace_newlines_layout.addWidget(self.replace_newlines_combo) + controls_layout.addLayout(replace_newlines_layout) + + # Save location + save_layout = QHBoxLayout() + save_layout.setSpacing(7) + save_label = QLabel("Save location:", self) + save_layout.addWidget(save_label) + self.save_combo = QComboBox(self) + save_options = [ + "Save next to input file", + "Save to Desktop", + "Choose output folder", + ] + self.save_combo.addItems(save_options) + self.save_combo.setStyleSheet( + "QComboBox { min-height: 20px; padding: 6px 12px; }" + ) + self.save_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.save_combo.setCurrentText(self.save_option) + self.save_combo.currentTextChanged.connect(self.on_save_option_changed) + save_layout.addWidget(self.save_combo) + controls_layout.addLayout(save_layout) + + # Save path label + self.save_path_row_widget = QWidget(self) + save_path_row = QHBoxLayout(self.save_path_row_widget) + save_path_row.setSpacing(7) + save_path_row.setContentsMargins(0, 0, 0, 0) + selected_folder_label = QLabel("Selected folder:", self.save_path_row_widget) + save_path_row.addWidget(selected_folder_label) + self.save_path_label = QLabel("", self.save_path_row_widget) + self.save_path_label.setStyleSheet(f"QLabel {{ color: {COLORS['GREEN']}; }}") + self.save_path_label.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + save_path_row.addWidget(self.save_path_label) + self.save_path_row_widget.hide() # Hide the whole row by default + controls_layout.addWidget(self.save_path_row_widget) + + # GPU Acceleration Checkbox with Settings button + gpu_layout = QHBoxLayout() + gpu_checkbox_layout = QVBoxLayout() + self.gpu_checkbox = QCheckBox("Use GPU Acceleration (if available)", self) + self.gpu_checkbox.setChecked(self.use_gpu) + self.gpu_checkbox.setToolTip( + "Uncheck to force using CPU even if a compatible GPU is detected." + ) + self.gpu_checkbox.stateChanged.connect(self.on_gpu_setting_changed) + gpu_checkbox_layout.addWidget(self.gpu_checkbox) + gpu_layout.addLayout(gpu_checkbox_layout) + + # Set initial enabled state for subtitle format combo + if self.subtitle_mode == "Disabled": + self.subtitle_format_combo.setEnabled(False) + else: + self.subtitle_format_combo.setEnabled(True) + + # Settings button with icon + settings_icon_path = get_resource_path("abogen.assets", "settings.svg") + self.settings_btn = QPushButton(self) + if settings_icon_path and os.path.exists(settings_icon_path): + self.settings_btn.setIcon(QIcon(settings_icon_path)) + else: + # Fallback text if icon not found + self.settings_btn.setText("⚙") + self.settings_btn.setToolTip("Settings") + self.settings_btn.setFixedSize(36, 36) + self.settings_btn.clicked.connect(self.show_settings_menu) + gpu_layout.addWidget(self.settings_btn) + + controls_layout.addLayout(gpu_layout) + + # Start button + self.btn_start = QPushButton("Start", self) + self.btn_start.setFixedHeight(60) + self.btn_start.clicked.connect(self.start_conversion) + controls_layout.addWidget(self.btn_start) + # Add controls to a container widget + self.controls_widget = QWidget() + self.controls_widget.setLayout(controls_layout) + self.controls_widget.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed + ) + container_layout.addWidget(self.controls_widget) + # Progress bar + self.progress_bar = QProgressBar(self) + self.progress_bar.setValue(0) + self.progress_bar.hide() + container_layout.addWidget(self.progress_bar) + # ETR Label + self.etr_label = QLabel("Estimated time remaining: Calculating...", self) + self.etr_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.etr_label.hide() + container_layout.addWidget(self.etr_label) + # Cancel button + self.btn_cancel = QPushButton("Cancel", self) + self.btn_cancel.setFixedHeight(60) + self.btn_cancel.clicked.connect(self.cancel_conversion) + self.btn_cancel.hide() + container_layout.addWidget(self.btn_cancel) + # Finish buttons + self.finish_widget = QWidget() + finish_layout = QVBoxLayout() + finish_layout.setContentsMargins(0, 0, 0, 0) + finish_layout.setSpacing(10) + self.open_file_btn = None # Store reference to open file button + + # Create buttons with their functions + finish_buttons = [ + ("Open file", self.open_file, "Open the output file."), + ( + "Go to folder", + self.go_to_file, + "Open the folder containing the output file.", + ), + ("New Conversion", self.reset_ui, "Start a new conversion."), + ("Go back", self.go_back_ui, "Return to the previous screen."), + ] + + for text, func, tip in finish_buttons: + btn = QPushButton(text, self) + btn.setFixedHeight(35) + btn.setToolTip(tip) + btn.clicked.connect(func) + finish_layout.addWidget(btn) + # Identify the Open file button by its function reference + if func == self.open_file: + self.open_file_btn = btn # Save reference to the open file button + + self.finish_widget.setLayout(finish_layout) + self.finish_widget.hide() + container_layout.addWidget(self.finish_widget) + outer_layout.addWidget(container) + self.setLayout(outer_layout) + self.populate_profiles_in_voice_combo() + + # Initialize flag to track if input box was cleared by queue + self.input_box_cleared_by_queue = False + + def open_file_dialog(self): + if self.is_converting: + return + try: + file_path, _ = QFileDialog.getOpenFileName( + self, + "Select File", + "", + "Supported Files (*.txt *.epub *.pdf *.md *.srt *.ass *.vtt)", + ) + if not file_path: + return + if ( + file_path.lower().endswith(".epub") + or file_path.lower().endswith(".pdf") + or file_path.lower().endswith((".md", ".markdown")) + ): + # Determine file type + if file_path.lower().endswith(".epub"): + self.selected_file_type = "epub" + elif file_path.lower().endswith(".pdf"): + self.selected_file_type = "pdf" + else: + self.selected_file_type = "markdown" + + self.selected_book_path = file_path + # Don't set file info immediately, open_book_file will handle it after dialog is accepted + if not self.open_book_file(file_path): + return + elif file_path.lower().endswith((".srt", ".ass", ".vtt")): + # Handle subtitle files like text files + self.selected_file, self.selected_file_type = file_path, "txt" + self.displayed_file_path = file_path + self.input_box.set_file_info(file_path) + else: + self.selected_file, self.selected_file_type = file_path, "txt" + self.displayed_file_path = ( + file_path # Set the displayed file path for text files + ) + self.input_box.set_file_info(file_path) + except Exception as e: + self._show_error_message_box( + "File Dialog Error", f"Could not open file dialog:\n{e}" + ) + + def open_book_file(self, book_path): + # Clear selected chapters if this is a different book than the last one + if ( + not hasattr(self, "last_opened_book_path") + or self.last_opened_book_path != book_path + ): + self.selected_chapters = set() + self.last_opened_book_path = book_path + + # HandlerDialog uses internal caching to avoid reprocessing the same book + dialog = HandlerDialog( + book_path, + file_type=getattr(self, "selected_file_type", None), + checked_chapters=self.selected_chapters, + parent=self, + ) + dialog.setWindowModality(Qt.WindowModality.NonModal) + dialog.setModal(False) + dialog.show() # We'll handle the dialog result asynchronously + + def on_dialog_finished(result): + if result != QDialog.DialogCode.Accepted: + return False + chapters_text, all_checked_hrefs = dialog.get_selected_text() + if not all_checked_hrefs: + # Determine file type for error message + if book_path.lower().endswith(".pdf"): + file_type = "pdf" + item_type = "pages" + elif book_path.lower().endswith((".md", ".markdown")): + file_type = "markdown" + item_type = "chapters" + else: + file_type = "epub" + item_type = "chapters" + + error_msg = f"No {item_type} selected." + self._show_error_message_box(f"{file_type.upper()} Error", error_msg) + return False + self.selected_chapters = all_checked_hrefs + self.save_chapters_separately = dialog.get_save_chapters_separately() + self.merge_chapters_at_end = dialog.get_merge_chapters_at_end() + self.save_as_project = dialog.get_save_as_project() + + # Store if the PDF has bookmarks for button text display + if book_path.lower().endswith(".pdf"): + self.pdf_has_bookmarks = getattr(dialog, "has_pdf_bookmarks", False) + + cleaned_text = clean_text(chapters_text) + computed_char_count = calculate_text_length(cleaned_text) + self.char_count = computed_char_count + if isinstance(getattr(self, "_char_count_cache", None), dict): + self._char_count_cache[book_path] = computed_char_count + + # Use "abogen" prefix for cache files + # Extract base name without extension + base_name = os.path.splitext(os.path.basename(book_path))[0] + + if self.save_as_project: + # Get project directory from user + project_dir = QFileDialog.getExistingDirectory( + self, "Select Project Folder", "", QFileDialog.Option.ShowDirsOnly + ) + if not project_dir: + # User cancelled, fallback to cache + self.save_as_project = False + cache_dir = get_user_cache_path() + else: + # Create project folder structure + project_name = f"{base_name}_project" + project_dir = os.path.join(project_dir, project_name) + cache_dir = os.path.join(project_dir, "text") + os.makedirs(cache_dir, exist_ok=True) + + # Save metadata if available + meta_dir = os.path.join(project_dir, "metadata") + os.makedirs( + meta_dir, exist_ok=True + ) # Save book metadata if available + if hasattr(dialog, "book_metadata"): + meta_path = os.path.join(meta_dir, "book_info.txt") + with open(meta_path, "w", encoding="utf-8") as f: + # Clean HTML tags from metadata + title = re.sub( + r"<[^>]+>", + "", + str(dialog.book_metadata.get("title", "Unknown")), + ) + publisher = re.sub( + r"<[^>]+>", + "", + str(dialog.book_metadata.get("publisher", "Unknown")), + ) + authors = [ + re.sub(r"<[^>]+>", "", str(author)) + for author in dialog.book_metadata.get( + "authors", ["Unknown"] + ) + ] + publication_year = re.sub( + r"<[^>]+>", + "", + str( + dialog.book_metadata.get( + "publication_year", "Unknown" + ) + ), + ) + + f.write(f"Title: {title}\n") + f.write(f"Authors: {', '.join(authors)}\n") + f.write(f"Publisher: {publisher}\n") + f.write(f"Publication Year: {publication_year}\n") + if dialog.book_metadata.get("description"): + description = re.sub( + r"<[^>]+>", + "", + str(dialog.book_metadata.get("description")), + ) + f.write(f"\nDescription:\n{description}\n") + + # Save cover image if available + if dialog.book_metadata.get("cover_image"): + cover_path = os.path.join(meta_dir, "cover.png") + with open(cover_path, "wb") as f: + f.write(dialog.book_metadata["cover_image"]) + else: + cache_dir = get_user_cache_path() + + fd, tmp = tempfile.mkstemp( + prefix=f"{base_name}_", suffix=".txt", dir=cache_dir + ) + os.close(fd) + with open(tmp, "w", encoding="utf-8") as f: + f.write(chapters_text) + self.selected_file = tmp + self.selected_book_path = book_path + self.displayed_file_path = book_path + if isinstance(getattr(self, "_char_count_cache", None), dict): + self._char_count_cache[tmp] = computed_char_count + # Only set file info if dialog was accepted + self.input_box.set_file_info(book_path) + return True + + dialog.finished.connect(on_dialog_finished) + return True + + def open_textbox_dialog(self, file_path=None): + """Shows dialog for direct text input or editing and processes the entered text""" + if self.is_converting: + return + + editing = False + is_cache_file = False + # If path is explicitly provided, use it + if file_path and os.path.exists(file_path): + editing = True + edit_file = file_path + # Check if this is a cache file + is_cache_file = get_user_cache_path() in file_path + # Otherwise use selected_file if it's a txt file + elif ( + self.selected_file_type == "txt" + and self.selected_file + and os.path.exists(self.selected_file) + ): + editing = True + edit_file = self.selected_file + # Check if this is a cache file + is_cache_file = get_user_cache_path() in self.selected_file + + dialog = TextboxDialog(self) + if editing: + try: + with open(edit_file, "r", encoding="utf-8", errors="ignore") as f: + dialog.text_edit.setText(f.read()) + dialog.update_char_count() + dialog.original_text = ( + dialog.text_edit.toPlainText() + ) # Store original text + + # If editing a non-cache file, alert the user + if not is_cache_file: + dialog.is_non_cache_file = True + dialog.non_cache_file_path = edit_file + except Exception: + pass + if dialog.exec() == QDialog.DialogCode.Accepted: + text = dialog.get_text() + if not text.strip(): + self._show_error_message_box("Textbox Error", "Text cannot be empty.") + return + try: + if editing: + with open(edit_file, "w", encoding="utf-8") as f: + f.write(text) + # Update the display path to the edited file + self.displayed_file_path = edit_file + self.input_box.set_file_info(edit_file) + # Hide chapters button since we're using custom text now + self.input_box.chapters_btn.hide() + else: + cache_dir = get_user_cache_path() + fd, tmp = tempfile.mkstemp( + prefix="abogen_", suffix=".txt", dir=cache_dir + ) + os.close(fd) + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + self.selected_file = tmp + self.selected_file_type = "txt" + self.displayed_file_path = None + self.input_box.set_file_info(tmp) + # Hide chapters button since we're using custom text now + self.input_box.chapters_btn.hide() + if hasattr(self, "conversion_thread"): + self.conversion_thread.is_direct_text = True + except Exception as e: + self._show_error_message_box( + "Textbox Error", f"Could not process text input:\n{e}" + ) + + def update_speed_label(self): + s = self.speed_slider.value() / 100.0 + self.speed_label.setText(f"{s}") + self.config["speed"] = s + save_config(self.config) + + def update_subtitle_options_availability(self): + """ + Update the enabled state of subtitle options based on the selected language. + For non-English languages, only sentence-based and line-based modes are supported. + """ + # Check if current file is a subtitle file + is_subtitle_input = False + if self.selected_file and self.selected_file.lower().endswith( + (".srt", ".ass", ".vtt") + ): + is_subtitle_input = True + + if self.selected_lang not in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION: + self.subtitle_combo.setEnabled(False) + self.subtitle_format_combo.setEnabled(False) + return + + # Only enable subtitle_combo if it's NOT a subtitle input + self.subtitle_combo.setEnabled(not is_subtitle_input) + self.subtitle_format_combo.setEnabled(True) + + is_english = self.selected_lang in ["a", "b"] + + # Items to keep enabled for non-English + allowed_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma"] + + model = self.subtitle_combo.model() + for i in range(self.subtitle_combo.count()): + text = self.subtitle_combo.itemText(i) + item = model.item(i) + if not item: + continue + + if is_english: + item.setEnabled(True) + else: + if text in allowed_modes: + item.setEnabled(True) + else: + item.setEnabled(False) + + # If current selection is disabled, switch to a valid one + current_text = self.subtitle_combo.currentText() + current_idx = self.subtitle_combo.currentIndex() + current_item = model.item(current_idx) + + if current_item and not current_item.isEnabled(): + # Switch to "Sentence" if available, else "Disabled" + sentence_idx = self.subtitle_combo.findText("Sentence") + if sentence_idx >= 0: + self.subtitle_combo.setCurrentIndex(sentence_idx) + else: + self.subtitle_combo.setCurrentIndex(0) # Disabled + + self.subtitle_mode = self.subtitle_combo.currentText() + + def on_voice_changed(self, index): + voice = self.voice_combo.itemData(index) + self.selected_voice, self.selected_lang = voice, voice[0] + self.config["selected_voice"] = voice + save_config(self.config) + # Enable/disable subtitle options based on language + self.update_subtitle_options_availability() + + def on_voice_combo_changed(self, index): + data = self.voice_combo.itemData(index) + if isinstance(data, str) and data.startswith("profile:"): + pname = data.split(":", 1)[1] + self.selected_profile_name = pname + from abogen.voice_profiles import load_profiles + + entry = load_profiles().get(pname, {}) + # set mixed voices and language + if isinstance(entry, dict): + self.mixed_voice_state = entry.get("voices", []) + self.selected_lang = entry.get("language") + else: + self.mixed_voice_state = entry + self.selected_lang = entry[0][0] if entry and entry[0] else None + self.selected_voice = None + self.config["selected_profile_name"] = pname + self.config.pop("selected_voice", None) + save_config(self.config) + # enable subtitles based on profile language + self.update_subtitle_options_availability() + else: + self.mixed_voice_state = None + self.selected_profile_name = None + self.selected_voice, self.selected_lang = data, data[0] + self.config["selected_voice"] = data + if "selected_profile_name" in self.config: + del self.config["selected_profile_name"] + save_config(self.config) + self.update_subtitle_options_availability() + + def update_subtitle_combo_for_profile(self, profile_name): + from abogen.voice_profiles import load_profiles + + entry = load_profiles().get(profile_name, {}) + lang = entry.get("language") if isinstance(entry, dict) else None + enable = lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + self.subtitle_combo.setEnabled(enable) + self.subtitle_format_combo.setEnabled(enable) + + def populate_profiles_in_voice_combo(self): + # preserve current voice or profile + current = self.voice_combo.currentData() + self.voice_combo.blockSignals(True) + self.voice_combo.clear() + # re-add profiles + profile_icon = QIcon(get_resource_path("abogen.assets", "profile.png")) + for pname in load_profiles().keys(): + self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}") + # re-add voices + for v in VOICES_INTERNAL: + icon = QIcon() + flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png") + if flag_path and os.path.exists(flag_path): + icon = QIcon(flag_path) + self.voice_combo.addItem(icon, f"{v}", v) + # restore selection + idx = -1 + if self.selected_profile_name: + idx = self.voice_combo.findData(f"profile:{self.selected_profile_name}") + elif current: + idx = self.voice_combo.findData(current) + if idx >= 0: + self.voice_combo.setCurrentIndex(idx) + # Also update subtitle combo for selected profile + data = self.voice_combo.itemData(idx) + if isinstance(data, str) and data.startswith("profile:"): + pname = data.split(":", 1)[1] + self.update_subtitle_combo_for_profile(pname) + self.voice_combo.blockSignals(False) + # If no profiles exist, clear selected_profile_name from config + if not load_profiles(): + if "selected_profile_name" in self.config: + del self.config["selected_profile_name"] + save_config(self.config) + + def convert_input_box_to_log(self): + self.input_box.hide() + self.log_text.show() + self.log_text.clear() + QApplication.processEvents() + + def restore_input_box(self): + self.log_text.hide() + self.input_box.show() + + def update_log(self, message): + # Use signal-based approach for thread-safe logging + if QThread.currentThread() != QApplication.instance().thread(): + # We're in a background thread, emit signal for the main thread + self.log_signal.emit_log(message) + return + + # Direct update if already on main thread + self._update_log_main_thread(message) + + def _update_log_main_thread(self, message): + txt = self.log_text + sb = txt.verticalScrollBar() + at_bottom = sb.value() == sb.maximum() + + cursor = txt.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + + fmt = cursor.charFormat() + if isinstance(message, tuple): + text, spec = message + fmt.setForeground(QColor(LOG_COLOR_MAP.get(spec, COLORS["LIGHT_DISABLED"]))) + else: + text = str(message) + fmt.clearForeground() + cursor.setCharFormat(fmt) + cursor.insertText(text + "\n") + + doc = txt.document() + excess = doc.blockCount() - self.log_window_max_lines + if excess > 0: + start = doc.findBlockByNumber(0).position() + end = doc.findBlockByNumber(excess).position() + trim_cursor = QTextCursor(doc) + trim_cursor.setPosition(start) + trim_cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor) + trim_cursor.removeSelectedText() + + if at_bottom: + sb.setValue(sb.maximum()) + + def _get_queue_progress_format(self, value=None): + """Return the progress bar format string for queue mode.""" + if ( + hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") + ): + N = self.current_queue_index + 1 + M = len(self.queued_items) + percent = value if value is not None else self.progress_bar.value() + return f"{percent}% ({N}/{M})" + else: + percent = value if value is not None else self.progress_bar.value() + return f"{percent}%" + + def update_progress(self, value, etr_str): # Add etr_str parameter + # Ensure progress doesn't exceed 99% + if value >= 100: + value = 99 + self.progress_bar.setValue(value) + # Show queue progress if in queue mode + if ( + hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") + ): + N = self.current_queue_index + 1 + M = len(self.queued_items) + self.progress_bar.setFormat(f"{value}% ({N}/{M})") + else: + self.progress_bar.setFormat(f"{value}%") + self.etr_label.setText( + f"Estimated time remaining: {etr_str}" + ) # Update ETR label + self.etr_label.show() # Show only when estimate is ready + + # Disable cancel button if progress is >= 98% + if value >= 98: + self.btn_cancel.setEnabled(False) + + self.progress_bar.repaint() + QApplication.processEvents() + + def enable_disable_queue_buttons(self): + enabled = bool(self.queued_items) + self.btn_clear_queue.setEnabled(enabled) + # Update Manage Queue button text with count + if enabled: + self.btn_manage_queue.setText(f"Manage Queue ({len(self.queued_items)})") + self.btn_manage_queue.setStyleSheet( + f"QPushButton {{ color: {COLORS['GREEN']}; }}" + ) + else: + self.btn_manage_queue.setText("Manage Queue") + self.btn_manage_queue.setStyleSheet("") + # Change main Start button to 'Start queue' if queue has items + if enabled: + self.btn_start.setText(f"Start queue ({len(self.queued_items)})") + try: + self.btn_start.clicked.disconnect() + except Exception: + pass + self.btn_start.clicked.connect(self.start_queue) + else: + self.btn_start.setText("Start") + try: + self.btn_start.clicked.disconnect() + except Exception: + pass + self.btn_start.clicked.connect(self.start_conversion) + + def enqueue(self, item: QueuedItem): + self.queued_items.append(item) + # self.update_log((f"Enqueued: {item.file_name}", True)) + # enable start queue button, manage queue button + self.enable_disable_queue_buttons() + + def get_queue(self): + return self.queued_items + + def add_to_queue(self): + # For epub/pdf, always use the converted txt file (selected_file) + if self.selected_file_type in ["epub", "pdf", "md", "markdown"]: + file_to_queue = self.selected_file + # Use the original file path for save location + save_base_path = ( + self.displayed_file_path if self.displayed_file_path else file_to_queue + ) + else: + file_to_queue = ( + self.displayed_file_path + if self.displayed_file_path + else self.selected_file + ) + save_base_path = file_to_queue # For non-EPUB, it's the same + + if not file_to_queue: + self.input_box.set_error("Please add a file.") + return + actual_subtitle_mode = self.get_actual_subtitle_mode() + voice_formula = self.get_voice_formula() + selected_lang = self.get_selected_lang(voice_formula) + + item_queue = QueuedItem( + file_name=file_to_queue, + lang_code=selected_lang, + speed=self.speed_slider.value() / 100.0, + voice=voice_formula, + save_option=self.save_option, + output_folder=self.selected_output_folder, + subtitle_mode=actual_subtitle_mode, + output_format=self.selected_format, + total_char_count=self.char_count, + replace_single_newlines=self.replace_single_newlines, + use_silent_gaps=self.use_silent_gaps, + subtitle_speed_method=self.subtitle_speed_method, + save_base_path=save_base_path, + save_chapters_separately=getattr(self, "save_chapters_separately", None), + merge_chapters_at_end=getattr(self, "merge_chapters_at_end", None), + ) + + # Prevent adding duplicate items to the queue + for queued_item in self.queued_items: + if ( + queued_item.file_name == item_queue.file_name + and queued_item.lang_code == item_queue.lang_code + and queued_item.speed == item_queue.speed + and queued_item.voice == item_queue.voice + and queued_item.save_option == item_queue.save_option + and queued_item.output_folder == item_queue.output_folder + and queued_item.subtitle_mode == item_queue.subtitle_mode + and queued_item.output_format == item_queue.output_format + and getattr(queued_item, "replace_single_newlines", True) + == item_queue.replace_single_newlines + and getattr(queued_item, "save_base_path", None) + == item_queue.save_base_path + and getattr(queued_item, "save_chapters_separately", None) + == item_queue.save_chapters_separately + and getattr(queued_item, "merge_chapters_at_end", None) + == item_queue.merge_chapters_at_end + ): + QMessageBox.warning( + self, "Duplicate Item", "This item is already in the queue." + ) + return + + self.enqueue(item_queue) + # Clear input after adding to queue + self.input_box.clear_input() + self.input_box_cleared_by_queue = True # Set flag + self.enable_disable_queue_buttons() + + def clear_queue(self): + # Warn user if more than 1 item in the queue before clearing + if len(self.queued_items) > 1: + reply = QMessageBox.question( + self, + "Confirm Clear Queue", + f"Are you sure you want to clear {len(self.queued_items)} items from the queue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + self.queued_items = [] + self.enable_disable_queue_buttons() + + def manage_queue(self): + # show a dialog to manage the queue + dialog = QueueManager(self, self.queued_items) + if dialog.exec() == QDialog.DialogCode.Accepted: + self.queued_items = dialog.get_queue() + + # Reload config to capture the new "Override" setting + # The QueueManager writes to disk, so we must refresh our local copy + self.config = load_config() + + # re-enable/disable buttons based on queue state + self.enable_disable_queue_buttons() + + def start_queue(self): + self.current_queue_index = 0 # Start from the first item + # Set progress bar to 0% (1/M) immediately + if self.queued_items: + self.progress_bar.setValue(0) + self.progress_bar.setFormat(f"0% (1/{len(self.queued_items)})") + self.progress_bar.show() + self.start_next_queued_item() + + def start_next_queued_item(self): + if self.current_queue_index < len(self.queued_items): + queued_item = self.queued_items[self.current_queue_index] + + self.selected_file = queued_item.file_name + self.char_count = queued_item.total_char_count + + # Restore the original file path for save location (Important for EPUB/PDF) + self.displayed_file_path = ( + queued_item.save_base_path or queued_item.file_name + ) + + # Restore chapter options (Structure specific, must be preserved) + self.save_chapters_separately = getattr( + queued_item, "save_chapters_separately", None + ) + self.merge_chapters_at_end = getattr( + queued_item, "merge_chapters_at_end", None + ) + + # CHECK GLOBAL OVERRIDE SETTING + if not self.config.get("queue_override_settings", False): + self.selected_lang = queued_item.lang_code + self.speed_slider.setValue(int(queued_item.speed * 100)) + + # Load the specific voice string + self.selected_voice = queued_item.voice + # Clear complex GUI states so the specific voice string is used + self.mixed_voice_state = None + self.selected_profile_name = None + + self.save_option = queued_item.save_option + self.selected_output_folder = queued_item.output_folder + self.subtitle_mode = queued_item.subtitle_mode + self.selected_format = queued_item.output_format + self.replace_single_newlines = getattr( + queued_item, "replace_single_newlines", True + ) + self.use_silent_gaps = getattr(queued_item, "use_silent_gaps", False) + self.subtitle_speed_method = getattr( + queued_item, "subtitle_speed_method", "tts" + ) + + # This ensures that if conversion.py (or utils) reads from config/disk + # instead of using passed arguments, it sees the correct queue values. + self.config["replace_single_newlines"] = self.replace_single_newlines + self.config["subtitle_mode"] = self.subtitle_mode + self.config["selected_format"] = self.selected_format + self.config["use_silent_gaps"] = self.use_silent_gaps + self.config["subtitle_speed_method"] = self.subtitle_speed_method + + # Sync Voice/Profile in config + self.config["selected_voice"] = self.selected_voice + if "selected_profile_name" in self.config: + del self.config["selected_profile_name"] + + # Note: Speed is already synced via self.speed_slider.setValue() -> update_speed_label() + save_config(self.config) + + self.start_conversion(from_queue=True) + else: + # Queue finished, reset index + self.current_queue_index = 0 + + def queue_item_conversion_finished(self): + # Called after each conversion finishes + self.current_queue_index += 1 + if self.current_queue_index < len(self.queued_items): + self.start_next_queued_item() + else: + self.current_queue_index = 0 # Reset for next time + + def get_voice_formula(self) -> str: + if self.mixed_voice_state: + formula_components = [ + f"{name}*{weight}" for name, weight in self.mixed_voice_state + ] + return " + ".join(filter(None, formula_components)) + else: + return self.selected_voice + + def get_selected_lang(self, voice_formula) -> str: + if self.selected_profile_name: + from abogen.voice_profiles import load_profiles + + entry = load_profiles().get(self.selected_profile_name, {}) + selected_lang = entry.get("language") + else: + selected_lang = self.selected_voice[0] if self.selected_voice else None + # fallback: extract from formula if missing + if not selected_lang: + m = re.search(r"\b([a-z])", voice_formula) + selected_lang = m.group(1) if m else None + return selected_lang + + def get_actual_subtitle_mode(self) -> str: + return "Disabled" if not self.subtitle_combo.isEnabled() else self.subtitle_mode + + def start_conversion(self, from_queue=False): + if not self.selected_file: + self.input_box.set_error("Please add a file.") + return + + # Ensure we honor the currently selected save option when not running from queue + if not from_queue: + current_option = self.save_combo.currentText() + self.save_option = current_option + self.config["save_option"] = current_option + # If user is not choosing a specific folder, clear any residual folder + if current_option != "Choose output folder": + self.selected_output_folder = None + self.config["selected_output_folder"] = None + save_config(self.config) + + prevent_sleep_start() + self.is_converting = True + self.convert_input_box_to_log() + self.progress_bar.setValue(0) + # Show queue progress if in queue mode + if ( + from_queue + and hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") + ): + N = self.current_queue_index + 1 + M = len(self.queued_items) + self.progress_bar.setFormat(f"0% ({N}/{M})") + else: + self.progress_bar.setFormat("%p%") # Reset format initially + self.etr_label.hide() # Hide ETR label initially + self.controls_widget.hide() + self.queue_row_widget.hide() # Hide queue row when process starts + self.progress_bar.show() + self.btn_cancel.show() + QApplication.processEvents() + self.btn_cancel.setEnabled(False) + self.start_time = time.time() + self.finish_widget.hide() + speed = self.speed_slider.value() / 100.0 + + # Get the display file path for logs + display_path = ( + self.displayed_file_path if self.displayed_file_path else self.selected_file + ) + + # Get file size string + try: + file_size_str = self.input_box._human_readable_size( + os.path.getsize(self.selected_file) + ) + except Exception: + file_size_str = "Unknown" + + # pipeline_loaded_callback remains unchanged + def pipeline_loaded_callback(np_module, kpipeline_class, error): + if error: + self.update_log((f"Error loading numpy or KPipeline: {error}", "red")) + prevent_sleep_end() + return + + self.btn_cancel.setEnabled(True) + + # Override subtitle_mode to "Disabled" if subtitle_combo is disabled + actual_subtitle_mode = self.get_actual_subtitle_mode() + + # if voice formula is not None, use the selected voice + voice_formula = self.get_voice_formula() + # determine selected language: use profile setting if profile selected, else voice code + selected_lang = self.get_selected_lang(voice_formula) + + self.conversion_thread = ConversionThread( + self.selected_file, + selected_lang, + speed, + voice_formula, + self.save_option, + self.selected_output_folder, + subtitle_mode=actual_subtitle_mode, + output_format=self.selected_format, + np_module=np_module, + kpipeline_class=kpipeline_class, + start_time=self.start_time, + total_char_count=self.char_count, + use_gpu=self.gpu_ok, + from_queue=from_queue, + save_base_path=self.displayed_file_path, # Pass the save base path (original file for EPUB) + ) # Use gpu_ok status + # Pass the displayed file path to the log_updated signal handler in ConversionThread + self.conversion_thread.display_path = display_path + # Pass the file size string + self.conversion_thread.file_size_str = file_size_str + # Pass max_subtitle_words from config + self.conversion_thread.max_subtitle_words = self.max_subtitle_words + # Pass silence_duration from config + self.conversion_thread.silence_duration = self.silence_duration + # Pass replace_single_newlines setting + self.conversion_thread.replace_single_newlines = ( + self.replace_single_newlines + ) + # Pass use_silent_gaps setting + self.conversion_thread.use_silent_gaps = self.use_silent_gaps + # Pass subtitle_speed_method setting + self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method + # Pass use_spacy_segmentation setting + self.conversion_thread.use_spacy_segmentation = self.use_spacy_segmentation + # Pass separate_chapters_format setting + self.conversion_thread.separate_chapters_format = ( + self.separate_chapters_format + ) + # Pass subtitle format setting + self.conversion_thread.subtitle_format = self.config.get( + "subtitle_format", "ass_centered_narrow" + ) + # Pass chapter count for EPUB or PDF files + if self.selected_file_type in ["epub", "pdf", "md", "markdown"] and hasattr( + self, "selected_chapters" + ): + self.conversion_thread.chapter_count = len(self.selected_chapters) + # Pass save_chapters_separately flag if available + self.conversion_thread.save_chapters_separately = getattr( + self, "save_chapters_separately", False + ) + # Pass merge_chapters_at_end flag if available + self.conversion_thread.merge_chapters_at_end = getattr( + self, "merge_chapters_at_end", True + ) + self.conversion_thread.progress_updated.connect(self.update_progress) + self.conversion_thread.log_updated.connect(self.update_log) + self.conversion_thread.conversion_finished.connect( + self.on_conversion_finished + ) + + # Connect chapters_detected signal + self.conversion_thread.chapters_detected.connect( + self.show_chapter_options_dialog + ) + + self.conversion_thread.start() + QApplication.processEvents() + + # Run GPU acceleration and module loading in a background thread + def gpu_and_load(): + self.update_log("Checking GPU acceleration...") + # Pass the use_gpu setting from the checkbox + gpu_msg, gpu_ok = get_gpu_acceleration(self.gpu_checkbox.isChecked()) + # Store gpu_ok status to use when creating the conversion thread + self.gpu_ok = gpu_ok + self.update_log((gpu_msg, gpu_ok)) + self.update_log("Loading modules...") + load_thread = LoadPipelineThread(pipeline_loaded_callback) + load_thread.start() + + threading.Thread(target=gpu_and_load, daemon=True).start() + + def show_queue_summary(self): + """Show a summary dialog after queue finishes.""" + if not self.queued_items: + return + + # Check if override was active (this determines which settings were ACTUALLY used) + override_active = self.config.get("queue_override_settings", False) + + # If override is ON, capture the global settings that were used for processing + if override_active: + g_voice = self.get_voice_formula() + g_lang = self.get_selected_lang(g_voice) + g_speed = self.speed_slider.value() / 100.0 + g_sub_mode = self.get_actual_subtitle_mode() + g_format = self.selected_format + g_newlines = self.replace_single_newlines + g_silent_gaps = self.use_silent_gaps + g_speed_method = self.subtitle_speed_method + + # Build HTML summary (Default Styling) + summary_html = "" + + header_text = "Queue finished" + if override_active: + header_text += " (Global Settings Applied)" + + summary_html += ( + f"

    {header_text}

    " + f"Processed {len(self.queued_items)} items:

    " + ) + + for idx, item in enumerate(self.queued_items, 1): + # Resolve Effective Settings + if override_active: + eff_lang = g_lang + eff_voice = g_voice + eff_speed = g_speed + eff_sub_mode = g_sub_mode + eff_format = g_format + eff_newlines = g_newlines + eff_silent = g_silent_gaps + eff_method = g_speed_method + else: + eff_lang = item.lang_code + eff_voice = item.voice + eff_speed = item.speed + eff_sub_mode = item.subtitle_mode + eff_format = item.output_format + eff_newlines = getattr(item, "replace_single_newlines", True) + eff_silent = getattr(item, "use_silent_gaps", False) + eff_method = getattr(item, "subtitle_speed_method", "tts") + + # Retrieve File-Specific Data (Never Overridden) + eff_chars = item.total_char_count + eff_input = item.file_name + eff_output = getattr(item, "output_path", "Unknown") + eff_save_sep = getattr(item, "save_chapters_separately", None) + eff_merge = getattr(item, "merge_chapters_at_end", None) + + # --- Construct Display Block --- + summary_html += ( + f"{idx}) {os.path.basename(eff_input)}
    " + f"Language: {eff_lang}
    " + f"Voice: {eff_voice}
    " + f"Speed: {eff_speed}
    " + f"Characters: {eff_chars}
    " + f"Format: {eff_format}
    " + f"Subtitle Mode: {eff_sub_mode}
    " + f"Method: {eff_method}
    " + f"Silent Gaps: {eff_silent}
    " + f"Repl. Newlines: {eff_newlines}
    " + ) + + # Book/Chapter specific options + if eff_save_sep is not None: + summary_html += f"Split Chapters: {eff_save_sep}
    " + if eff_save_sep and eff_merge is not None: + summary_html += f"Merge End: {eff_merge}
    " + + summary_html += ( + f"Input: {eff_input}
    " + f"Output: {eff_output}

    " + ) + + summary_html += "" + + dialog = QDialog(self) + dialog.setWindowTitle("Queue Summary") + # Allow resizing + dialog.resize(550, 650) + + layout = QVBoxLayout(dialog) + text_edit = QTextEdit(dialog) + text_edit.setReadOnly(True) + text_edit.setHtml(summary_html) + layout.addWidget(text_edit) + + close_btn = QPushButton("Close", dialog) + close_btn.setFixedHeight(36) + close_btn.clicked.connect(dialog.accept) + layout.addWidget(close_btn) + + dialog.setLayout(layout) + dialog.setMinimumSize(400, 300) + dialog.setSizeGripEnabled(True) + dialog.exec() + + def on_conversion_finished(self, message, output_path): + prevent_sleep_end() + if message == "Cancelled": + self.etr_label.hide() # Hide ETR label + self.progress_bar.hide() + self.btn_cancel.hide() + self.is_converting = False + self.controls_widget.show() + self.finish_widget.hide() + self.restore_input_box() + display_path = ( + self.displayed_file_path + if self.displayed_file_path + else self.selected_file + ) + # Only repopulate if not cleared by queue + if not getattr(self, "input_box_cleared_by_queue", False): + if display_path and os.path.exists(display_path): + self.input_box.set_file_info(display_path) + else: + self.input_box.clear_input() + else: + self.input_box.clear_input() + return + + self.update_log(message) + if output_path: + self.last_output_path = output_path + # Store output_path in the current queued item if in queue mode + if self.queued_items and self.current_queue_index < len(self.queued_items): + self.queued_items[self.current_queue_index].output_path = output_path + + self.etr_label.hide() # Hide ETR label + self.progress_bar.setValue(100) + self.progress_bar.hide() + self.btn_cancel.hide() + self.is_converting = False + elapsed = int(time.time() - self.start_time) + h, m, s = elapsed // 3600, (elapsed % 3600) // 60, elapsed % 60 + self.update_log((f"\nTime elapsed: {h:02d}:{m:02d}:{s:02d}", "grey")) + + # Default to showing the button + show_open_file_button = True + # Check conditions to hide the button (only if flags exist for the completed conversion) + save_sep = getattr(self, "save_chapters_separately", False) + merge_end = getattr( + self, "merge_chapters_at_end", True + ) # Default to True if flag doesn't exist + if save_sep and not merge_end: + show_open_file_button = False + + if self.open_file_btn: + self.open_file_btn.setVisible(show_open_file_button) + + # Only show finish_widget if queue is done + if ( + self.current_queue_index + 1 >= len(self.queued_items) + or not self.queued_items + ): + # Queue finished, show finish screen + self.controls_widget.hide() + self.finish_widget.show() + sb = self.log_text.verticalScrollBar() + sb.setValue(sb.maximum()) + save_config(self.config) + # Show queue summary if more than one item + if len(self.queued_items) > 1: + self.show_queue_summary() + else: + # More items in queue: clear log and reload for next item + self.log_text.clear() + QApplication.processEvents() + + # Start new queued item, if we're using a queued conversion + self.queue_item_conversion_finished() + + def reset_ui(self): + try: + self.etr_label.hide() # Hide ETR label + self.progress_bar.setValue(0) + self.progress_bar.hide() + self.selected_file = self.selected_file_type = self.selected_book_path = ( + None + ) + self.selected_chapters = set() # Reset selected chapters + + # Ensure open file button is visible when resetting + if self.open_file_btn: + self.open_file_btn.show() + self.controls_widget.show() + self.queue_row_widget.show() # Show queue row on reset + self.finish_widget.hide() + self.btn_start.setText("Start") + # Disconnect only if connected, then reconnect + try: + self.btn_start.clicked.disconnect() + except TypeError: + pass # Ignore error if not connected + self.btn_start.clicked.connect(self.start_conversion) + self.enable_disable_queue_buttons() + self.restore_input_box() + self.input_box.clear_input() # Reset text and style + # Trigger the "Clear Queue" button (simulate user click) + self.btn_clear_queue.click() + except Exception as e: + self._show_error_message_box("Reset Error", f"Could not reset UI:\n{e}") + + def go_back_ui(self): + self.finish_widget.hide() + self.controls_widget.show() + self.queue_row_widget.show() # Show queue row on go back + self.progress_bar.hide() + self.restore_input_box() + self.log_text.clear() + + # Use displayed_file_path instead of selected_file for EPUBs or PDFs + display_path = ( + self.displayed_file_path if self.displayed_file_path else self.selected_file + ) + + # Only repopulate if not cleared by queue + if not getattr(self, "input_box_cleared_by_queue", False): + if display_path and os.path.exists(display_path): + self.input_box.set_file_info(display_path) + else: + self.input_box.clear_input() + else: + self.input_box.clear_input() + + # Ensure open file button is visible when going back + if self.open_file_btn: + self.open_file_btn.show() + + def on_save_option_changed(self, option): + self.save_option = option + self.config["save_option"] = option + if option == "Choose output folder": + try: + folder = QFileDialog.getExistingDirectory( + self, "Select Output Folder", "" + ) + if folder: + self.selected_output_folder = folder + self.save_path_label.setText(folder) + self.save_path_row_widget.show() + self.config["selected_output_folder"] = folder + else: + self.save_option = "Save next to input file" + self.save_combo.setCurrentText(self.save_option) + self.config["save_option"] = self.save_option + except Exception as e: + self._show_error_message_box( + "Folder Dialog Error", f"Could not open folder dialog:\n{e}" + ) + self.save_option = "Save next to input file" + self.save_combo.setCurrentText(self.save_option) + self.config["save_option"] = self.save_option + else: + self.save_path_row_widget.hide() + self.selected_output_folder = None + self.config["selected_output_folder"] = None + save_config(self.config) + + def go_to_file(self): + path = self.last_output_path + if not path: + return + try: + # Check if path is a directory (for multiple chapter files) + if os.path.isdir(path): + folder = path + else: + folder = os.path.dirname(path) + QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) + except Exception as e: + self._show_error_message_box( + "Open Folder Error", f"Could not open folder:\n{e}" + ) + + def open_file(self): + path = self.last_output_path + if not path: + return + try: + # Check if path exists and is a file before opening + if os.path.exists(path): + if os.path.isdir(path): + self._show_error_message_box( + "Open File Error", + "Cannot open a directory as a file. Please use 'Go to folder' instead.", + ) + return + QDesktopServices.openUrl(QUrl.fromLocalFile(path)) + else: + self._show_error_message_box( + "Open File Error", f"File not found: {path}" + ) + except Exception as e: + self._show_error_message_box( + "Open File Error", f"Could not open file:\n{e}" + ) + + def _get_preview_cache_path(self): + """Generate the expected cache path for the current voice settings.""" + speed = self.speed_slider.value() / 100.0 + voice_to_cache = "" + lang_to_cache = "" + + if self.mixed_voice_state: + components = [f"{name}*{weight}" for name, weight in self.mixed_voice_state] + voice_formula = " + ".join(filter(None, components)) + voice_to_cache = voice_formula + if self.selected_profile_name: + from abogen.voice_profiles import load_profiles + + entry = load_profiles().get(self.selected_profile_name, {}) + lang_to_cache = entry.get("language") + else: + lang_to_cache = self.selected_lang + if not lang_to_cache and self.mixed_voice_state: + lang_to_cache = ( + self.mixed_voice_state[0][0][0] + if self.mixed_voice_state and self.mixed_voice_state[0][0] + else None + ) + elif self.selected_voice: + lang_to_cache = self.selected_voice[0] + voice_to_cache = self.selected_voice + else: # No voice or profile selected + return None + + if not lang_to_cache or not voice_to_cache: # Not enough info + return None + + cache_dir = get_user_cache_path("preview_cache") + + if "*" in voice_to_cache: # Voice formula + voice_id = ( + f"voice_formula_{hashlib.md5(voice_to_cache.encode()).hexdigest()[:8]}" + ) + else: # Single voice + voice_id = voice_to_cache + + filename = f"{voice_id}_{lang_to_cache}_{speed:.2f}.wav" + return os.path.join(cache_dir, filename) + + def preview_voice(self): + if self.preview_playing: + try: + if self.play_audio_thread and self.play_audio_thread.isRunning(): + # Call the stop method on PlayAudioThread to safely handle stopping + self.play_audio_thread.stop() + self.play_audio_thread.wait(500) # Wait a bit + except Exception as e: + print(f"Error stopping preview audio: {e}") + self._preview_cleanup() + return + + if hasattr(self, "preview_thread") and self.preview_thread.isRunning(): + return + + # Check for cache first + cached_path = self._get_preview_cache_path() + if cached_path and os.path.exists(cached_path): + print(f"Cache hit for {cached_path}") + self.btn_preview.setEnabled(False) # Disable button briefly + self.voice_combo.setEnabled(False) + self.btn_voice_formula_mixer.setEnabled(False) + self.btn_start.setEnabled(False) + + # Directly play from cache + self.preview_playing = True + self.btn_preview.setIcon(self.stop_icon) + self.btn_preview.setToolTip("Stop preview") + self.btn_preview.setEnabled(True) + + def cleanup_cached_play(): + self._preview_cleanup() + + try: + # Ensure pygame mixer is initialized for the audio thread + import pygame + + if not pygame.mixer.get_init(): + pygame.mixer.init() + + self.play_audio_thread = PlayAudioThread(cached_path) + self.play_audio_thread.finished.connect(cleanup_cached_play) + self.play_audio_thread.error.connect( + lambda msg: ( + self._show_preview_error_box(msg), + cleanup_cached_play(), + ) + ) + self.play_audio_thread.start() + except Exception as e: + self._show_error_message_box( + "Preview Error", f"Could not play cached preview audio:\n{e}" + ) + cleanup_cached_play() + return + + # If no cache hit, proceed to load pipeline and generate + self.btn_preview.setEnabled(False) + self.btn_preview.setToolTip("Loading...") + self.voice_combo.setEnabled(False) + self.btn_voice_formula_mixer.setEnabled(False) # Disable mixer button + self.btn_start.setEnabled(False) # Disable start button during preview + + # Start loading animation - ensure signal connection is always active + if hasattr(self, "loading_movie"): + # Disconnect previous connections to avoid multiple connections + try: + self.loading_movie.frameChanged.disconnect() + except TypeError: + pass # Ignore error if not connected + + # Reconnect the signal + self.loading_movie.frameChanged.connect( + lambda: self.btn_preview.setIcon( + QIcon(self.loading_movie.currentPixmap()) + ) + ) + self.loading_movie.start() + + def pipeline_loaded_callback(np_module, kpipeline_class, error): + self._on_pipeline_loaded_for_preview(np_module, kpipeline_class, error) + + load_thread = LoadPipelineThread(pipeline_loaded_callback) + load_thread.start() + + def _on_pipeline_loaded_for_preview(self, np_module, kpipeline_class, error): + # stop loading animation and restore icon on error + if error: + self.loading_movie.stop() + self._show_error_message_box( + "Loading Error", f"Error loading numpy or KPipeline: {error}" + ) + self.btn_preview.setIcon(self.play_icon) + self.btn_preview.setEnabled(True) + self.btn_preview.setToolTip("Preview selected voice") + self.voice_combo.setEnabled(True) + self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button + self.btn_start.setEnabled(True) # Re-enable start button on error + return + + # Support preview for voice profiles + speed = self.speed_slider.value() / 100.0 + if self.mixed_voice_state: + # Build voice formula string + components = [f"{name}*{weight}" for name, weight in self.mixed_voice_state] + voice = " + ".join(filter(None, components)) + # determine language: use profile setting, else explicit mixer selection, else fallback to first voice code + if self.selected_profile_name: + from abogen.voice_profiles import load_profiles + + entry = load_profiles().get(self.selected_profile_name, {}) + lang = entry.get("language") + else: + lang = self.selected_lang + if not lang and self.mixed_voice_state: + lang = ( + self.mixed_voice_state[0][0][0] + if self.mixed_voice_state and self.mixed_voice_state[0][0] + else None + ) + else: + lang = self.selected_voice[0] + voice = self.selected_voice + + # use same gpu/cpu logic as in conversion + gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu) + + self.preview_thread = VoicePreviewThread( + np_module, kpipeline_class, lang, voice, speed, gpu_ok + ) + self.preview_thread.finished.connect(self._play_preview_audio) + self.preview_thread.error.connect(self._preview_error) + self.preview_thread.start() + + def _play_preview_audio(self, from_cache=True): # from_cache default is now False + # If preview_thread is the source, get temp_wav from it + if hasattr(self, "preview_thread") and not from_cache: + temp_wav = self.preview_thread.temp_wav + elif from_cache: # This case is now handled before calling _play_preview_audio + cached_path = self._get_preview_cache_path() + if cached_path and os.path.exists(cached_path): + temp_wav = cached_path + else: # Should not happen if cache check was done + self._show_error_message_box( + "Preview Error", + "Cache file expected but not found, please try again.", + ) + self._preview_cleanup() + return + else: # Should have temp_wav from preview_thread or handled by cache check + self._show_error_message_box( + "Preview Error", "Preview audio path not found." + ) + self._preview_cleanup() + return + + if not temp_wav: + if hasattr(self, "loading_movie"): + self.loading_movie.stop() + self._show_error_message_box( + "Preview Error", "Preview error: No audio generated." + ) + self._preview_cleanup() + return + + # stop loading animation, switch to stop icon + if hasattr(self, "loading_movie"): + self.loading_movie.stop() + self.preview_playing = True + self.btn_preview.setIcon(self.stop_icon) + self.btn_preview.setToolTip("Stop preview") + self.btn_preview.setEnabled(True) + + def cleanup(): + # Only remove if not from cache AND it's a temp file from VoicePreviewThread + if ( + not from_cache + and hasattr(self, "preview_thread") + and hasattr(self.preview_thread, "temp_wav") + and self.preview_thread.temp_wav == temp_wav + ): + try: + if os.path.exists( + temp_wav + ): # Ensure it exists before trying to remove + os.remove(temp_wav) + except Exception: + pass + self._preview_cleanup() + + try: + # Ensure pygame mixer is initialized for the audio thread + import pygame + + if not pygame.mixer.get_init(): + pygame.mixer.init() + + self.play_audio_thread = PlayAudioThread(temp_wav) + self.play_audio_thread.finished.connect(cleanup) + self.play_audio_thread.error.connect( + lambda msg: (self._show_preview_error_box(msg), cleanup()) + ) + self.play_audio_thread.start() + except Exception as e: + self._show_error_message_box( + "Preview Error", f"Could not play preview audio:\n{e}" + ) + cleanup() + + def _show_error_message_box(self, title, message): + box = QMessageBox(self) + box.setIcon(QMessageBox.Icon.Critical) + box.setWindowTitle(title) + box.setText(message) + copy_btn = QPushButton("Copy") + box.addButton(copy_btn, QMessageBox.ButtonRole.ActionRole) + box.addButton(QMessageBox.StandardButton.Ok) + copy_btn.clicked.connect(lambda: QApplication.clipboard().setText(message)) + box.exec() + + def _show_preview_error_box(self, msg): + self._show_error_message_box("Preview Error", f"Preview error: {msg}") + + def _preview_cleanup(self): + self.preview_playing = False + if hasattr(self, "loading_movie"): + self.loading_movie.stop() + try: + if hasattr(self, "loading_movie"): + self.loading_movie.frameChanged.disconnect() + except Exception: + pass # Ignore error if not connected + self.btn_preview.setIcon(self.play_icon) + self.btn_preview.setToolTip("Preview selected voice") + self.btn_preview.setEnabled(True) + self.voice_combo.setEnabled(True) + self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button + self.btn_start.setEnabled(True) + + def _preview_error(self, msg): + self._show_error_message_box("Preview Error", f"Preview error: {msg}") + self._preview_cleanup() + + def cancel_conversion(self): + if self.is_converting: + box = QMessageBox(self) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle("Cancel Conversion") + box.setText( + "A conversion is currently running. Are you sure you want to cancel?" + ) + box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + box.setDefaultButton(QMessageBox.StandardButton.No) + if box.exec() != QMessageBox.StandardButton.Yes: + return + try: + if ( + hasattr(self, "conversion_thread") + and self.conversion_thread.isRunning() + ): + if not hasattr(self, "_conversion_lock"): + self._conversion_lock = threading.Lock() + + def _cancel(): + with self._conversion_lock: + self.conversion_thread.cancel() # <-- Use cancel() method + self.conversion_thread.wait() + + threading.Thread(target=_cancel, daemon=True).start() + + self.is_converting = False + self.etr_label.hide() # Hide ETR label + self.progress_bar.hide() + self.btn_cancel.hide() + self.controls_widget.show() + self.queue_row_widget.show() # Show queue row on cancel + self.finish_widget.hide() + self.restore_input_box() + self.log_text.clear() + display_path = ( + self.displayed_file_path + if self.displayed_file_path + else self.selected_file + ) + # Only repopulate if not cleared by queue + if not getattr(self, "input_box_cleared_by_queue", False): + if display_path and os.path.exists(display_path): + self.input_box.set_file_info(display_path) + else: + self.input_box.clear_input() + else: + self.input_box.clear_input() + prevent_sleep_end() + except Exception as e: + self._show_error_message_box( + "Cancel Error", f"Could not cancel conversion:\n{e}" + ) + + def on_subtitle_mode_changed(self, mode): + self.subtitle_mode = mode + self.config["subtitle_mode"] = mode + save_config(self.config) + # Disable subtitle format combo if subtitles are disabled + if mode == "Disabled": + self.subtitle_format_combo.setEnabled(False) + else: + self.subtitle_format_combo.setEnabled(True) + # If highlighting mode selected, SRT is not supported. Disable SRT option and + # switch away from it if currently selected. + try: + idx_srt = self.subtitle_format_combo.findData("srt") + if mode == "Sentence + Highlighting": + if idx_srt >= 0: + item = self.subtitle_format_combo.model().item(idx_srt) + if item is not None: + item.setEnabled(False) + # If current format is SRT, switch to a compatible ASS format + if self.subtitle_format_combo.currentData() == "srt": + new_idx = self.subtitle_format_combo.findData("ass_centered_narrow") + if new_idx >= 0: + self.subtitle_format_combo.setCurrentIndex(new_idx) + self.set_subtitle_format( + self.subtitle_format_combo.itemData(new_idx) + ) + else: + # Re-enable SRT option when not in highlighting mode + if idx_srt >= 0: + item = self.subtitle_format_combo.model().item(idx_srt) + if item is not None: + item.setEnabled(True) + except Exception: + # Ignore errors interacting with model (defensive) + pass + + def on_format_changed(self, fmt): + self.selected_format = fmt + self.config["selected_format"] = fmt + save_config(self.config) + + def on_gpu_setting_changed(self, state): + self.use_gpu = state == Qt.CheckState.Checked.value + self.config["use_gpu"] = self.use_gpu + save_config(self.config) + + def cleanup_conversion_thread(self): + # Stop conversion thread + if ( + hasattr(self, "conversion_thread") + and self.conversion_thread is not None + and self.conversion_thread.isRunning() + ): + self.conversion_thread.cancel() + self.conversion_thread.wait() + + def cleanup_preview_threads(self): + # Stop preview generation thread + if ( + hasattr(self, "preview_thread") + and self.preview_thread is not None + and self.preview_thread.isRunning() + ): + self.preview_thread.terminate() + self.preview_thread.wait() + + # Stop audio playback thread + if ( + hasattr(self, "play_audio_thread") + and self.play_audio_thread is not None + and self.play_audio_thread.isRunning() + ): + self.play_audio_thread.stop() + self.play_audio_thread.wait() + + # Cleanup pygame mixer if initialized + try: + pygame = sys.modules.get("pygame") + if pygame and pygame.mixer.get_init(): + pygame.mixer.quit() + except Exception: + pass + + def closeEvent(self, event): + if self.is_converting: + box = QMessageBox(self) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle("Conversion in Progress") + box.setText( + "A conversion is currently running. Are you sure you want to exit?" + ) + box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + box.setDefaultButton(QMessageBox.StandardButton.No) + if box.exec() == QMessageBox.StandardButton.Yes: + self.cleanup_conversion_thread() + self.cleanup_preview_threads() + event.accept() + else: + event.ignore() + else: + self.cleanup_conversion_thread() + self.cleanup_preview_threads() + event.accept() + + def show_chapter_options_dialog(self, chapter_count): + """Show dialog to ask user about chapter processing options when chapters are detected in a .txt file""" + # Check if this is a timestamp detection (-1) or chapter detection + if chapter_count == -1: + from abogen.conversion import TimestampDetectionDialog + + dialog = TimestampDetectionDialog(parent=self) + dialog.setWindowModality(Qt.WindowModality.ApplicationModal) + + # Dialog always accepts (Yes or No), never cancels the conversion + dialog.exec() + treat_as_subtitle = dialog.use_timestamps() + if ( + hasattr(self, "conversion_thread") + and self.conversion_thread.isRunning() + ): + self.conversion_thread.set_timestamp_response(treat_as_subtitle) + return + + # Normal chapter detection + from abogen.conversion import ChapterOptionsDialog + + dialog = ChapterOptionsDialog(chapter_count, parent=self) + dialog.setWindowModality(Qt.WindowModality.ApplicationModal) + + if dialog.exec() == QDialog.DialogCode.Accepted: + options = dialog.get_options() + if ( + hasattr(self, "conversion_thread") + and self.conversion_thread.isRunning() + ): + self.conversion_thread.set_chapter_options(options) + else: + self.cancel_conversion() + + def apply_theme(self, theme): + + app = QApplication.instance() + is_windows = platform.system() == "Windows" + available_styles = [s.lower() for s in QStyleFactory.keys()] + + def is_windows_dark_mode(): + try: + import winreg + + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", + ) as key: + value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") + return value == 0 + except Exception: + return False + + # --- Theme selection logic --- + def set_dark_palette(): + palette = QPalette() + dark_bg = QColor(COLORS["DARK_BG"]) + base_bg = QColor(COLORS["DARK_BASE"]) + alt_bg = QColor(COLORS["DARK_ALT"]) + button_bg = QColor(COLORS["DARK_BUTTON"]) + disabled_fg = QColor(COLORS["DARK_DISABLED"]) + palette.setColor(QPalette.ColorRole.Window, dark_bg) + palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Base, base_bg) + palette.setColor(QPalette.ColorRole.AlternateBase, alt_bg) + palette.setColor(QPalette.ColorRole.ToolTipBase, dark_bg) + palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Button, button_bg) + palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white) + # Disabled roles + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, dark_bg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.Button, dark_bg + ) + app.setPalette(palette) + + def set_light_palette(): + palette = QPalette() + disabled_fg = QColor(COLORS["LIGHT_DISABLED"]) + palette.setColor(QPalette.ColorRole.Window, QColor(COLORS["LIGHT_BG"])) + palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.black) + palette.setColor(QPalette.ColorRole.Base, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.AlternateBase, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.ToolTipBase, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.black) + palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.black) + palette.setColor(QPalette.ColorRole.Button, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.black) + # Disabled roles + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, + QPalette.ColorRole.Base, + Qt.GlobalColor.white, + ) + palette.setColor( + QPalette.ColorGroup.Disabled, + QPalette.ColorRole.Button, + Qt.GlobalColor.white, + ) + app.setPalette(palette) + + # --- Dark title bar support for Windows --- + def set_title_bar_dark_mode(window, enable): + if is_windows: + try: + window.update() + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + set_window_attribute = ctypes.windll.dwmapi.DwmSetWindowAttribute + hwnd = int(window.winId()) + value = ctypes.c_int(2 if enable else 0) + set_window_attribute( + hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(value), + ctypes.sizeof(value), + ) + except Exception: + pass + + # Main logic + dark_mode = theme == "dark" or ( + theme == "system" and is_windows and is_windows_dark_mode() + ) + if dark_mode: + app.setStyle("Fusion") + set_dark_palette() + elif (theme == "light" or theme == "system") and is_windows: + if "windowsvista" in available_styles: + app.setStyle("windowsvista") + else: + app.setStyle("Fusion") + app.setPalette(QPalette()) + elif theme == "light": + app.setStyle("Fusion") + set_light_palette() + else: + app.setStyle("Fusion") + app.setPalette(QPalette()) + + # Always set the title bar mode according to the current theme for all top-level widgets + for widget in app.topLevelWidgets(): + set_title_bar_dark_mode(widget, dark_mode) + + # Refresh all top-level widgets + style_name = app.style().objectName() + app.setStyle(style_name) + for widget in app.topLevelWidgets(): + app.style().polish(widget) + widget.update() + + # Remove old event filter if present, then install a new one for dark title bar on new windows + if hasattr(app, "_dark_titlebar_event_filter"): + app.removeEventFilter(app._dark_titlebar_event_filter) + delattr(app, "_dark_titlebar_event_filter") + + def get_dark_mode(): + return theme == "dark" or ( + theme == "system" and is_windows and is_windows_dark_mode() + ) + + app._dark_titlebar_event_filter = DarkTitleBarEventFilter( + is_windows, get_dark_mode, set_title_bar_dark_mode + ) + app.installEventFilter(app._dark_titlebar_event_filter) + + # Save config if changed + if self.config.get("theme", "system") != theme: + self.config["theme"] = theme + save_config(self.config) + + def show_settings_menu(self): + """Show a dropdown menu for settings options.""" + menu = QMenu(self) + + theme_menu = QMenu("Theme", self) + theme_menu.setToolTip("Choose the application theme") + + theme_group = QActionGroup(self) + theme_group.setExclusive(True) + + # Theme options: (internal_value, display_text) + theme_options = [ + ("system", "System"), + ("light", "Light"), + ("dark", "Dark"), + ] + + # Get current theme from config, default to "system" + current_theme = self.config.get("theme", "system") + for value, text in theme_options: + theme_action = QAction(text, self) + theme_action.setCheckable(True) + theme_action.setChecked(current_theme == value) + theme_action.triggered.connect(lambda checked, v=value: self.apply_theme(v)) + theme_group.addAction(theme_action) + theme_menu.addAction(theme_action) + + menu.addMenu(theme_menu) + + # Add separate chapters format option + separate_chapters_format_menu = QMenu("Separate chapters audio format", self) + separate_chapters_format_menu.setToolTip( + "Choose the format for individual chapter files" + ) + + format_group = QActionGroup(self) + format_group.setExclusive(True) + + for format_option in ["wav", "flac", "mp3", "opus"]: + format_action = QAction(format_option, self) + format_action.setCheckable(True) + format_action.setChecked(self.separate_chapters_format == format_option) + format_action.triggered.connect( + lambda checked, fmt=format_option: self.set_separate_chapters_format( + fmt + ) + ) + format_group.addAction(format_action) + separate_chapters_format_menu.addAction(format_action) + + menu.addMenu(separate_chapters_format_menu) + + # Add max words per subtitle option + max_words_action = QAction("Configure max words per subtitle", self) + max_words_action.triggered.connect(self.set_max_subtitle_words) + menu.addAction(max_words_action) + + # Add silence between chapters option + silence_action = QAction("Configure silence between chapters", self) + silence_action.triggered.connect(self.set_silence_between_chapters) + menu.addAction(silence_action) + + max_lines_action = QAction("Configure max lines in log window", self) + max_lines_action.triggered.connect(self.set_max_log_lines) + menu.addAction(max_lines_action) + + # Add separator + menu.addSeparator() + + # Add shortcut to desktop (Windows or Linux) + if platform.system() == "Windows" or platform.system() == "Linux": + # Use extended label on Linux + label = ( + "Create desktop shortcut and install" + if platform.system() == "Linux" + else "Create desktop shortcut" + ) + add_shortcut_action = QAction(label, self) + add_shortcut_action.triggered.connect(self.add_shortcut_to_desktop) + menu.addAction(add_shortcut_action) + + # Add reveal config option + reveal_config_action = QAction("Open configuration directory", self) + reveal_config_action.triggered.connect(self.reveal_config_in_explorer) + menu.addAction(reveal_config_action) + + # Add open cache directory option + open_cache_action = QAction("Open cache directory", self) + open_cache_action.triggered.connect(self.open_cache_directory) + menu.addAction(open_cache_action) + + # Add clear cache files option + clear_cache_action = QAction("Clear cache files", self) + clear_cache_action.triggered.connect(self.clear_cache_files) + menu.addAction(clear_cache_action) + + # Add separator + menu.addSeparator() + + # Add use silent gaps option (for subtitle files) + self.silent_gaps_action = QAction("Use silent gaps between subtitles", self) + self.silent_gaps_action.setCheckable(True) + self.silent_gaps_action.setChecked(self.use_silent_gaps) + self.silent_gaps_action.triggered.connect( + lambda checked: self.toggle_use_silent_gaps(checked) + ) + menu.addAction(self.silent_gaps_action) + + # Subtitle speed adjustment method + speed_method_menu = menu.addMenu("Subtitle speed adjustment method") + speed_method_menu.setToolTip( + "Choose speed adjustment method:\n" + "TTS Regeneration: Better quality\n" + "FFmpeg Time-stretch: Faster processing" + ) + + speed_method_group = QActionGroup(self) + speed_method_group.setExclusive(True) + + for method, label in [ + ("tts", "TTS Regeneration (better quality)"), + ("ffmpeg", "FFmpeg Time-stretch (better speed)"), + ]: + action = QAction(label, speed_method_menu) + action.setCheckable(True) + action.setChecked(self.subtitle_speed_method == method) + action.triggered.connect( + lambda checked, m=method: self.toggle_subtitle_speed_method(m) + ) + speed_method_group.addAction(action) + speed_method_menu.addAction(action) + + self.speed_method_group = speed_method_group + + # Add separator + menu.addSeparator() + + # Add spaCy sentence segmentation option + spacy_action = QAction("Use spaCy for sentence segmentation", self) + spacy_action.setCheckable(True) + spacy_action.setChecked(self.use_spacy_segmentation) + spacy_action.triggered.connect( + lambda checked: self.toggle_spacy_segmentation(checked) + ) + menu.addAction(spacy_action) + + # Add separator + menu.addSeparator() + + # Add "Pre-download models and voices for offline use" option + predownload_action = QAction( + "Pre-download models and voices for offline use", self + ) + predownload_action.triggered.connect(self.show_predownload_dialog) + menu.addAction(predownload_action) + + # Add "Disable Kokoro's internet access" option + disable_kokoro_action = QAction("Disable Kokoro's internet access", self) + disable_kokoro_action.setCheckable(True) + disable_kokoro_action.setChecked( + self.config.get("disable_kokoro_internet", False) + ) + disable_kokoro_action.triggered.connect( + lambda checked: self.toggle_kokoro_internet_access(checked) + ) + menu.addAction(disable_kokoro_action) + + # Add check for updates option + check_updates_action = QAction("Check for updates at startup", self) + check_updates_action.setCheckable(True) + check_updates_action.setChecked(self.config.get("check_updates", True)) + check_updates_action.triggered.connect(self.toggle_check_updates) + menu.addAction(check_updates_action) + + # Add "Reset to default settings" option + reset_defaults_action = QAction("Reset to default settings", self) + reset_defaults_action.triggered.connect(self.reset_to_default_settings) + menu.addAction(reset_defaults_action) + + # Add about action + about_action = QAction("About", self) + about_action.triggered.connect(self.show_about_dialog) + menu.addAction(about_action) + + menu.exec(self.settings_btn.mapToGlobal(QPoint(0, self.settings_btn.height()))) + + def toggle_replace_single_newlines(self, enabled): + self.replace_single_newlines = enabled + self.config["replace_single_newlines"] = enabled + save_config(self.config) + + def toggle_use_silent_gaps(self, enabled): + # Show confirmation dialog with explanation + action = "enable" if enabled else "disable" + message = ( + "When enabled, allows speech to continue naturally into the silent periods between subtitles, " + "preventing unnecessary audio speed-up based on subtitle end timestamps.\n\nWhen disabled, ensures strict subtitle timing where " + f"audio ends exactly when the subtitle ends.\n\nDo you want to {action} this option?" + ) + + reply = QMessageBox.question( + self, + "Use Silent Gaps Between Subtitles", + message, + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) + + if reply == QMessageBox.StandardButton.Ok: + self.use_silent_gaps = enabled + self.config["use_silent_gaps"] = enabled + save_config(self.config) + else: + # Revert the checkbox state if cancelled + self.silent_gaps_action.setChecked(not enabled) + + def toggle_subtitle_speed_method(self, method): + self.subtitle_speed_method = method + self.config["subtitle_speed_method"] = method + save_config(self.config) + + def toggle_spacy_segmentation(self, enabled): + self.use_spacy_segmentation = enabled + self.config["use_spacy_segmentation"] = enabled + save_config(self.config) + + def restart_app(self): + + import sys + + exe = sys.executable + args = sys.argv + + # On Windows, use .exe if available + if platform.system() == "Windows": + script_path = args[0] + if not script_path.lower().endswith(".exe"): + exe_path = os.path.splitext(script_path)[0] + ".exe" + if os.path.exists(exe_path): + args[0] = exe_path + + QProcess.startDetached(exe, args) + QApplication.quit() + + def toggle_kokoro_internet_access(self, disabled): + if disabled: + message = ( + "Disabling Kokoro's internet access will block downloads of models and voices from Hugging Face Hub. " + "This can make processing faster when there is no internet connection, since no requests will be made. " + "The app needs to restart to apply this change.\n\nDo you want to continue?" + ) + else: + message = ( + "Enabling Kokoro's internet access will allow it to download models and voices from Hugging Face Hub. " + "The app needs to restart to apply this change.\n\nDo you want to continue?" + ) + reply = QMessageBox.question( + self, + "Restart Required", + message, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self.config["disable_kokoro_internet"] = disabled + save_config(self.config) + try: + self.restart_app() + except Exception as e: + QMessageBox.critical( + self, "Restart Failed", f"Failed to restart the application:\n{e}" + ) + + def reset_to_default_settings(self): + reply = QMessageBox.question( + self, + "Reset Settings", + "This will reset all settings to their default values and restart the application.\n\nDo you want to continue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + from abogen.utils import get_user_config_path + + config_path = get_user_config_path() + try: + if os.path.exists(config_path): + os.remove(config_path) + self.restart_app() + except Exception as e: + QMessageBox.critical( + self, "Reset Error", f"Could not reset settings:\n{e}" + ) + + def reveal_config_in_explorer(self): + """Open the configuration file location in file explorer.""" + from abogen.utils import get_user_config_path + + try: + config_path = get_user_config_path() + # Open the directory containing the config file + QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(config_path))) + except Exception as e: + QMessageBox.critical( + self, "Config Error", f"Could not open config location:\n{e}" + ) + + def open_cache_directory(self): + """Open the cache directory used by the program.""" + try: + # Get the abogen cache directory + cache_dir = get_user_cache_path() + + # Create the directory if it doesn't exist + if not os.path.exists(cache_dir): + os.makedirs(cache_dir) + + # Open the directory in file explorer + QDesktopServices.openUrl(QUrl.fromLocalFile(cache_dir)) + except Exception as e: + QMessageBox.critical( + self, "Cache Directory Error", f"Could not open cache directory:\n{e}" + ) + + def add_shortcut_to_desktop(self): + """Create a desktop shortcut to this program using PowerShell.""" + import sys + from platformdirs import user_desktop_dir + from abogen.utils import create_process + + try: + if platform.system() == "Windows": + # where to put the .lnk + desktop = user_desktop_dir() + shortcut_path = os.path.join(desktop, "abogen.lnk") + + # target exe + python_dir = os.path.dirname(sys.executable) + target = os.path.join(python_dir, "Scripts", "abogen.exe") + if not os.path.exists(target): + QMessageBox.critical( + self, + "Shortcut Error", + f"Could not find abogen.exe at:\n{target}", + ) + return + + # icon (fallback to exe if missing) + icon = get_resource_path("abogen.assets", "icon.ico") + if not icon or not os.path.exists(icon): + icon = target # Create a more direct PowerShell command + shortcut_ps = shortcut_path.replace("'", "''").replace("\\", "\\\\") + target_ps = target.replace("'", "''").replace("\\", "\\\\") + workdir_ps = ( + os.path.dirname(target).replace("'", "''").replace("\\", "\\\\") + ) + icon_ps = icon.replace("'", "''").replace("\\", "\\\\") + # Create PowerShell script as a single line with no line breaks (more reliable) + ps_cmd = f"$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('{shortcut_ps}'); $lnk.TargetPath='{target_ps}'; $lnk.WorkingDirectory='{workdir_ps}'; $lnk.IconLocation='{icon_ps}'; $lnk.Save()" + + # Run PowerShell with the command directly + proc = create_process( + 'powershell -NoProfile -ExecutionPolicy Bypass -Command "' + + ps_cmd + + '"' + ) + proc.wait() + + if proc.returncode == 0: + QMessageBox.information( + self, + "Shortcut Created", + f"Shortcut created on desktop:\n{shortcut_path}", + ) + else: + QMessageBox.critical( + self, + "Shortcut Error", + f"PowerShell failed with exit code: {proc.returncode}", + ) + elif platform.system() == "Linux": + desktop = user_desktop_dir() + if not desktop or not os.path.isdir(desktop): + QMessageBox.critical( + self, "Shortcut Error", "Could not determine desktop directory." + ) + return + + shortcut_path = os.path.join(desktop, "abogen.desktop") + + import shutil + + found = shutil.which("abogen") + if found: + target = found + else: + local_bin = os.path.expanduser("~/.local/bin/abogen") + if os.path.exists(local_bin): + target = local_bin + else: + python_dir = os.path.dirname(sys.executable) + target = os.path.join(python_dir, "bin", "abogen") + if not os.path.exists(target): + target_fallback = os.path.join(python_dir, "abogen") + if os.path.exists(target_fallback): + target = target_fallback + else: + QMessageBox.critical( + self, + "Shortcut Error", + "Could not find abogen executable in PATH or common installation directories.", + ) + return + + icon_path = get_resource_path("abogen.assets", "icon.png") + + desktop_entry_content = f"""[Desktop Entry] +Version={VERSION} +Name={PROGRAM_NAME} +Comment={PROGRAM_DESCRIPTION} +Exec={target} +Icon={icon_path} +Terminal=false +Type=Application +Categories=AudioVideo;Audio;Utility; +""" + with open(shortcut_path, "w", encoding="utf-8") as f: + f.write(desktop_entry_content) + + os.chmod(shortcut_path, 0o755) + + QMessageBox.information( + self, + "Shortcut Created", + f"Shortcut created on desktop:\n{shortcut_path}", + ) + + # Offer installation for current user under ~/.local/share/applications + reply = QMessageBox.question( + self, + "Install Application Entry", + "Install application entry for current user?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + import shutil + + user_app_dir = os.path.expanduser("~/.local/share/applications") + os.makedirs(user_app_dir, exist_ok=True) + user_entry = os.path.join(user_app_dir, "abogen.desktop") + try: + shutil.copyfile(shortcut_path, user_entry) + os.chmod(user_entry, 0o644) + QMessageBox.information( + self, + "Installation Complete", + f"Desktop entry installed to {user_entry}", + ) + except Exception as e: + QMessageBox.warning( + self, + "Install Error", + f"Could not install entry:\n{e}", + ) + else: + QMessageBox.information( + self, + "Unsupported OS", + "Desktop shortcut creation is not supported on this operating system.", + ) + + except Exception as e: + QMessageBox.critical( + self, "Shortcut Error", f"Could not create shortcut:\n{e}" + ) + + def toggle_check_updates(self, checked): + self.config["check_updates"] = checked + save_config(self.config) + + def show_voice_formula_dialog(self): + from abogen.voice_profiles import load_profiles + + profiles = load_profiles() + initial_state = None + selected_profile = self.selected_profile_name + if selected_profile: + entry = profiles.get(selected_profile, {}) + if isinstance(entry, dict): + initial_state = entry.get("voices", []) + else: + initial_state = entry + elif self.mixed_voice_state is not None: + initial_state = self.mixed_voice_state + elif self.selected_voice: + # If a single voice is selected, default to first profile if available + if profiles: + first_profile = next(iter(profiles)) + entry = profiles[first_profile] + selected_profile = first_profile + if isinstance(entry, dict): + initial_state = entry.get("voices", []) + else: + initial_state = entry + self.selected_lang = entry[0][0] if entry and entry[0] else None + dialog = VoiceFormulaDialog( + self, initial_state=initial_state, selected_profile=selected_profile + ) + if dialog.exec() == QDialog.DialogCode.Accepted: + if dialog.current_profile: + self.selected_profile_name = dialog.current_profile + self.config["selected_profile_name"] = dialog.current_profile + if "selected_voice" in self.config: + del self.config["selected_voice"] + save_config(self.config) + self.populate_profiles_in_voice_combo() + idx = self.voice_combo.findData(f"profile:{dialog.current_profile}") + if idx >= 0: + self.voice_combo.setCurrentIndex(idx) + self.mixed_voice_state = dialog.get_selected_voices() + + def show_predownload_dialog(self): + """Show the pre-download models and voices dialog.""" + from abogen.pyqt.predownload_gui import PreDownloadDialog + + dialog = PreDownloadDialog(self) + dialog.exec() + + def show_about_dialog(self): + """Show an About dialog with program information including GitHub link.""" + # Get application icon for dialog + icon = self.windowIcon() + + # Create custom dialog + dialog = QDialog(self) + dialog.setWindowTitle(f"About {PROGRAM_NAME}") + dialog.setWindowFlags( + dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint + ) + dialog.setFixedSize(400, 320) # Increased height for new button + + layout = QVBoxLayout(dialog) + layout.setSpacing(10) + + # Header with icon and title + header_layout = QHBoxLayout() + icon_label = QLabel() + if not icon.isNull(): + icon_label.setPixmap(icon.pixmap(64, 64)) + else: + # Fallback text if icon not available + icon_label.setText("📚") + icon_label.setStyleSheet("font-size: 48px;") + + header_layout.addWidget(icon_label) + + # Fix: Added style to reduce space between h1 and h3 + title_label = QLabel( + f"

    {PROGRAM_NAME} v{VERSION}

    Audiobook Generator

    " + ) + title_label.setTextFormat(Qt.TextFormat.RichText) + header_layout.addWidget(title_label, 1) + layout.addLayout(header_layout) + + # Description + desc_label = QLabel( + f"

    {PROGRAM_DESCRIPTION}

    " + "

    Visit the GitHub repository for updates, documentation, and to report issues.

    " + ) + desc_label.setTextFormat(Qt.TextFormat.RichText) + desc_label.setWordWrap(True) + layout.addWidget(desc_label) + + # GitHub link + github_btn = QPushButton("Visit GitHub Repository") + github_btn.setIcon(QIcon(get_resource_path("abogen.assets", "github.png"))) + github_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(GITHUB_URL))) + github_btn.setFixedHeight(32) + layout.addWidget(github_btn) + + # Check for updates button + update_btn = QPushButton("Check for updates") + update_btn.clicked.connect(self.manual_check_for_updates) + update_btn.setFixedHeight(32) + layout.addWidget(update_btn) + + # Close button + close_btn = QPushButton("Close") + close_btn.clicked.connect(dialog.accept) + close_btn.setFixedHeight(32) + layout.addWidget(close_btn) + + dialog.exec() + + def manual_check_for_updates(self): + """Manually check for updates and always show result""" + # Set a flag to always show the result message + self._show_update_check_result = True + self.check_for_updates_startup() + + def check_for_updates_startup(self): + import urllib.request + + def show_update_message(remote_version, local_version): + msg_box = QMessageBox(self) + msg_box.setIcon(QMessageBox.Icon.Information) + msg_box.setWindowTitle("Update Available") + msg_box.setText( + f"A new version of {PROGRAM_NAME} is available! ({local_version} > {remote_version})" + ) + msg_box.setInformativeText( + f"If you installed via pip, update by running:\n" + f"pip install --upgrade {PROGRAM_NAME}\n\n" + f"If you're using the Windows portable version, run 'WINDOWS_INSTALL.bat' again.\n\n" + "Alternatively, visit the GitHub repository for more information. " + "Would you like to view the changelog?" + ) + msg_box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) + if msg_box.exec() == QMessageBox.StandardButton.Yes: + try: + QDesktopServices.openUrl(QUrl(GITHUB_URL + "/releases/latest")) + except Exception: + pass + + # Reset flag to track if we should show "no updates" message + show_result = ( + hasattr(self, "_show_update_check_result") + and self._show_update_check_result + ) + self._show_update_check_result = False + + try: + update_url = "https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/abogen/VERSION" + with urllib.request.urlopen(update_url) as response: + remote_raw = response.read().decode().strip() + local_raw = VERSION + + # Parse version numbers + remote_version = remote_raw + local_version = local_raw + + try: + remote_num = int("".join(remote_version.split("."))) + local_num = int("".join(local_version.split("."))) + except ValueError as ve: + return + + if remote_num > local_num: + # Use QTimer to ensure UI is ready, then show update message. + QTimer.singleShot( + 1000, lambda: show_update_message(remote_version, local_version) + ) + elif show_result: + # Show "no updates" message if manually checking + QMessageBox.information( + self, + "Up to Date", + f"You are running the latest version of {PROGRAM_NAME} ({local_version}).", + ) + except Exception as e: + if show_result: + QMessageBox.warning( + self, + "Update Check Failed", + f"Could not check for updates:\n{str(e)}", + ) + pass + + def clear_cache_files(self): + """Clear cache files created by the program.""" + import glob + + try: + # Get the abogen cache directory + cache_dir = get_user_cache_path() + + # Find all .txt files and cover images in the abogen cache directory + cache_files = glob.glob(os.path.join(cache_dir, "*.txt")) + cache_files.extend(glob.glob(os.path.join(cache_dir, "cover_*.jpg"))) + + # Count the files + file_count = len(cache_files) + + # Check for preview cache files + preview_cache_dir = os.path.join(cache_dir, "preview_cache") + preview_files = [] + if os.path.exists(preview_cache_dir): + preview_pattern = os.path.join(preview_cache_dir, "*.wav") + preview_files = glob.glob(preview_pattern) + + preview_count = len(preview_files) + + if file_count == 0 and preview_count == 0: + QMessageBox.information( + self, "No Cache Files", "No cache files were found." + ) + return + + # Create a custom message box with checkbox + msg_box = QMessageBox(self) + msg_box.setIcon(QMessageBox.Icon.Question) + msg_box.setWindowTitle("Clear Cache Files") + + msg_text = f"Found {file_count} cache file{'s' if file_count != 1 else ''} in the {PROGRAM_NAME} cache folder." + if preview_count > 0: + msg_text += f"\nAlso found {preview_count} preview cache file{'s' if preview_count != 1 else ''}." + + msg_box.setText(msg_text + "\nDo you want to delete them?") + + # Add checkbox for preview cache + preview_cache_checkbox = QCheckBox("Also clean preview cache", msg_box) + preview_cache_checkbox.setChecked(False) + # Only enable checkbox if preview files exist + preview_cache_checkbox.setEnabled(preview_count > 0) + + # Add the checkbox to the layout + msg_box.setCheckBox(preview_cache_checkbox) + + # Add buttons + msg_box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) + + if msg_box.exec() != QMessageBox.StandardButton.Yes: + return + + # Delete the text files + deleted_count = 0 + for file_path in cache_files: + try: + os.remove(file_path) + deleted_count += 1 + except Exception as e: + print(f"Error deleting {file_path}: {e}") + + # Delete preview cache files if checkbox is checked + deleted_preview_count = 0 + if preview_cache_checkbox.isChecked() and preview_count > 0: + for file_path in preview_files: + try: + os.remove(file_path) + deleted_preview_count += 1 + except Exception as e: + print(f"Error deleting preview cache {file_path}: {e}") + + # Build result message + result_msg = f"Successfully deleted {deleted_count} temporary file{'s' if deleted_count != 1 else ''}." + if preview_cache_checkbox.isChecked() and deleted_preview_count > 0: + result_msg += f"\nAlso deleted {deleted_preview_count} preview cache file{'s' if deleted_preview_count != 1 else ''}." + + # Show results + QMessageBox.information(self, "Cache Files Cleared", result_msg) + + # If currently selected file is in the cache directory, clear the UI + if ( + self.selected_file + and os.path.dirname(self.selected_file) == cache_dir + and self.selected_file.endswith(".txt") + ): + self.input_box.clear_input() + + except Exception as e: + QMessageBox.critical( + self, "Error", f"An error occurred while clearing temporary files:\n{e}" + ) + + def set_max_log_lines(self): + """Open a dialog to set the maximum lines in the log window.""" + from PyQt6.QtWidgets import QInputDialog + + value, ok = QInputDialog.getInt( + self, + "Max Lines in Log Window", + "Enter the maximum number of lines to display in the log window:", + self.log_window_max_lines, + 10, # min value + 999999999, # max value + 1, # step + ) + if ok: + self.log_window_max_lines = value + self.config["log_window_max_lines"] = value + save_config(self.config) + QMessageBox.information( + self, + "Setting Saved", + f"Maximum lines in log window set to {value}.", + ) + + def set_max_subtitle_words(self): + """Open a dialog to set the maximum words per subtitle""" + from PyQt6.QtWidgets import QInputDialog + + current_value = self.config.get("max_subtitle_words", 50) + + value, ok = QInputDialog.getInt( + self, + "Max Words Per Subtitle", + "Enter the maximum number of words per\nsubtitle (before splitting the subtitle):", + current_value, + 1, # min value + 200, # max value + 1, # step + ) + + if ok: + # Save the new value + self.max_subtitle_words = value + self.config["max_subtitle_words"] = value + save_config(self.config) + + # Show confirmation + QMessageBox.information( + self, + "Setting Saved", + f"Maximum words per subtitle set to {value}.", + ) + + def set_silence_between_chapters(self): + """Open a dialog to set the silence duration between chapters""" + + current_value = self.config.get("silence_duration", 2.0) + + dlg = QInputDialog(self) + dlg.setWindowTitle("Silence Duration (seconds)") + dlg.setLabelText( + "Enter the duration of silence\nbetween chapters (in seconds):" + ) + dlg.setInputMode(QInputDialog.InputMode.DoubleInput) + dlg.setDoubleDecimals(1) + dlg.setDoubleMinimum(0.0) + dlg.setDoubleMaximum(60.0) + dlg.setDoubleValue(current_value) + dlg.setDoubleStep(0.1) # <-- set step to 0.1 + + if dlg.exec() == QDialog.DialogCode.Accepted: + value = dlg.doubleValue() + # Round to one decimal to avoid floating-point representation noise + value = round(value, 1) + + # Save the new value + self.silence_duration = value + self.config["silence_duration"] = value + save_config(self.config) + + # Show confirmation (format with one decimal) + QMessageBox.information( + self, + "Setting Saved", + f"Silence duration between chapters set to {value:.1f} seconds.", + ) + + def set_separate_chapters_format(self, fmt): + """Set the format for separate chapters audio files.""" + self.separate_chapters_format = fmt + self.config["separate_chapters_format"] = fmt + save_config(self.config) + + def set_subtitle_format(self, fmt): + """Set the subtitle format.""" + self.config["subtitle_format"] = fmt + save_config(self.config) + + def show_model_download_warning(self, title, message): + QMessageBox.information(self, title, message) diff --git a/abogen/pyqt/main.py b/abogen/pyqt/main.py new file mode 100644 index 0000000..f788e15 --- /dev/null +++ b/abogen/pyqt/main.py @@ -0,0 +1,187 @@ +import os +import sys +import platform +import atexit +import signal +from abogen.utils import get_resource_path, load_config, prevent_sleep_end + + +# Fix PyTorch DLL loading issue ([WinError 1114]) on Windows before importing PyQt6 +if platform.system() == "Windows": + import ctypes + from importlib.util import find_spec + + try: + if ( + (spec := find_spec("torch")) + and spec.origin + and os.path.exists( + dll_path := os.path.join(os.path.dirname(spec.origin), "lib", "c10.dll") + ) + ): + ctypes.CDLL(os.path.normpath(dll_path)) + except Exception: + pass + + +# Qt platform plugin detection (fixes #59) +try: + from PyQt6.QtCore import QLibraryInfo + + # Get the path to the plugins directory + plugins = QLibraryInfo.path(QLibraryInfo.LibraryPath.PluginsPath) + + # Normalize path to use the OS-native separators and absolute path + platform_dir = os.path.normpath(os.path.join(plugins, "platforms")) + + # Ensure we work with an absolute path for clarity + platform_dir = os.path.abspath(platform_dir) + + if os.path.isdir(platform_dir): + os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = platform_dir + print("QT_QPA_PLATFORM_PLUGIN_PATH set to:", platform_dir) + else: + print("PyQt6 platform plugins not found at", platform_dir) +except ImportError: + print("PyQt6 not installed.") + + +# Pre-load "libxcb-cursor" on Linux (fixes #101) +if platform.system() == "Linux": + arch = platform.machine().lower() + lib_filename = {"x86_64": "libxcb-cursor-amd64.so.0", "amd64": "libxcb-cursor-amd64.so.0", "aarch64": "libxcb-cursor-arm64.so.0", "arm64": "libxcb-cursor-arm64.so.0"}.get(arch) + if lib_filename: + import ctypes + try: + # Try to load the system libxcb-cursor.so.0 first + ctypes.CDLL('libxcb-cursor.so.0', mode=ctypes.RTLD_GLOBAL) + except OSError: + # System lib not available, load the bundled version + lib_path = get_resource_path('abogen.libs', lib_filename) + if lib_path: + try: + ctypes.CDLL(lib_path, mode=ctypes.RTLD_GLOBAL) + except OSError: + # If it fails (e.g. wrong glibc version on very old systems), + # we simply ignore it and hope the system has the library. + pass + + +# Set application ID for Windows taskbar icon +if platform.system() == "Windows": + try: + from abogen.constants import PROGRAM_NAME, VERSION + import ctypes + + app_id = f"{PROGRAM_NAME}.{VERSION}" + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) + except Exception as e: + print("Warning: failed to set AppUserModelID:", e) + +from PyQt6.QtWidgets import QApplication +from PyQt6.QtGui import QIcon +from PyQt6.QtCore import ( + QLibraryInfo, + qInstallMessageHandler, + QtMsgType, +) + +# Add the directory to Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__))) + +# Set Hugging Face Hub environment variables +os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry +os.environ["HF_HUB_ETAG_TIMEOUT"] = "10" # Metadata request timeout (seconds) +os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds) +os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Disable symlinks warning +if load_config().get("disable_kokoro_internet", False): + print("INFO: Kokoro's internet access is disabled.") + os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access + +from abogen.pyqt.gui import abogen +from abogen.constants import PROGRAM_NAME, VERSION + +# Set environment variables for AMD ROCm +os.environ["MIOPEN_FIND_MODE"] = "FAST" +os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0" + +# Reset sleep states +atexit.register(prevent_sleep_end) + + +# Also handle signals (Ctrl+C, kill, etc.) +def _cleanup_sleep(signum, frame): + prevent_sleep_end() + sys.exit(0) + + +signal.signal(signal.SIGINT, _cleanup_sleep) +signal.signal(signal.SIGTERM, _cleanup_sleep) + +# Ensure sys.stdout and sys.stderr are valid in GUI mode +if sys.stdout is None: + sys.stdout = open(os.devnull, "w") +if sys.stderr is None: + sys.stderr = open(os.devnull, "w") + +# Enable MPS GPU acceleration on Mac Apple Silicon +if platform.system() == "Darwin" and platform.processor() == "arm": + os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" + + +# Custom message handler to filter out specific Qt warnings +def qt_message_handler(mode, context, message): + # In PyQt6, the mode is an enum, so we compare with the enum members + if "Wayland does not support QWindow::requestActivate()" in message: + return # Suppress this specific message + if "setGrabPopup called with a parent, QtWaylandClient" in message: + return + + if mode == QtMsgType.QtWarningMsg: + print(f"Qt Warning: {message}") + elif mode == QtMsgType.QtCriticalMsg: + print(f"Qt Critical: {message}") + elif mode == QtMsgType.QtFatalMsg: + print(f"Qt Fatal: {message}") + elif mode == QtMsgType.QtInfoMsg: + print(f"Qt Info: {message}") + + +# Install the custom message handler +qInstallMessageHandler(qt_message_handler) + +# Handle Wayland on Linux GNOME +if platform.system() == "Linux": + xdg_session = os.environ.get("XDG_SESSION_TYPE", "").lower() + desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() + if ( + "gnome" in desktop + and xdg_session == "wayland" + and "QT_QPA_PLATFORM" not in os.environ + ): + os.environ["QT_QPA_PLATFORM"] = "wayland" + + +def main(): + """Main entry point for console usage.""" + app = QApplication(sys.argv) + + # Set application icon using get_resource_path from utils + icon_path = get_resource_path("abogen.assets", "icon.ico") + if icon_path: + app.setWindowIcon(QIcon(icon_path)) + + # Set the .desktop name on Linux + if platform.system() == "Linux": + try: + app.setDesktopFileName("abogen") + except AttributeError: + pass + + ex = abogen() + ex.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/abogen/pyqt/predownload_gui.py b/abogen/pyqt/predownload_gui.py new file mode 100644 index 0000000..47138f1 --- /dev/null +++ b/abogen/pyqt/predownload_gui.py @@ -0,0 +1,590 @@ +""" +Pre-download dialog and worker for Abogen + +This module consolidates pre-download logic for Kokoro voices and model +and spaCy language models. The code favors clarity, avoids duplication, +and handles optional dependencies gracefully. +""" + +from typing import List, Optional, Tuple +import importlib +import importlib.util + +from PyQt6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QSpacerItem, + QSizePolicy, +) +from PyQt6.QtCore import QThread, pyqtSignal + +from abogen.constants import COLORS, VOICES_INTERNAL +from abogen.spacy_utils import SPACY_MODELS +import abogen.hf_tracker + + +# Helpers +def _unique_sorted_models() -> List[str]: + """Return a sorted list of unique spaCy model package names.""" + return sorted(set(SPACY_MODELS.values())) + + +def _is_package_installed(pkg_name: str) -> bool: + """Return True if a package with the given name can be imported (site-packages).""" + try: + return importlib.util.find_spec(pkg_name) is not None + except Exception: + return False + + +# NOTE: explicit HF cache helper removed; we use try_to_load_from_cache in-scope where needed + + +class PreDownloadWorker(QThread): + """Worker thread to download required models/voices. + + Emits human-readable messages via `progress`. Uses `category_done` to indicate + a category (voices/model/spacy) finished successfully. Emits `error` on exception + and `finished` after all work completes. + """ + + # Emit (category, status, message) + progress = pyqtSignal(str, str, str) + category_done = pyqtSignal(str) + finished = pyqtSignal() + error = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._cancelled = False + # repo and filenames used for Kokoro model + self._repo_id = "hexgrad/Kokoro-82M" + self._model_files = ["kokoro-v1_0.pth", "config.json"] + # Track download success per category + self._voices_success = False + self._model_success = False + self._spacy_success = False + # Suppress HF tracker warnings during downloads + self._original_emitter = abogen.hf_tracker.show_warning_signal_emitter + + def cancel(self) -> None: + self._cancelled = True + + def run(self) -> None: + # Suppress HF tracker warnings during downloads + abogen.hf_tracker.show_warning_signal_emitter = None + try: + self._download_kokoro_voices() + if self._cancelled: + return + if self._voices_success: + self.category_done.emit("voices") + + self._download_kokoro_model() + if self._cancelled: + return + if self._model_success: + self.category_done.emit("model") + + self._download_spacy_models() + if self._cancelled: + return + if self._spacy_success: + self.category_done.emit("spacy") + + self.finished.emit() + except Exception as exc: # pragma: no cover - best-effort reporting + self.error.emit(str(exc)) + finally: + # Restore original emitter + abogen.hf_tracker.show_warning_signal_emitter = self._original_emitter + + # Kokoro voices + def _download_kokoro_voices(self) -> None: + self._voices_success = True + try: + from huggingface_hub import hf_hub_download, try_to_load_from_cache + except Exception: + self.progress.emit( + "voice", "warning", "huggingface_hub not installed, skipping voices..." + ) + self._voices_success = False + return + + voice_list = VOICES_INTERNAL + for idx, voice in enumerate(voice_list, start=1): + if self._cancelled: + self._voices_success = False + return + filename = f"voices/{voice}.pt" + if try_to_load_from_cache(repo_id=self._repo_id, filename=filename): + self.progress.emit( + "voice", + "installed", + f"{idx}/{len(voice_list)}: {voice} already present", + ) + continue + self.progress.emit( + "voice", "downloading", f"{idx}/{len(voice_list)}: {voice}..." + ) + try: + hf_hub_download(repo_id=self._repo_id, filename=filename) + self.progress.emit("voice", "downloaded", f"{voice} downloaded") + except Exception as exc: + self.progress.emit( + "voice", "warning", f"could not download {voice}: {exc}" + ) + self._voices_success = False + + # Kokoro model + def _download_kokoro_model(self) -> None: + self._model_success = True + try: + from huggingface_hub import hf_hub_download, try_to_load_from_cache + except Exception: + self.progress.emit( + "model", "warning", "huggingface_hub not installed, skipping model..." + ) + self._model_success = False + return + for fname in self._model_files: + if self._cancelled: + self._model_success = False + return + category = "config" if fname == "config.json" else "model" + if try_to_load_from_cache(repo_id=self._repo_id, filename=fname): + self.progress.emit( + category, "installed", f"file {fname} already present" + ) + continue + self.progress.emit(category, "downloading", f"file {fname}...") + try: + hf_hub_download(repo_id=self._repo_id, filename=fname) + self.progress.emit(category, "downloaded", f"file {fname} downloaded") + except Exception as exc: + self.progress.emit( + category, "warning", f"could not download file {fname}: {exc}" + ) + self._model_success = False + + # spaCy models + def _download_spacy_models(self) -> None: + """Download spaCy models. Prefer missing models provided by parent. + + Parent dialog will populate _spacy_models_missing during checking. + """ + self._spacy_success = True + # Determine which models to process: prefer parent-provided missing list to avoid + # re-checking everything; otherwise use the full unique list. + parent = self.parent() + models_to_process: List[str] = _unique_sorted_models() + try: + if ( + parent is not None + and hasattr(parent, "_spacy_models_missing") + and parent._spacy_models_missing + ): + models_to_process = list(dict.fromkeys(parent._spacy_models_missing)) + except Exception: + pass + + # If spaCy is not available to run the CLI, skip gracefully + try: + import spacy.cli as _spacy_cli + except Exception: + self.progress.emit( + "spacy", "warning", "spaCy not available, skipping spaCy models..." + ) + self._spacy_success = False + return + + for idx, model_name in enumerate(models_to_process, start=1): + if self._cancelled: + self._spacy_success = False + return + if _is_package_installed(model_name): + self.progress.emit( + "spacy", + "installed", + f"{idx}/{len(models_to_process)}: {model_name} already installed", + ) + continue + self.progress.emit( + "spacy", + "downloading", + f"{idx}/{len(models_to_process)}: {model_name}...", + ) + try: + _spacy_cli.download(model_name) + self.progress.emit("spacy", "downloaded", f"{model_name} downloaded") + except Exception as exc: + self.progress.emit( + "spacy", "warning", f"could not download {model_name}: {exc}" + ) + self._spacy_success = False + + +class PreDownloadDialog(QDialog): + """Dialog to show and control pre-download process.""" + + VOICE_PREFIX = "Kokoro voices: " + MODEL_PREFIX = "Kokoro model: " + CONFIG_PREFIX = "Kokoro config: " + SPACY_PREFIX = "spaCy models: " + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Pre-download Models and Voices") + self.setMinimumWidth(500) + self.worker: Optional[PreDownloadWorker] = None + self.has_missing = False + self._spacy_models_checked: List[tuple] = [] + self._spacy_models_missing: List[str] = [] + self._status_worker = None + + # Map keywords to (label, prefix) - labels filled after UI creation + self.status_map = { + "voice": (None, self.VOICE_PREFIX), + "spacy": (None, self.SPACY_PREFIX), + "model": (None, self.MODEL_PREFIX), + "config": (None, self.CONFIG_PREFIX), + } + + self.category_map = { + "voices": ["voice"], + "model": ["model", "config"], + "spacy": ["spacy"], + } + + self._setup_ui() + self._start_status_check() + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(15, 0, 15, 15) + + desc = QLabel( + "You can pre-download all required models and voices for offline use.\n" + "This includes Kokoro voices, Kokoro model (and config), and spaCy models." + ) + desc.setWordWrap(True) + layout.addWidget(desc) + + # Status rows + status_layout = QVBoxLayout() + status_title = QLabel("Current Status:") + status_layout.addWidget(status_title) + + self.voices_status = QLabel(self.VOICE_PREFIX + "⏳ Checking...") + row = QHBoxLayout() + row.addWidget(self.voices_status) + row.addStretch() + status_layout.addLayout(row) + + self.model_status = QLabel(self.MODEL_PREFIX + "⏳ Checking...") + row = QHBoxLayout() + row.addWidget(self.model_status) + row.addStretch() + status_layout.addLayout(row) + + self.config_status = QLabel(self.CONFIG_PREFIX + "⏳ Checking...") + row = QHBoxLayout() + row.addWidget(self.config_status) + row.addStretch() + status_layout.addLayout(row) + + self.spacy_status = QLabel(self.SPACY_PREFIX + "⏳ Checking...") + row = QHBoxLayout() + row.addWidget(self.spacy_status) + row.addStretch() + status_layout.addLayout(row) + + # register labels + self.status_map["voice"] = (self.voices_status, self.VOICE_PREFIX) + self.status_map["model"] = (self.model_status, self.MODEL_PREFIX) + self.status_map["config"] = (self.config_status, self.CONFIG_PREFIX) + self.status_map["spacy"] = (self.spacy_status, self.SPACY_PREFIX) + + layout.addLayout(status_layout) + + layout.addItem( + QSpacerItem(0, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + ) + + # Buttons + button_row = QHBoxLayout() + button_row.setSpacing(10) + self.download_btn = QPushButton("Download all") + self.download_btn.setMinimumWidth(100) + self.download_btn.setMinimumHeight(35) + self.download_btn.setEnabled(False) + self.download_btn.clicked.connect(self._start_download) + button_row.addWidget(self.download_btn) + + self.close_btn = QPushButton("Close") + self.close_btn.setMinimumWidth(100) + self.close_btn.setMinimumHeight(35) + self.close_btn.clicked.connect(self._handle_close) + button_row.addWidget(self.close_btn) + + layout.addLayout(button_row) + self.adjustSize() + + # Status checking worker + class StatusCheckWorker(QThread): + voices_checked = pyqtSignal(bool, list) + model_checked = pyqtSignal(bool) + config_checked = pyqtSignal(bool) + spacy_model_checking = pyqtSignal(str) + spacy_model_result = pyqtSignal(str, bool) + spacy_checked = pyqtSignal(bool, list) + + def run(self): + parent = self.parent() + if parent is None: + return + + voices_ok, missing_voices = parent._check_kokoro_voices() + self.voices_checked.emit(voices_ok, missing_voices) + + model_ok = parent._check_kokoro_model() + self.model_checked.emit(model_ok) + + config_ok = parent._check_kokoro_config() + self.config_checked.emit(config_ok) + + # Check spaCy models by package name to detect site-package installs + unique = _unique_sorted_models() + missing: List[str] = [] + for name in unique: + self.spacy_model_checking.emit(name) + ok = _is_package_installed(name) + self.spacy_model_result.emit(name, ok) + if not ok: + missing.append(name) + parent._spacy_models_missing = missing + self.spacy_checked.emit(len(missing) == 0, missing) + + def _start_status_check(self) -> None: + self._status_worker = self.StatusCheckWorker(self) + self._status_worker.voices_checked.connect(self._update_voices_status) + self._status_worker.model_checked.connect(self._update_model_status) + self._status_worker.config_checked.connect(self._update_config_status) + self._status_worker.spacy_model_checking.connect(self._spacy_model_checking) + self._status_worker.spacy_model_result.connect(self._spacy_model_result) + self._status_worker.spacy_checked.connect(self._update_spacy_status) + + # These are initialized in __init__ to keep consistent object state + + # Set checking visual state + for lbl in ( + self.voices_status, + self.model_status, + self.config_status, + self.spacy_status, + ): + lbl.setStyleSheet(f"color: {COLORS['ORANGE']};") + + self.spacy_status.setText(self.SPACY_PREFIX + "⏳ Checking...") + self._status_worker.start() + + # UI update callbacks + def _spacy_model_checking(self, name: str) -> None: + self.spacy_status.setText(f"{self.SPACY_PREFIX}Checking {name}...") + + def _spacy_model_result(self, name: str, ok: bool) -> None: + self._spacy_models_checked.append((name, ok)) + if not ok and name not in self._spacy_models_missing: + self._spacy_models_missing.append(name) + checked = len(self._spacy_models_checked) + missing_count = len(self._spacy_models_missing) + if missing_count: + self.spacy_status.setText( + f"{self.SPACY_PREFIX}{checked} checked, {missing_count} missing..." + ) + else: + self.spacy_status.setText(f"{self.SPACY_PREFIX}{checked} checked...") + + def _update_voices_status(self, ok: bool, missing: List[str]) -> None: + if ok: + self._set_status("voice", "✓ Downloaded", COLORS["GREEN"]) + else: + self.has_missing = True + if missing: + self._set_status( + "voice", f"✗ Missing {len(missing)} voices", COLORS["RED"] + ) + else: + self._set_status("voice", "✗ Not downloaded", COLORS["RED"]) + + def _update_model_status(self, ok: bool) -> None: + if ok: + self._set_status("model", "✓ Downloaded", COLORS["GREEN"]) + else: + self.has_missing = True + self._set_status("model", "✗ Not downloaded", COLORS["RED"]) + + def _update_config_status(self, ok: bool) -> None: + if ok: + self._set_status("config", "✓ Downloaded", COLORS["GREEN"]) + else: + self.has_missing = True + self._set_status("config", "✗ Not downloaded", COLORS["RED"]) + + def _update_spacy_status(self, ok: bool, missing: List[str]) -> None: + if ok: + self._set_status("spacy", "✓ Downloaded", COLORS["GREEN"]) + else: + self.has_missing = True + if missing: + self._set_status( + "spacy", f"✗ Missing {len(missing)} model(s)", COLORS["RED"] + ) + else: + self._set_status("spacy", "✗ Not downloaded", COLORS["RED"]) + self.download_btn.setEnabled(self.has_missing) + + def _set_status(self, key: str, text: str, color: str) -> None: + lbl, prefix = self.status_map.get(key, (None, "")) + if not lbl: + return + lbl.setText(prefix + text) + lbl.setStyleSheet(f"color: {color};") + + # Helper checks + def _check_kokoro_voices(self) -> Tuple[bool, List[str]]: + """Return (ok, missing_list) for Kokoro voices check.""" + missing = [] + try: + from huggingface_hub import try_to_load_from_cache + + for voice in VOICES_INTERNAL: + if not try_to_load_from_cache( + repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt" + ): + missing.append(voice) + except Exception: + # If HF missing, report all as missing + return False, list(VOICES_INTERNAL) + return (len(missing) == 0), missing + + def _check_kokoro_model(self) -> bool: + try: + from huggingface_hub import try_to_load_from_cache + + return ( + try_to_load_from_cache( + repo_id="hexgrad/Kokoro-82M", filename="kokoro-v1_0.pth" + ) + is not None + ) + except Exception: + return False + + def _check_kokoro_config(self) -> bool: + try: + from huggingface_hub import try_to_load_from_cache + + return ( + try_to_load_from_cache( + repo_id="hexgrad/Kokoro-82M", filename="config.json" + ) + is not None + ) + except Exception: + return False + + def _check_spacy_models(self) -> bool: + unique = _unique_sorted_models() + missing = [m for m in unique if not _is_package_installed(m)] + self._spacy_models_missing = missing + return len(missing) == 0 + + # Download control + def _start_download(self) -> None: + self.download_btn.setEnabled(False) + self.download_btn.setText("Downloading...") + # mark the start of downloads; this triggers the labels + self._on_progress("system", "starting", "Processing, please wait...") + self.worker = PreDownloadWorker(self) + self.worker.progress.connect(self._on_progress) + self.worker.category_done.connect(self._on_category_done) + self.worker.finished.connect(self._on_download_finished) + self.worker.error.connect(self._on_download_error) + self.worker.start() + + def _on_progress(self, category: str, status: str, message: str) -> None: + """Map worker (category, status, message) to UI label updates. + + Status is one of: 'downloading', 'installed', 'downloaded', 'warning', 'starting'. + Category is one of: 'voice', 'model', 'spacy', 'config', or 'system'. + """ + try: + # If the category targets a specific label, update directly + if category in self.status_map: + lbl, prefix = self.status_map[category] + if not lbl: + return + # Compose message and set color based on status token + full_text = prefix + message + if len(full_text) > 60: + display_text = full_text[:57] + "..." + lbl.setText(display_text) + lbl.setToolTip(full_text) + else: + lbl.setText(full_text) + lbl.setToolTip("") # Clear tooltip if not needed + if status == "downloading": + lbl.setStyleSheet(f"color: {COLORS['ORANGE']};") + elif status in ("installed", "downloaded"): + lbl.setStyleSheet(f"color: {COLORS['GREEN']};") + elif status == "warning": + lbl.setStyleSheet(f"color: {COLORS['RED']};") + elif status == "error": + lbl.setStyleSheet(f"color: {COLORS['RED']};") + return + + # System-level messages + if category == "system": + if status == "starting": + for k in self.status_map: + lbl, prefix = self.status_map[k] + if lbl: + lbl.setText(prefix + "Processing, please wait...") + lbl.setStyleSheet(f"color: {COLORS['ORANGE']};") + # other system statuses don't require action + return + except Exception: + # Do not let UI thread crash on unexpected worker message + pass + + def _on_category_done(self, category: str) -> None: + for key in self.category_map.get(category, []): + self._set_status(key, "✓ Downloaded", COLORS["GREEN"]) + + def _on_download_finished(self) -> None: + self.has_missing = False + self.download_btn.setText("Download all") + self.download_btn.setEnabled(False) + + def _on_download_error(self, error_msg: str) -> None: + self.download_btn.setText("Download all") + self.download_btn.setEnabled(True) + for key in self.status_map: + self._set_status(key, f"✗ Error - {error_msg}", COLORS["RED"]) + + def _handle_close(self) -> None: + if self.worker and self.worker.isRunning(): + self.worker.cancel() + self.worker.wait(2000) + self.accept() + + def closeEvent(self, event) -> None: + if self.worker and self.worker.isRunning(): + self.worker.cancel() + self.worker.wait(2000) + super().closeEvent(event) diff --git a/abogen/pyqt/queue_manager_gui.py b/abogen/pyqt/queue_manager_gui.py new file mode 100644 index 0000000..384be79 --- /dev/null +++ b/abogen/pyqt/queue_manager_gui.py @@ -0,0 +1,860 @@ +# a simple window with a list of items in the queue, no checkboxes +# button to remove an item from the queue +# button to clear the queue + +from PyQt6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QDialogButtonBox, + QPushButton, + QListWidget, + QListWidgetItem, + QFileIconProvider, + QLabel, + QWidget, + QSizePolicy, + QAbstractItemView, + QCheckBox, +) +from PyQt6.QtCore import QFileInfo, Qt +from abogen.constants import COLORS +from copy import deepcopy +from PyQt6.QtGui import QFontMetrics +from abogen.utils import load_config, save_config + +# Define attributes that are safe to override with global settings +OVERRIDE_FIELDS = [ + "lang_code", + "speed", + "voice", + "save_option", + "output_folder", + "subtitle_mode", + "output_format", + "replace_single_newlines", + "use_silent_gaps", + "subtitle_speed_method", +] + + +class ElidedLabel(QLabel): + def __init__(self, text): + super().__init__(text) + self._full_text = text + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.setTextFormat(Qt.TextFormat.PlainText) + + def setText(self, text): + self._full_text = text + super().setText(text) + self.update() + + def resizeEvent(self, event): + metrics = QFontMetrics(self.font()) + elided = metrics.elidedText( + self._full_text, Qt.TextElideMode.ElideRight, self.width() + ) + super().setText(elided) + super().resizeEvent(event) + + def fullText(self): + return self._full_text + + +class QueueListItemWidget(QWidget): + def __init__(self, file_name, char_count): + super().__init__() + layout = QHBoxLayout() + layout.setContentsMargins(12, 0, 6, 0) + layout.setSpacing(0) + import os + + name_label = ElidedLabel(os.path.basename(file_name)) + char_label = QLabel(f"Chars: {char_count}") + char_label.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']};") + char_label.setAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + ) + char_label.setSizePolicy( + QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred + ) + layout.addWidget(name_label, 1) + layout.addWidget(char_label, 0) + self.setLayout(layout) + + +class DroppableQueueListWidget(QListWidget): + def __init__(self, parent_dialog): + super().__init__() + self.parent_dialog = parent_dialog + self.setAcceptDrops(True) + # Overlay for drag hover + self.drag_overlay = QLabel("", self) + self.drag_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.drag_overlay.setStyleSheet( + f"border:2px dashed {COLORS['BLUE_BORDER_HOVER']}; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG_HOVER']};" + ) + self.drag_overlay.setVisible(False) + self.drag_overlay.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents, True + ) + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + for url in event.mimeData().urls(): + file_path = url.toLocalFile().lower() + if url.isLocalFile() and ( + file_path.endswith(".txt") + or file_path.endswith((".srt", ".ass", ".vtt")) + ): + self.drag_overlay.resize(self.size()) + self.drag_overlay.setVisible(True) + event.acceptProposedAction() + return + self.drag_overlay.setVisible(False) + event.ignore() + + def dragMoveEvent(self, event): + if event.mimeData().hasUrls(): + for url in event.mimeData().urls(): + file_path = url.toLocalFile().lower() + if url.isLocalFile() and ( + file_path.endswith(".txt") + or file_path.endswith((".srt", ".ass", ".vtt")) + ): + event.acceptProposedAction() + return + event.ignore() + + def dragLeaveEvent(self, event): + self.drag_overlay.setVisible(False) + event.accept() + + def dropEvent(self, event): + self.drag_overlay.setVisible(False) + if event.mimeData().hasUrls(): + file_paths = [ + url.toLocalFile() + for url in event.mimeData().urls() + if url.isLocalFile() + and ( + url.toLocalFile().lower().endswith(".txt") + or url.toLocalFile().lower().endswith((".srt", ".ass", ".vtt")) + ) + ] + if file_paths: + self.parent_dialog.add_files_from_paths(file_paths) + event.acceptProposedAction() + else: + event.ignore() + else: + event.ignore() + + def resizeEvent(self, event): + super().resizeEvent(event) + if hasattr(self, "drag_overlay"): + self.drag_overlay.resize(self.size()) + + +class QueueManager(QDialog): + def __init__(self, parent, queue: list, title="Queue Manager", size=(600, 700)): + super().__init__() + self.queue = queue + self._original_queue = deepcopy( + queue + ) # Store a deep copy of the original queue + self.parent = parent + self.config = load_config() # Load config for persistence + + layout = QVBoxLayout() + layout.setContentsMargins(15, 15, 15, 15) # set main layout margins + layout.setSpacing(12) # set spacing between widgets in main layout + # list of queued items + self.listwidget = DroppableQueueListWidget(self) + self.listwidget.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + self.listwidget.setAlternatingRowColors(True) + self.listwidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.listwidget.customContextMenuRequested.connect(self.show_context_menu) + # Add informative instructions at the top + instructions = QLabel( + "

    How Queue Works?

    " + "You can add text and subtitle files (.txt, .srt, .ass, .vtt) directly using the 'Add files' button below. " + "To add PDF, EPUB or markdown files, use the input box in the main window and click the 'Add to Queue' button. " + "By default, each file in the queue keeps the configuration settings active when they were added. " + "Enabling the 'Override item settings with current selection' option below will force all items to use the configuration currently selected in the main window. " + "You can view each file's configuration by hovering over them." + ) + instructions.setAlignment(Qt.AlignmentFlag.AlignLeft) + instructions.setWordWrap(True) + layout.addWidget(instructions) + + # Override Checkbox + self.override_chk = QCheckBox("Override item settings with current selection") + self.override_chk.setToolTip( + "If checked, all items in the queue will be processed using the \n" + "settings currently selected in the main window, ignoring their saved state." + ) + # Load saved state (default to False) + self.override_chk.setChecked(self.config.get("queue_override_settings", False)) + # Trigger process_queue to update tooltips immediately when toggled + self.override_chk.stateChanged.connect(self.process_queue) + self.override_chk.setStyleSheet("margin-bottom: 8px;") + layout.addWidget(self.override_chk) + + # Overlay label for empty queue + self.empty_overlay = QLabel( + "Drag and drop your text or subtitle files here or use the 'Add files' button.", + self.listwidget, + ) + self.empty_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty_overlay.setStyleSheet( + f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;" + ) + self.empty_overlay.setWordWrap(True) + self.empty_overlay.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents, True + ) + self.empty_overlay.hide() + # add queue items to the list + self.process_queue() + + button_row = QHBoxLayout() + button_row.setContentsMargins(0, 0, 0, 0) # optional: no margins for button row + button_row.setSpacing(7) # set spacing between buttons + # Add files button + add_files_button = QPushButton("Add files") + add_files_button.setFixedHeight(40) + add_files_button.clicked.connect(self.add_more_files) + button_row.addWidget(add_files_button) + + # Remove button + self.remove_button = QPushButton("Remove selected") + self.remove_button.setFixedHeight(40) + self.remove_button.clicked.connect(self.remove_item) + button_row.addWidget(self.remove_button) + + # Clear button + self.clear_button = QPushButton("Clear Queue") + self.clear_button.setFixedHeight(40) + self.clear_button.clicked.connect(self.clear_queue) + button_row.addWidget(self.clear_button) + + layout.addLayout(button_row) + layout.addWidget(self.listwidget) + + # Connect selection change to update button state + self.listwidget.currentItemChanged.connect(self.update_button_states) + self.listwidget.itemSelectionChanged.connect(self.update_button_states) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + self, + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout.addWidget(buttons) + + self.setLayout(layout) + + self.setWindowTitle(title) + self.resize(*size) + + self.update_button_states() + + def process_queue(self): + """Process the queue items.""" + import os + + self.listwidget.clear() + if not self.queue: + self.empty_overlay.show() + self.update_button_states() + return + else: + self.empty_overlay.hide() + + # Get current global settings and checkbox state for overrides + current_global_settings = self.get_current_attributes() + is_override_active = self.override_chk.isChecked() + + icon_provider = QFileIconProvider() + for item in self.queue: + # Dynamic Attribute Retrieval Helper + def get_val(attr, default=""): + # If override is ON and attr is overrideable, use global setting + if is_override_active and attr in OVERRIDE_FIELDS: + return current_global_settings.get(attr, default) + # Otherwise return the item's saved attribute + return getattr(item, attr, default) + + # Determine display file path (prefer save_base_path for original file) + display_file_path = getattr(item, "save_base_path", None) or item.file_name + processing_file_path = item.file_name + + # Normalize paths for consistent display (fixes Windows path separator issues) + display_file_path = ( + os.path.normpath(display_file_path) + if display_file_path + else display_file_path + ) + processing_file_path = ( + os.path.normpath(processing_file_path) + if processing_file_path + else processing_file_path + ) + + # Only show the file name, not the full path + display_name = display_file_path + + if os.path.sep in display_file_path: + display_name = os.path.basename(display_file_path) + # Get icon for the display file + icon = icon_provider.icon(QFileInfo(display_file_path)) + list_item = QListWidgetItem() + + # Tooltip Generation + tooltip = "" + # If override is active, add the warning header on its own line + if is_override_active: + tooltip += "(Global Override Active)
    " + + output_folder = get_val("output_folder") + # For plain .txt inputs we don't need to show a separate processing file + show_processing = True + try: + if isinstance( + display_file_path, str + ) and display_file_path.lower().endswith(".txt"): + show_processing = False + except Exception: + show_processing = True + + tooltip += f"Input File: {display_file_path}
    " + if ( + show_processing + and processing_file_path + and processing_file_path != display_file_path + ): + tooltip += f"Processing File: {processing_file_path}
    " + + tooltip += ( + f"Language: {get_val('lang_code')}
    " + f"Speed: {get_val('speed')}
    " + f"Voice: {get_val('voice')}
    " + f"Save Option: {get_val('save_option')}
    " + ) + if output_folder not in (None, "", "None"): + tooltip += f"Output Folder: {output_folder}
    " + tooltip += ( + f"Subtitle Mode: {get_val('subtitle_mode')}
    " + f"Output Format: {get_val('output_format')}
    " + f"Characters: {getattr(item, 'total_char_count', '')}
    " + f"Replace Single Newlines: {get_val('replace_single_newlines', True)}
    " + f"Use Silent Gaps: {get_val('use_silent_gaps', False)}
    " + f"Speed Method: {get_val('subtitle_speed_method', 'tts')}" + ) + # Add book handler options if present (Preserve logic: specific to file structure) + save_chapters_separately = getattr(item, "save_chapters_separately", None) + merge_chapters_at_end = getattr(item, "merge_chapters_at_end", None) + if save_chapters_separately is not None: + tooltip += f"
    Save chapters separately: {'Yes' if save_chapters_separately else 'No'}" + # Only show merge option if saving chapters separately + if save_chapters_separately and merge_chapters_at_end is not None: + tooltip += f"
    Merge chapters at the end: {'Yes' if merge_chapters_at_end else 'No'}" + list_item.setToolTip(tooltip) + list_item.setIcon(icon) + # Store both paths for context menu + list_item.setData( + Qt.ItemDataRole.UserRole, + { + "display_path": display_file_path, + "processing_path": processing_file_path, + }, + ) + # Use custom widget for display + char_count = getattr(item, "total_char_count", 0) + widget = QueueListItemWidget(display_file_path, char_count) + self.listwidget.addItem(list_item) + self.listwidget.setItemWidget(list_item, widget) + self.update_button_states() + + def remove_item(self): + items = self.listwidget.selectedItems() + if not items: + return + from PyQt6.QtWidgets import QMessageBox + + # Remove by index to ensure correct mapping + rows = sorted([self.listwidget.row(item) for item in items], reverse=True) + # Warn user if removing multiple files + if len(rows) > 1: + reply = QMessageBox.question( + self, + "Confirm Remove", + f"Are you sure you want to remove {len(rows)} selected items from the queue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + for row in rows: + if 0 <= row < len(self.queue): + del self.queue[row] + self.process_queue() + self.update_button_states() + + def clear_queue(self): + from PyQt6.QtWidgets import QMessageBox + + if len(self.queue) > 1: + reply = QMessageBox.question( + self, + "Confirm Clear Queue", + f"Are you sure you want to clear {len(self.queue)} items from the queue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + self.queue.clear() + self.listwidget.clear() + self.empty_overlay.resize( + self.listwidget.size() + ) # Ensure overlay is sized correctly + self.empty_overlay.show() # Show the overlay when queue is empty + self.update_button_states() + + def get_queue(self): + return self.queue + + def get_current_attributes(self): + # Fetch current attribute values from the parent abogen GUI + attrs = {} + parent = self.parent + if parent is not None: + # lang_code: use parent's get_voice_formula and get_selected_lang + if hasattr(parent, "get_voice_formula") and hasattr( + parent, "get_selected_lang" + ): + voice_formula = parent.get_voice_formula() + attrs["lang_code"] = parent.get_selected_lang(voice_formula) + attrs["voice"] = voice_formula + else: + attrs["lang_code"] = getattr(parent, "selected_lang", "") + attrs["voice"] = getattr(parent, "selected_voice", "") + # speed + if hasattr(parent, "speed_slider"): + attrs["speed"] = parent.speed_slider.value() / 100.0 + else: + attrs["speed"] = getattr(parent, "speed", 1.0) + # save_option + attrs["save_option"] = getattr(parent, "save_option", "") + # output_folder + attrs["output_folder"] = getattr(parent, "selected_output_folder", "") + # subtitle_mode + if hasattr(parent, "get_actual_subtitle_mode"): + attrs["subtitle_mode"] = parent.get_actual_subtitle_mode() + else: + attrs["subtitle_mode"] = getattr(parent, "subtitle_mode", "") + # output_format + attrs["output_format"] = getattr(parent, "selected_format", "") + # total_char_count + attrs["total_char_count"] = getattr(parent, "char_count", "") + # replace_single_newlines + attrs["replace_single_newlines"] = getattr( + parent, "replace_single_newlines", True + ) + # use_silent_gaps + attrs["use_silent_gaps"] = getattr(parent, "use_silent_gaps", False) + # subtitle_speed_method + attrs["subtitle_speed_method"] = getattr( + parent, "subtitle_speed_method", "tts" + ) + # book handler options + attrs["save_chapters_separately"] = getattr( + parent, "save_chapters_separately", None + ) + attrs["merge_chapters_at_end"] = getattr( + parent, "merge_chapters_at_end", None + ) + else: + # fallback: empty values + attrs = { + k: "" + for k in [ + "lang_code", + "speed", + "voice", + "save_option", + "output_folder", + "subtitle_mode", + "output_format", + "total_char_count", + "replace_single_newlines", + ] + } + attrs["save_chapters_separately"] = None + attrs["merge_chapters_at_end"] = None + return attrs + + def add_files_from_paths(self, file_paths): + from abogen.subtitle_utils import calculate_text_length + from PyQt6.QtWidgets import QMessageBox + import os + + current_attrs = self.get_current_attributes() + duplicates = [] + for file_path in file_paths: + + class QueueItem: + pass + + item = QueueItem() + item.file_name = file_path + item.save_base_path = ( + file_path # For .txt files, processing and save paths are the same + ) + for attr, value in current_attrs.items(): + setattr(item, attr, value) + # Override subtitle_mode to "Disabled" for subtitle files + if file_path.lower().endswith((".srt", ".ass", ".vtt")): + item.subtitle_mode = "Disabled" + # Read file content and calculate total_char_count using calculate_text_length + try: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + file_content = f.read() + item.total_char_count = calculate_text_length(file_content) + except Exception: + item.total_char_count = 0 + # Prevent adding duplicate items to the queue (check all attributes) + is_duplicate = False + for queued_item in self.queue: + if ( + getattr(queued_item, "file_name", None) + == getattr(item, "file_name", None) + and getattr(queued_item, "lang_code", None) + == getattr(item, "lang_code", None) + and getattr(queued_item, "speed", None) + == getattr(item, "speed", None) + and getattr(queued_item, "voice", None) + == getattr(item, "voice", None) + and getattr(queued_item, "save_option", None) + == getattr(item, "save_option", None) + and getattr(queued_item, "output_folder", None) + == getattr(item, "output_folder", None) + and getattr(queued_item, "subtitle_mode", None) + == getattr(item, "subtitle_mode", None) + and getattr(queued_item, "output_format", None) + == getattr(item, "output_format", None) + and getattr(queued_item, "total_char_count", None) + == getattr(item, "total_char_count", None) + and getattr(queued_item, "replace_single_newlines", True) + == getattr(item, "replace_single_newlines", True) + and getattr(queued_item, "use_silent_gaps", False) + == getattr(item, "use_silent_gaps", False) + and getattr(queued_item, "subtitle_speed_method", "tts") + == getattr(item, "subtitle_speed_method", "tts") + and getattr(queued_item, "save_base_path", None) + == getattr(item, "save_base_path", None) + and getattr(queued_item, "save_chapters_separately", None) + == getattr(item, "save_chapters_separately", None) + and getattr(queued_item, "merge_chapters_at_end", None) + == getattr(item, "merge_chapters_at_end", None) + ): + is_duplicate = True + break + if is_duplicate: + duplicates.append(os.path.basename(file_path)) + continue + self.queue.append(item) + if duplicates: + QMessageBox.warning( + self, + "Duplicate Item(s)", + f"Skipping {len(duplicates)} file(s) with the same attributes, already in the queue.", + ) + self.process_queue() + self.update_button_states() + + def add_more_files(self): + from PyQt6.QtWidgets import QFileDialog + + # Allow .txt, .srt, .ass, and .vtt files + files, _ = QFileDialog.getOpenFileNames( + self, + "Select text or subtitle files", + "", + "Supported Files (*.txt *.srt *.ass *.vtt)", + ) + if not files: + return + self.add_files_from_paths(files) + + def resizeEvent(self, event): + super().resizeEvent(event) + if hasattr(self, "empty_overlay"): + self.empty_overlay.resize(self.listwidget.size()) + + def update_button_states(self): + # Enable Remove if at least one item is selected, else disable + if hasattr(self, "remove_button"): + selected_count = len(self.listwidget.selectedItems()) + self.remove_button.setEnabled(selected_count > 0) + if selected_count > 1: + self.remove_button.setText(f"Remove selected ({selected_count})") + else: + self.remove_button.setText("Remove selected") + # Disable Clear if queue is empty + if hasattr(self, "clear_button"): + self.clear_button.setEnabled(bool(self.queue)) + + def show_context_menu(self, pos): + from PyQt6.QtWidgets import QMenu + from PyQt6.QtGui import QAction, QDesktopServices + from PyQt6.QtCore import QUrl + import os + + global_pos = self.listwidget.viewport().mapToGlobal(pos) + selected_items = self.listwidget.selectedItems() + menu = QMenu(self) + if len(selected_items) == 1: + # Add Remove action + remove_action = QAction("Remove this item", self) + remove_action.triggered.connect(self.remove_item) + menu.addAction(remove_action) + + # Get paths for determining if it's a document input + item = selected_items[0] + paths = item.data(Qt.ItemDataRole.UserRole) + if isinstance(paths, dict): + display_path = paths.get("display_path", "") + processing_path = paths.get("processing_path", "") + else: + display_path = paths + processing_path = paths + + doc_exts = (".md", ".markdown", ".pdf", ".epub") + is_document_input = ( + isinstance(display_path, str) + and display_path.lower().endswith(doc_exts) + ) or ( + isinstance(processing_path, str) + and processing_path.lower().endswith(doc_exts) + ) + + # Add Open file action(s) + def open_file_by_path(path_label: str): + from PyQt6.QtWidgets import QMessageBox + + p = display_path if path_label == "display" else processing_path + if not p: + QMessageBox.warning( + self, "File Not Found", "Path is not available." + ) + return + + # Find the queue item and resolve the target path + target_path = None + for q in self.queue: + if ( + getattr(q, "save_base_path", None) == display_path + or q.file_name == display_path + ): + if path_label == "display": + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) + else: + target_path = q.file_name + break + if ( + getattr(q, "save_base_path", None) == processing_path + or q.file_name == processing_path + ): + if path_label == "display": + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) + else: + target_path = q.file_name + break + + # Fallback to the raw path if resolution failed + if not target_path: + target_path = p + + if not os.path.exists(target_path): + QMessageBox.warning( + self, "File Not Found", f"The file does not exist." + ) + return + QDesktopServices.openUrl(QUrl.fromLocalFile(target_path)) + + if is_document_input: + # For documents, show two open options + open_processed_action = QAction("Open processed file", self) + open_processed_action.triggered.connect( + lambda: open_file_by_path("processing") + ) + menu.addAction(open_processed_action) + + open_input_action = QAction("Open input file", self) + open_input_action.triggered.connect( + lambda: open_file_by_path("display") + ) + menu.addAction(open_input_action) + else: + # For plain text files, show single open option + open_file_action = QAction("Open file", self) + open_file_action.triggered.connect(lambda: open_file_by_path("display")) + menu.addAction(open_file_action) + + # Add Go to folder action + # If the queued item represents a converted document (markdown, pdf, epub) + # show two actions: Go to processed file (the cached .txt) and Go to input file (original source) + + from PyQt6.QtWidgets import QMessageBox + + def open_folder_for(path_label: str): + # path_label should be either 'display' or 'processing' + p = display_path if path_label == "display" else processing_path + if not p: + QMessageBox.warning( + self, "File Not Found", "Path is not available." + ) + return + # If the stored path is the display path (original) but the actual file may be + # stored on the queue object differently, try to resolve via the queue entry. + target_path = None + for q in self.queue: + if ( + getattr(q, "save_base_path", None) == display_path + or q.file_name == display_path + ): + if path_label == "display": + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) + else: + target_path = q.file_name + break + if ( + getattr(q, "save_base_path", None) == processing_path + or q.file_name == processing_path + ): + if path_label == "display": + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) + else: + target_path = q.file_name + break + # Fallback to the raw path if resolution failed + if not target_path: + target_path = p + + if not os.path.exists(target_path): + QMessageBox.warning( + self, + "File Not Found", + f"The file does not exist: {target_path}", + ) + return + folder = os.path.dirname(target_path) + if os.path.exists(folder): + QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) + + if is_document_input: + processed_action = QAction("Go to processed file", self) + processed_action.triggered.connect( + lambda: open_folder_for("processing") + ) + menu.addAction(processed_action) + + input_action = QAction("Go to input file", self) + input_action.triggered.connect(lambda: open_folder_for("display")) + menu.addAction(input_action) + else: + # Default behavior for non-document inputs: single "Go to folder" action + go_to_folder_action = QAction("Go to folder", self) + + def go_to_folder(): + item = selected_items[0] + paths = item.data(Qt.ItemDataRole.UserRole) + if isinstance(paths, dict): + file_path = paths.get( + "display_path", paths.get("processing_path", "") + ) + else: + file_path = paths # Fallback for old format + # Find the queue item + for q in self.queue: + if ( + getattr(q, "save_base_path", None) == file_path + or q.file_name == file_path + ): + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) + if not os.path.exists(target_path): + QMessageBox.warning( + self, "File Not Found", f"The file does not exist." + ) + return + folder = os.path.dirname(target_path) + if os.path.exists(folder): + QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) + break + + go_to_folder_action.triggered.connect(go_to_folder) + menu.addAction(go_to_folder_action) + + elif len(selected_items) > 1: + remove_action = QAction(f"Remove selected ({len(selected_items)})", self) + remove_action.triggered.connect(self.remove_item) + menu.addAction(remove_action) + # Always add Clear Queue + clear_action = QAction("Clear Queue", self) + clear_action.triggered.connect(self.clear_queue) + menu.addAction(clear_action) + menu.exec(global_pos) + + def accept(self): + # Save the override state to config so it persists globally + self.config["queue_override_settings"] = self.override_chk.isChecked() + save_config(self.config) + + super().accept() + + def reject(self): + # Cancel: restore original queue + from PyQt6.QtWidgets import QMessageBox + + # Warn if user changed a lot (e.g., more than 1 items difference) + original_count = len(self._original_queue) + current_count = len(self.queue) + if abs(original_count - current_count) > 1: + reply = QMessageBox.question( + self, + "Confirm Cancel", + f"Are you sure you want to cancel and discard all changes?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + self.queue.clear() + self.queue.extend(deepcopy(self._original_queue)) + super().reject() + + def keyPressEvent(self, event): + from PyQt6.QtCore import Qt + + if event.key() == Qt.Key.Key_Delete: + self.remove_item() + else: + super().keyPressEvent(event) diff --git a/abogen/pyqt/queued_item.py b/abogen/pyqt/queued_item.py new file mode 100644 index 0000000..38d4054 --- /dev/null +++ b/abogen/pyqt/queued_item.py @@ -0,0 +1,21 @@ +# represents a queued item - book, chapters, voice, etc. +from dataclasses import dataclass + + +@dataclass +class QueuedItem: + file_name: str + lang_code: str + speed: float + voice: str + save_option: str + output_folder: str + subtitle_mode: str + output_format: str + total_char_count: int + replace_single_newlines: bool = True + use_silent_gaps: bool = False + subtitle_speed_method: str = "tts" + save_base_path: str = None + save_chapters_separately: bool = None + merge_chapters_at_end: bool = None diff --git a/abogen/pyqt/voice_formula_gui.py b/abogen/pyqt/voice_formula_gui.py new file mode 100644 index 0000000..11f717d --- /dev/null +++ b/abogen/pyqt/voice_formula_gui.py @@ -0,0 +1,1521 @@ +import json +import os +from PyQt6.QtWidgets import ( + QDialog, + QVBoxLayout, + QCheckBox, + QLabel, + QHBoxLayout, + QDoubleSpinBox, + QSlider, + QScrollArea, + QWidget, + QPushButton, + QSizePolicy, + QMessageBox, + QFrame, + QLayout, + QStyle, + QListWidget, + QListWidgetItem, + QInputDialog, + QFileDialog, + QSplitter, + QMenu, + QApplication, + QComboBox, +) +from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize +from PyQt6.QtGui import QPixmap, QIcon, QAction +from abogen.constants import ( + VOICES_INTERNAL, + SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, + LANGUAGE_DESCRIPTIONS, + COLORS, +) +import re +import platform +from abogen.utils import get_resource_path +from abogen.voice_profiles import ( + load_profiles, + save_profiles, + delete_profile, + duplicate_profile, + export_profiles, +) + + +# Constants +VOICE_MIXER_WIDTH = 100 +SLIDER_WIDTH = 32 +MIN_WINDOW_WIDTH = 600 +MIN_WINDOW_HEIGHT = 400 +INITIAL_WINDOW_WIDTH = 1200 +INITIAL_WINDOW_HEIGHT = 500 + +# Language options for the language selector loaded from constants +LANGUAGE_OPTIONS = list(LANGUAGE_DESCRIPTIONS.items()) + + +class SaveButtonWidget(QWidget): + def __init__(self, parent, profile_name, save_callback): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.save_btn = QPushButton("Save", self) + self.save_btn.setFixedWidth(48) + self.save_btn.clicked.connect(lambda: save_callback(profile_name)) + layout.addStretch() + layout.addWidget(self.save_btn) + self.setLayout(layout) + + +class FlowLayout(QLayout): + def __init__(self, parent=None, margin=0, spacing=-1): + super().__init__(parent) + if parent: + self.setContentsMargins(margin, margin, margin, margin) + self.setSpacing(spacing) + self._item_list = [] + + def __del__(self): + item = self.takeAt(0) + while item: + item = self.takeAt(0) + + def addItem(self, item): + self._item_list.append(item) + + def count(self): + return len(self._item_list) + + def expandingDirections(self): + return Qt.Orientation(0) + + def hasHeightForWidth(self): + return True + + def sizeHint(self): + return self.minimumSize() + + def itemAt(self, index): + if 0 <= index < len(self._item_list): + return self._item_list[index] + return None + + def takeAt(self, index): + if 0 <= index < len(self._item_list): + return self._item_list.pop(index) + return None + + def heightForWidth(self, width): + return self._do_layout(QRect(0, 0, width, 0), True) + + def setGeometry(self, rect): + super().setGeometry(rect) + self._do_layout(rect, False) + + def minimumSize(self): + size = QSize() + for item in self._item_list: + size = size.expandedTo(item.minimumSize()) + margin, _, _, _ = self.getContentsMargins() + size += QSize(2 * margin, 2 * margin) + return size + + def _do_layout(self, rect, test_only): + x, y = rect.x(), rect.y() + line_height = 0 + spacing = self.spacing() + + for item in self._item_list: + style = self.parentWidget().style() if self.parentWidget() else QStyle() + layout_spacing_x = style.layoutSpacing( + QSizePolicy.ControlType.PushButton, + QSizePolicy.ControlType.PushButton, + Qt.Orientation.Horizontal, + ) + layout_spacing_y = style.layoutSpacing( + QSizePolicy.ControlType.PushButton, + QSizePolicy.ControlType.PushButton, + Qt.Orientation.Vertical, + ) + space_x = spacing if spacing >= 0 else layout_spacing_x + space_y = spacing if spacing >= 0 else layout_spacing_y + + next_x = x + item.sizeHint().width() + space_x + if next_x - space_x > rect.right() and line_height > 0: + x = rect.x() + y = y + line_height + space_y + next_x = x + item.sizeHint().width() + space_x + line_height = 0 + + if not test_only: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + + x = next_x + line_height = max(line_height, item.sizeHint().height()) + + return y + line_height - rect.y() + + +class VoiceMixer(QWidget): + def __init__( + self, voice_name, language_code, initial_status=False, initial_weight=0.0 + ): + super().__init__() + self.voice_name = voice_name + self.setFixedWidth(VOICE_MIXER_WIDTH) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + # TODO Set CSS for rounded corners + # self.setObjectName("VoiceMixer") + # self.setStyleSheet(self.ROUNDED_CSS) + + layout = QVBoxLayout() + + # Name label at the top + name = voice_name + layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter) + + # Voice name label with gender icon + is_female = self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f" + + # Icons layout (flag and gender) + icons_layout = QHBoxLayout() + icons_layout.setSpacing(3) + icons_layout.setAlignment( + Qt.AlignmentFlag.AlignCenter + ) # Center the icons horizontally + + # Flag icon + flag_icon_path = get_resource_path( + "abogen.assets.flags", f"{language_code}.png" + ) + gender_icon_path = get_resource_path( + "abogen.assets", "female.png" if is_female else "male.png" + ) + flag_label = QLabel() + gender_label = QLabel() + flag_pixmap = QPixmap(flag_icon_path) + flag_label.setPixmap( + flag_pixmap.scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + gender_pixmap = QPixmap(gender_icon_path) + gender_label.setPixmap( + gender_pixmap.scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + icons_layout.addWidget(flag_label) + icons_layout.addWidget(gender_label) + + # Add icons layout + layout.addLayout(icons_layout) + + # Checkbox (now below icons) + self.checkbox = QCheckBox() + self.checkbox.setChecked(initial_status) + self.checkbox.stateChanged.connect(self.toggle_inputs) + layout.addWidget(self.checkbox, alignment=Qt.AlignmentFlag.AlignCenter) + + # Spinbox and slider + self.spin_box = QDoubleSpinBox() + self.spin_box.setRange(0, 1) + self.spin_box.setSingleStep(0.01) + self.spin_box.setDecimals(2) + self.spin_box.setValue(initial_weight) + + self.slider = QSlider(Qt.Orientation.Vertical) + self.slider.setRange(0, 100) + self.slider.setValue(int(initial_weight * 100)) + self.slider.setSizePolicy( + QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding + ) + self.slider.setFixedWidth(SLIDER_WIDTH) + + # Apply slider styling after widget is added to window (see showEvent) + self._slider_style_applied = False + + # Connect controls + self.slider.valueChanged.connect(lambda val: self.spin_box.setValue(val / 100)) + self.spin_box.valueChanged.connect( + lambda val: self.slider.setValue(int(val * 100)) + ) + + # Layout for slider and labels + slider_layout = QVBoxLayout() + slider_layout.addWidget(self.spin_box) + slider_layout.addWidget(QLabel("1", alignment=Qt.AlignmentFlag.AlignCenter)) + + slider_center_layout = QHBoxLayout() + slider_center_layout.addWidget( + self.slider, alignment=Qt.AlignmentFlag.AlignHCenter + ) + slider_center_layout.setContentsMargins(0, 0, 0, 0) + + slider_center_widget = QWidget() + slider_center_widget.setLayout(slider_center_layout) + + slider_layout.addWidget(slider_center_widget, stretch=1) + slider_layout.addWidget(QLabel("0", alignment=Qt.AlignmentFlag.AlignCenter)) + slider_layout.setStretch(2, 1) + + layout.addLayout(slider_layout, stretch=1) + self.setLayout(layout) + self.toggle_inputs() + + def showEvent(self, event): + super().showEvent(event) + # Apply slider styling once when widget is shown and has access to parent + if not self._slider_style_applied: + self._slider_style_applied = True + + # Fix slider in Windows + if platform.system() == "Windows": + appstyle = QApplication.instance().style().objectName().lower() + if appstyle != "windowsvista": + # Set custom groove color for disabled state using COLORS["GREY_BACKGROUND"] + self.slider.setStyleSheet( + f""" + QSlider::groove:vertical:disabled {{ + background: {COLORS.get("GREY_BACKGROUND")}; + width: 4px; + border-radius: 4px; + }} + """ + ) + else: + # Apply same fix for Light theme on non-Windows systems + # Get theme from parent window's config + parent_window = self.window() + theme = "system" + while parent_window: + if hasattr(parent_window, "config"): + theme = parent_window.config.get("theme", "system") + break + parent_window = parent_window.parent() + + if theme == "light": + self.slider.setStyleSheet( + f""" + QSlider::groove:vertical:disabled {{ + background: {COLORS.get("GREY_BACKGROUND")}; + width: 4px; + border-radius: 4px; + }} + """ + ) + + def toggle_inputs(self): + is_enabled = self.checkbox.isChecked() + self.spin_box.setEnabled(is_enabled) + self.slider.setEnabled(is_enabled) + + def get_voice_weight(self): + if self.checkbox.isChecked(): + return self.voice_name, self.spin_box.value() + return None + + +class HoverLabel(QLabel): + def __init__(self, text, voice_name, parent=None): + super().__init__(text, parent) + self.voice_name = voice_name + self.setMouseTracking(True) + self.setStyleSheet( + "background-color: rgba(140, 140, 140, 0.15); border-radius: 4px; padding: 3px 6px 3px 6px; margin: 2px;" + ) + + # Create delete button + self.delete_button = QPushButton("×", self) + self.delete_button.setFixedSize(16, 16) + self.delete_button.setStyleSheet( + f""" + QPushButton {{ + background-color: {COLORS.get("RED")}; + color: white; + border-radius: 7px; + font-weight: bold; + font-size: 12px; + border: none; + padding: 0px; + margin: 0px; + }} + QPushButton:hover {{ + background-color: red; + }} + """ + ) + # Make sure the entire button is clickable, not just the text + self.delete_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.delete_button.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents, False + ) + self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.delete_button.hide() + + def resizeEvent(self, event): + super().resizeEvent(event) + # Position the button in the top-right corner with a small margin + self.delete_button.move(self.width() - 16, +0) + + def enterEvent(self, event): + self.delete_button.show() + + def leaveEvent(self, event): + self.delete_button.hide() + + +class VoiceFormulaDialog(QDialog): + def __init__(self, parent=None, initial_state=None, selected_profile=None): + super().__init__(parent) + # Store original profile/mix state for restoration on cancel + self._original_profile_name = None + self._original_mixed_voice_state = None + if parent is not None: + self._original_profile_name = getattr(parent, "selected_profile_name", None) + self._original_mixed_voice_state = getattr( + parent, "mixed_voice_state", None + ) + profiles = load_profiles() + self._virtual_new_profile = False + if not profiles: + # No profiles: show 'New profile' in the list, unsaved, not in JSON + self.current_profile = "New profile" + self._profile_dirty = {"New profile": True} + self._virtual_new_profile = True + profiles = {} # Do not add to JSON yet + else: + self.current_profile = ( + selected_profile + if selected_profile in profiles + else list(profiles.keys())[0] + ) + self._profile_dirty = {name: False for name in profiles} + # Track unsaved states per profile + self._profile_states = {} + # Add subtitle_combo reference if parent has it + self.subtitle_combo = None + if parent is not None and hasattr(parent, "subtitle_combo"): + self.subtitle_combo = parent.subtitle_combo + # Create main container layout with profile section and mixer section + splitter = QSplitter(Qt.Orientation.Horizontal) + # Profile section + profile_widget = QWidget() + profile_layout = QVBoxLayout(profile_widget) + profile_layout.setContentsMargins(0, 0, 0, 0) + # Profile header and save/new buttons + header_layout = QHBoxLayout() + header_layout.addWidget(QLabel("Profiles:")) + header_layout.addStretch() + self.btn_new_profile = QPushButton("New profile") + header_layout.addWidget(self.btn_new_profile) + profile_layout.addLayout(header_layout) + # Profile list + self.profile_list = QListWidget() + self.profile_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection) + self.profile_list.setSelectionBehavior(QListWidget.SelectionBehavior.SelectRows) + self.profile_list.setStyleSheet( + "QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }" + ) + icon = QIcon(get_resource_path("abogen.assets", "profile.png")) + if self._virtual_new_profile: + item = QListWidgetItem(icon, "New profile") + self.profile_list.addItem(item) + self.profile_list.setCurrentRow(0) + else: + for name in profiles: + item = QListWidgetItem(icon, name) + self.profile_list.addItem(item) + idx = list(profiles.keys()).index(self.current_profile) + self.profile_list.setCurrentRow(idx) + profile_layout.addWidget(self.profile_list) + self.profile_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.profile_list.customContextMenuRequested.connect( + self.show_profile_context_menu + ) + self.profile_list.setItemWidget = ( + self.profile_list.setItemWidget + ) # for type hints + # Save and management buttons + mgmt_layout = QVBoxLayout() + self.btn_import_profiles = QPushButton("Import profile(s)") + mgmt_layout.addWidget(self.btn_import_profiles) + self.btn_export_profiles = QPushButton("Export profiles") + mgmt_layout.addWidget(self.btn_export_profiles) + profile_layout.addLayout(mgmt_layout) + # prepare mixer widget + mixer_widget = QWidget() + mixer_layout = QVBoxLayout(mixer_widget) + mixer_layout.setContentsMargins(5, 0, 0, 0) + + self.setWindowTitle("Voice Mixer") + self.setWindowFlags( + Qt.WindowType.Window + | Qt.WindowType.WindowCloseButtonHint + | Qt.WindowType.WindowMaximizeButtonHint + ) + self.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) + self.resize(INITIAL_WINDOW_WIDTH, INITIAL_WINDOW_HEIGHT) + self.voice_mixers = [] + self.last_enabled_voice = None + + # Header label and language selector + self.header_label = QLabel( + "Adjust voice weights to create your preferred voice mix." + ) + self.header_label.setStyleSheet("font-size: 13px;") + self.header_label.setWordWrap(True) + header_row = QHBoxLayout() + header_row.addWidget(self.header_label, 1) + header_row.addStretch() + header_row.addWidget(QLabel("Language:")) + self.language_combo = QComboBox() + for code, desc in LANGUAGE_OPTIONS: + flag = get_resource_path("abogen.assets.flags", f"{code}.png") + if flag and os.path.exists(flag): + self.language_combo.addItem(QIcon(flag), desc, code) + else: + self.language_combo.addItem(desc, code) + # set current language for profile + prof = profiles.get(self.current_profile, {}) + lang = prof.get("language") if isinstance(prof, dict) else None + if not lang: + lang = list(LANGUAGE_DESCRIPTIONS.keys())[0] + idx = self.language_combo.findData(lang) + if idx >= 0: + self.language_combo.setCurrentIndex(idx) + self.language_combo.currentIndexChanged.connect(self.mark_profile_modified) + header_row.addWidget(self.language_combo) + # Preview current voice mix using main window's preview + self.btn_preview_mix = QPushButton("Preview", self) + self.btn_preview_mix.setToolTip("Preview current voice mix") + self.btn_preview_mix.clicked.connect(self.preview_current_mix) + header_row.addWidget(self.btn_preview_mix) + mixer_layout.addLayout(header_row) + + # Error message + self.error_label = QLabel( + "Please select at least one voice and set its weight above 0." + ) + self.error_label.setStyleSheet("color: red; font-weight: bold;") + self.error_label.setWordWrap(True) + self.error_label.hide() + mixer_layout.addWidget(self.error_label) + + # Voice weights display + self.weighted_sums_container = QWidget() + self.weighted_sums_layout = FlowLayout(self.weighted_sums_container) + self.weighted_sums_layout.setSpacing(5) + self.weighted_sums_layout.setContentsMargins(5, 5, 5, 5) + mixer_layout.addWidget(self.weighted_sums_container) + + # Separator + separator = QFrame() + separator.setFrameShadow(QFrame.Shadow.Sunken) + mixer_layout.addWidget(separator) + + # Voice list scroll area + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + self.scroll_area.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + self.scroll_area.viewport().installEventFilter(self) + + self.voice_list_widget = QWidget() + self.voice_list_layout = QHBoxLayout() + self.voice_list_widget.setLayout(self.voice_list_layout) + self.voice_list_widget.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + self.scroll_area.setWidget(self.voice_list_widget) + mixer_layout.addWidget(self.scroll_area, stretch=1) + + # Buttons + button_layout = QHBoxLayout() + clear_all_button = QPushButton("Clear all") + ok_button = QPushButton("OK") + cancel_button = QPushButton("Cancel") + + # Set OK button as default + ok_button.setDefault(True) + ok_button.setFocus() + + # Connect buttons + clear_all_button.clicked.connect(self.clear_all_voices) + ok_button.clicked.connect(self.accept) + # Connect buttons + clear_all_button.clicked.connect(self.clear_all_voices) + ok_button.clicked.connect(self.accept) + cancel_button.clicked.connect(self.reject) + + button_layout.addStretch() + button_layout.addWidget(clear_all_button) + button_layout.addWidget(ok_button) + button_layout.addWidget(cancel_button) + mixer_layout.addLayout(button_layout) + + self.add_voices(initial_state or []) + self.update_weighted_sums() + + # assemble splitter + splitter.addWidget(profile_widget) + splitter.addWidget(mixer_widget) + splitter.setStretchFactor(1, 1) + # set as main layout + self.setLayout(QHBoxLayout()) + self.layout().addWidget(splitter) + + # Connect profile actions + self.profile_list.currentRowChanged.connect(self.on_profile_selection_changed) + # Track initial profile for proper dirty-state saving + self.last_profile_row = self.profile_list.currentRow() + self.btn_new_profile.clicked.connect(self.new_profile) + self.btn_export_profiles.clicked.connect(self.export_all_profiles) + self.btn_import_profiles.clicked.connect(self.import_profiles_dialog) + # Detect modifications in voice mixers + for vm in self.voice_mixers: + vm.spin_box.valueChanged.connect(self.mark_profile_modified) + vm.checkbox.stateChanged.connect(lambda *_: self.mark_profile_modified()) + + def keyPressEvent(self, event): + # Bind Delete key to delete_profile when a profile is selected + if event.key() == Qt.Key.Key_Delete and self.profile_list.hasFocus(): + item = self.profile_list.currentItem() + if item: + self.delete_profile(item) + return + super().keyPressEvent(event) + + def _has_unsaved_changes(self): + # Only return True if there are actually modified (yellow background) profiles + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + # Only consider as unsaved if profile is marked dirty (yellow background) + if item.text().startswith("*"): + return True + return False + + def _prompt_save_changes(self): + dirty_indices = [ + i + for i in range(self.profile_list.count()) + if self.profile_list.item(i).text().startswith("*") + ] + parent = self.parent() + if len(dirty_indices) > 1: + msg = f"You have unsaved changes in {len(dirty_indices)} profiles. Do you want to save all?" + ret = QMessageBox.question( + self, + "Unsaved Changes", + msg, + QMessageBox.StandardButton.Save + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Save, + ) + if ret == QMessageBox.StandardButton.Save: + # Save all using stored states + profiles = load_profiles() + for i in dirty_indices: + name = self.profile_list.item(i).text().lstrip("*") + state = self._profile_states.get(name) + if state is not None: + profiles[name] = state + self._profile_dirty[name] = False + save_profiles(profiles) + # clear states + for name in list(self._profile_states.keys()): + if name not in profiles: + continue + del self._profile_states[name] + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + # clear markers + for i in dirty_indices: + item = self.profile_list.item(i) + n = item.text().lstrip("*") + item.setText(n) + self.update_profile_save_buttons() + self.update_profile_list_colors() + return True + elif ret == QMessageBox.StandardButton.Discard: + # Discard all modifications + self._profile_states.clear() + for i in dirty_indices: + item = self.profile_list.item(i) + n = item.text().lstrip("*") + item.setText(n) + self._profile_dirty[n] = False + self.update_profile_save_buttons() + self.update_profile_list_colors() + # reload current profile + profiles = load_profiles() + if self.current_profile in profiles: + self.load_profile_state(self.current_profile) + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + return True + else: + return False + else: + # Fallback to original logic for 0 or 1 dirty profile + box = QMessageBox(self) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle("Unsaved Changes") + box.setText( + "You have unsaved changes in your profile. Do you want to save the changes?" + ) + box.setStandardButtons( + QMessageBox.StandardButton.Save + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel + ) + box.setDefaultButton(QMessageBox.StandardButton.Save) + ret = box.exec() + if ret == QMessageBox.StandardButton.Save: + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + if ( + self._profile_dirty.get(name, False) + or item.text().startswith("*") + or (name == self.current_profile) + ): + self.profile_list.setCurrentRow(i) + self.save_profile_by_name(name) + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + return True + elif ret == QMessageBox.StandardButton.Discard: + profiles = load_profiles() + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + self._profile_dirty[name] = False + if item.text().startswith("*"): + item.setText(name) + self.update_profile_save_buttons() + self.update_profile_list_colors() + if self.current_profile in profiles: + self.load_profile_state(self.current_profile) + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + return True + else: + return False + + def on_profile_selection_changed(self, row): + # Save dirty state for previous profile + if hasattr(self, "last_profile_row") and self.last_profile_row is not None: + prev_item = self.profile_list.item(self.last_profile_row) + if prev_item: + prev_name = prev_item.text().lstrip("*") + self._profile_dirty[prev_name] = prev_item.text().startswith("*") + # Do NOT auto-save if modifications pending + # load new profile + item = self.profile_list.item(row) + if item: + name = item.text().lstrip("*") + self.load_profile_state(name) + # Restore dirty state for this profile + dirty = self._profile_dirty.get(name, False) + if dirty and not item.text().startswith("*"): + item.setText("*" + item.text()) + elif not dirty and item.text().startswith("*"): + item.setText(item.text().lstrip("*")) + self.last_profile_row = row + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def add_voices(self, initial_state): + first_enabled_voice = None + for voice in VOICES_INTERNAL: + language_code = voice[0] # First character is the language code + matching_voice = next( + (item for item in initial_state if item[0] == voice), None + ) + initial_status = matching_voice is not None + initial_weight = matching_voice[1] if matching_voice else 1.0 + voice_mixer = self.add_voice( + voice, language_code, initial_status, initial_weight + ) + if initial_status and first_enabled_voice is None: + first_enabled_voice = voice_mixer + + if first_enabled_voice: + QTimer.singleShot( + 0, lambda: self.scroll_area.ensureWidgetVisible(first_enabled_voice) + ) + + def add_voice( + self, voice_name, language_code, initial_status=False, initial_weight=1.0 + ): + voice_mixer = VoiceMixer( + voice_name, language_code, initial_status, initial_weight + ) + self.voice_mixers.append(voice_mixer) + self.voice_list_layout.addWidget(voice_mixer) + voice_mixer.checkbox.stateChanged.connect( + lambda state, vm=voice_mixer: self.handle_voice_checkbox(vm, state) + ) + voice_mixer.spin_box.valueChanged.connect(self.update_weighted_sums) + voice_mixer.checkbox.stateChanged.connect(self.update_weighted_sums) + voice_mixer.spin_box.valueChanged.connect(self.mark_profile_modified) + voice_mixer.checkbox.stateChanged.connect( + lambda *_: self.mark_profile_modified() + ) + return voice_mixer + + def handle_voice_checkbox(self, voice_mixer, state): + if state == Qt.CheckState.Checked.value: + self.last_enabled_voice = voice_mixer.voice_name + self.update_weighted_sums() + + def get_selected_voices(self): + return [ + v + for v in (m.get_voice_weight() for m in self.voice_mixers) + if v and v[1] > 0 + ] + + def update_weighted_sums(self): + # Clear previous labels + while self.weighted_sums_layout.count(): + item = self.weighted_sums_layout.takeAt(0) + if item and item.widget(): + item.widget().deleteLater() + + # Get selected voices + selected = [ + (m.voice_name, m.spin_box.value()) + for m in self.voice_mixers + if m.checkbox.isChecked() and m.spin_box.value() > 0 + ] + + total = sum(w for _, w in selected) + # disable Preview if no voices selected, but don't enable while loading + if not getattr(self, "_loading", False): + self.btn_preview_mix.setEnabled(total > 0) + + if total > 0: + self.error_label.hide() + self.weighted_sums_container.show() + + # Reorder so last enabled voice is at the end + if self.last_enabled_voice and any( + name == self.last_enabled_voice for name, _ in selected + ): + others = [(n, w) for n, w in selected if n != self.last_enabled_voice] + last = [(n, w) for n, w in selected if n == self.last_enabled_voice] + selected = others + last + + # Add voice labels + for name, weight in selected: + percentage = weight / total * 100 + # Make the voice name bold and include percentage + voice_label = HoverLabel( + f'{name}: {percentage:.1f}%', + name, + ) + voice_label.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred + ) + voice_label.delete_button.clicked.connect( + lambda _, vn=name: self.disable_voice_by_name(vn) + ) + self.weighted_sums_layout.addWidget(voice_label) + else: + self.error_label.show() + self.weighted_sums_container.hide() + + def disable_voice_by_name(self, voice_name): + for mixer in self.voice_mixers: + if mixer.voice_name == voice_name: + mixer.checkbox.setChecked(False) + break + + def clear_all_voices(self): + for mixer in self.voice_mixers: + mixer.checkbox.setChecked(False) + + def eventFilter(self, source, event): + if source is self.scroll_area.viewport() and event.type() == event.Type.Wheel: + # Skip if over an enabled slider + if any( + mixer.slider.underMouse() and mixer.slider.isEnabled() + for mixer in self.voice_mixers + ): + return False + + # Horizontal scrolling + horiz_bar = self.scroll_area.horizontalScrollBar() + delta = -120 if event.angleDelta().y() > 0 else 120 + horiz_bar.setValue(horiz_bar.value() + delta) + return True + return super().eventFilter(source, event) + + def load_profile_state(self, profile_name): + name = profile_name.lstrip("*") + profiles = load_profiles() + # load voices and language from state or JSON + if name in self._profile_states: + state = self._profile_states[name] + else: + state = profiles.get(name, {}) + voices = state.get("voices") if isinstance(state, dict) else state + lang = state.get("language") if isinstance(state, dict) else None + # apply language selection + if lang: + i = self.language_combo.findData(lang) + if i >= 0: + self.language_combo.blockSignals(True) + self.language_combo.setCurrentIndex(i) + self.language_combo.blockSignals(False) + self.current_profile = name + weights = {n: w for n, w in voices} + for vm in self.voice_mixers: + weight = weights.get(vm.voice_name, 0.0) + # block signals to avoid triggering updates + vm.checkbox.blockSignals(True) + vm.spin_box.blockSignals(True) + vm.slider.blockSignals(True) + vm.checkbox.setChecked(weight > 0) + val = weight if weight > 0 else 1.0 + vm.spin_box.setValue(val) + vm.slider.setValue(int(val * 100)) + # restore signals + vm.checkbox.blockSignals(False) + vm.spin_box.blockSignals(False) + vm.slider.blockSignals(False) + # sync enabled state + vm.toggle_inputs() + self.update_weighted_sums() + + def save_profile_by_name(self, name): + profiles = load_profiles() + state = self._profile_states.get(name, None) + if state is not None: + # ensure dict format + if isinstance(state, dict): + entry = state + else: + entry = {"voices": state, "language": self.language_combo.currentData()} + profiles[name] = entry + save_profiles(profiles) + self._profile_dirty[name] = False + del self._profile_states[name] + self._virtual_new_profile = False + # Remove * marker + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + if item.text().lstrip("*") == name: + item.setText(name) + break + self.update_profile_list_colors() + self.update_profile_save_buttons() + self.update_weighted_sums() + + def _handle_zero_weight_profiles(self): + profiles = load_profiles() + if len(profiles) < 1: + return False + zero = [] + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + weights = profiles.get(name, {}).get("voices", []) + total = 0 + if isinstance(weights, list): + for entry in weights: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + zero.append((i, name)) + if not zero: + return False + msg = f"{len(zero)} invalid profile(s) with no voices selected or their total weights are 0. They will be ignored and deleted. Do you want to delete?" + reply = QMessageBox.question( + self, + "Invalid Profiles", + msg, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Yes, + ) + if reply == QMessageBox.StandardButton.Yes: + for i, name in reversed(zero): + self.profile_list.takeItem(i) + delete_profile(name) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_list_colors() + self.update_profile_save_buttons() + return False + else: + idx, _ = zero[0] + self.profile_list.setCurrentRow(idx) + return True + + def accept(self): + # If no profiles, treat as cancel + if self.profile_list.count() == 0: + # Update subtitle_mode to match combo before closing + if self.subtitle_combo: + parent = self.parent() + if parent is not None: + parent.subtitle_mode = self.subtitle_combo.currentText() + self.reject() + return + # Prompt to save if unsaved changes, then check for zero-weight error after save + if self._has_unsaved_changes(): + if not self._prompt_save_changes(): + return + if self._handle_zero_weight_profiles(): + return + selected_voices = self.get_selected_voices() + total_weight = sum(weight for _, weight in selected_voices) + if total_weight == 0: + QMessageBox.warning( + self, + "Invalid Weights", + "The total weight of selected voices cannot be zero. Please select at least one voice or adjust the weights.", + ) + self.update_weighted_sums() + return + # Save weights to current profile + profiles = load_profiles() + profiles[self.current_profile] = { + "voices": selected_voices, + "language": self.language_combo.currentData(), + } + save_profiles(profiles) + # Mark this profile as not dirty + self._profile_dirty[self.current_profile] = False + super().accept() + + def reject(self): + # Restore parent's profile/mix state on cancel + parent = self.parent() + if parent is not None: + if hasattr(self, "_original_profile_name"): + parent.selected_profile_name = self._original_profile_name + if hasattr(self, "_original_mixed_voice_state"): + parent.mixed_voice_state = self._original_mixed_voice_state + # Prompt to save if unsaved changes, then check for zero-weight error after save + if self._has_unsaved_changes(): + if not self._prompt_save_changes(): + return + if self._handle_zero_weight_profiles(): + return + super().reject() + + def closeEvent(self, event): + # Restore parent's profile/mix state on close + parent = self.parent() + if parent is not None: + if hasattr(self, "_original_profile_name"): + parent.selected_profile_name = self._original_profile_name + if hasattr(self, "_original_mixed_voice_state"): + parent.mixed_voice_state = self._original_mixed_voice_state + # Prompt to save if unsaved changes, then check for zero-weight error after save + if self._has_unsaved_changes(): + if not self._prompt_save_changes(): + event.ignore() + return + if self._handle_zero_weight_profiles(): + event.ignore() + return + super().closeEvent(event) + + def _parse_rgba_to_qcolor(self, rgba_str): + from PyQt6.QtCore import Qt + from PyQt6.QtGui import QColor + + """Helper to convert 'rgba(R,G,B,A_float)' string to QColor.""" + match = re.match(r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)", rgba_str) + if match: + r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3)) + a_float = float(match.group(4)) + a_int = int(a_float * 255) + return QColor(r, g, b, a_int) + return Qt.GlobalColor.transparent + + def mark_profile_modified(self): + item = self.profile_list.currentItem() + if item and not item.text().startswith("*"): + item.setText("*" + item.text()) + # Flag profile as dirty and store unsaved state + name = self.current_profile + self._profile_dirty[name] = True + self._profile_states[name] = { + "voices": self.get_selected_voices(), + "language": self.language_combo.currentData(), + } + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def new_profile(self): + import re + + while True: + name, ok = QInputDialog.getText(self, "New Profile", "Enter profile name:") + if not ok or not name: + break + name = name.strip() # Remove leading/trailing spaces + if not name: + continue + if not re.match(r"^[\w\- ]+$", name): + QMessageBox.warning( + self, + "Invalid Name", + "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.", + ) + continue + profiles = load_profiles() + # Remove 'New profile' placeholder if not persisted in JSON + if ( + self.profile_list.count() == 1 + and self.profile_list.item(0).text() == "New profile" + and "New profile" not in profiles + ): + self.profile_list.takeItem(0) + self._virtual_new_profile = False + self._profile_dirty.pop("New profile", None) + if name in profiles: + QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") + continue + profiles[name] = { + "voices": [], + "language": self.language_combo.currentData(), + } + save_profiles(profiles) + self.profile_list.addItem( + QListWidgetItem( + QIcon(get_resource_path("abogen.assets", "profile.png")), name + ) + ) + self.profile_list.setCurrentRow(self.profile_list.count() - 1) + # reset UI mixers + for vm in self.voice_mixers: + vm.checkbox.setChecked(False) + vm.spin_box.setValue(1.0) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + break + self.update_profile_save_buttons() + self.update_profile_list_colors() + self.update_weighted_sums() + + def export_all_profiles(self): + # Prevent export if any profile has total weight 0 + profiles = load_profiles() + for name, weights in profiles.items(): + total = 0 + voices = weights.get("voices", []) + if isinstance(voices, list): + for entry in voices: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + QMessageBox.warning( + self, + "Export Blocked", + f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.", + ) + return + path, _ = QFileDialog.getSaveFileName( + self, "Export Profiles", "voice_profiles", "JSON Files (*.json)" + ) + if path: + export_profiles(path) + + def import_profiles_dialog(self): + path, _ = QFileDialog.getOpenFileName( + self, "Import Profiles", "", "JSON Files (*.json)" + ) + if path: + from abogen.voice_profiles import load_profiles, save_profiles + + # Try to read the file and count profiles + try: + import json + + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + # always expect abogen_voice_profiles wrapper + if not (isinstance(data, dict) and "abogen_voice_profiles" in data): + QMessageBox.warning( + self, + "Invalid File", + "This file is not a valid abogen voice profiles file.", + ) + return + imported_profiles = data["abogen_voice_profiles"] + if not isinstance(imported_profiles, dict): + QMessageBox.warning( + self, + "Invalid File", + "This file is not a valid abogen voice profiles file.", + ) + return + count = len(imported_profiles) + except Exception: + QMessageBox.warning( + self, "Import Error", "Could not read the selected file." + ) + return + if count == 0: + QMessageBox.information( + self, "No Profiles", "No profiles found in the selected file." + ) + return + profiles = load_profiles() + collisions = [name for name in imported_profiles if name in profiles] + # Combine prompts: show both import count and overwrite count if any + if count == 1: + orig_name = next(iter(imported_profiles.keys())) + msg = f"Profile '{orig_name}' will be imported." + if collisions: + msg += f"\nThis will overwrite an existing profile." + msg += "\nContinue?" + reply = QMessageBox.question( + self, + "Import Profile", + msg, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + profiles.update(imported_profiles) + save_profiles(profiles) + QMessageBox.information( + self, + "Profile Imported", + f"Profile '{orig_name}' imported successfully.", + ) + else: + msg = f"{count} profiles will be imported." + if collisions: + msg += f"\n{len(collisions)} profile(s) will be overwritten." + msg += "\nContinue?" + reply = QMessageBox.question( + self, + "Import Profiles", + msg, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + profiles.update(imported_profiles) + save_profiles(profiles) + QMessageBox.information( + self, + "Profiles Imported", + f"{count} profiles imported successfully.", + ) + # Refresh list + self.profile_list.clear() + profiles = load_profiles() + for nm in profiles: + self.profile_list.addItem( + QListWidgetItem( + QIcon(get_resource_path("abogen.assets", "profile.png")), nm + ) + ) + if self.profile_list.count() > 0: + self.profile_list.setCurrentRow(0) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self._virtual_new_profile = False + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def show_profile_context_menu(self, pos): + item = self.profile_list.itemAt(pos) + if not item: + return + name = item.text().lstrip("*") + menu = QMenu(self) + rename_act = QAction("Rename", self) + delete_act = QAction("Delete", self) + dup_act = QAction("Duplicate", self) + export_act = QAction("Export this profile", self) + menu.addAction(rename_act) + menu.addAction(dup_act) + menu.addAction(export_act) + menu.addAction(delete_act) + act = menu.exec(self.profile_list.viewport().mapToGlobal(pos)) + if act == rename_act: + self.rename_profile(item) + elif act == delete_act: + self.delete_profile(item) + elif act == dup_act: + self.duplicate_profile(item) + elif act == export_act: + self.export_selected_profile_item(item) + + def export_selected_profile_item(self, item): + if not item: + return + name = item.text().lstrip("*") + profiles = load_profiles() + weights = profiles.get(name, {}).get("voices", []) + total = 0 + if isinstance(weights, list): + for entry in weights: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + QMessageBox.warning( + self, + "Export Blocked", + f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.", + ) + return + path, _ = QFileDialog.getSaveFileName( + self, "Export Profile", f"{name}.json", "JSON Files (*.json)" + ) + if path: + # Use abogen_voice_profiles wrapper for single profile export + with open(path, "w", encoding="utf-8") as f: + json.dump( + {"abogen_voice_profiles": {name: profiles.get(name, {})}}, + f, + indent=2, + ) + + def rename_profile(self, item): + name = item.text().lstrip("*") + # block if profile has unsaved changes and it's not a virtual New profile + if self._profile_dirty.get(name, False) and not ( + self._virtual_new_profile and name == "New profile" + ): + QMessageBox.warning( + self, "Unsaved Changes", "Please save the profile before renaming." + ) + return + old = item.text().lstrip("*") + import re + + while True: + new, ok = QInputDialog.getText( + self, "Rename Profile", f"Profile name:", text=old + ) + if not ok or not new or new == old: + break + new = new.strip() # Remove leading/trailing spaces + if not new: + continue + if not re.match(r"^[\w\- ]+$", new): + QMessageBox.warning( + self, + "Invalid Name", + "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.", + ) + continue + + profiles = load_profiles() + if new in profiles: + QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") + continue + + # Special case for renaming the virtual "New profile" + if self._virtual_new_profile and name == "New profile": + # Create the profile with the new name + profiles[new] = { + "voices": self.get_selected_voices(), + "language": self.language_combo.currentData(), + } + save_profiles(profiles) + + # Update tracking properties + self._virtual_new_profile = False + self._profile_dirty.pop("New profile", None) + self._profile_dirty[new] = False + + # Update the current profile name + self.current_profile = new + item.setText(new) + else: + # Standard renaming for regular profiles + profiles[new] = profiles.pop(old) + save_profiles(profiles) + item.setText(new) + + # Update the current profile name if it was renamed + if self.current_profile == old: + self.current_profile = new + + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + break + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def delete_profile(self, item): + name = item.text().lstrip("*") + if self._virtual_new_profile and name == "New profile": + row = self.profile_list.row(item) + self.profile_list.takeItem(row) + self._virtual_new_profile = False + self._profile_dirty.pop("New profile", None) + self.update_profile_save_buttons() + self.update_profile_list_colors() + return + reply = QMessageBox.question( + self, + "Delete Profile", + f"Delete profile '{name}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + delete_profile(name) + row = self.profile_list.row(item) + self.profile_list.takeItem(row) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def duplicate_profile(self, item): + name = item.text().lstrip("*") + # block duplicating if profile has unsaved changes + if self._profile_dirty.get(name, False): + QMessageBox.warning( + self, "Unsaved Changes", "Please save the profile before duplicating." + ) + return + src = item.text().lstrip("*") + profiles = load_profiles() + base = f"{src}_duplicate" + new = base + i = 1 + while new in profiles: + new = f"{base}{i}" + i += 1 + duplicate_profile(src, new) + self.profile_list.addItem( + QListWidgetItem( + QIcon(get_resource_path("abogen.assets", "profile.png")), new + ) + ) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def update_profile_save_buttons(self): + # Remove all save buttons first + for i in range(self.profile_list.count()): + self.profile_list.setItemWidget(self.profile_list.item(i), None) + # Add save button to dirty profiles + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + if item.text().startswith("*"): + widget = SaveButtonWidget( + self.profile_list, name, self.save_profile_by_name + ) + self.profile_list.setItemWidget(item, widget) + + def update_profile_list_colors(self): + from PyQt6.QtCore import Qt + + profiles = load_profiles() + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + if self._virtual_new_profile and name == "New profile": + color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND")) + item.setData(Qt.ItemDataRole.BackgroundRole, color) + elif item.text().startswith("*"): + color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND")) + item.setData(Qt.ItemDataRole.BackgroundRole, color) + else: + item.setData( + Qt.ItemDataRole.BackgroundRole, + self.profile_list.palette().base().color(), + ) + weights = profiles.get(name, {}).get("voices", []) + total = 0 + if isinstance(weights, list): + for entry in weights: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + color = self._parse_rgba_to_qcolor(COLORS.get("RED_BACKGROUND")) + item.setData(Qt.ItemDataRole.BackgroundRole, color) + self.update_profile_save_buttons() + + def preview_current_mix(self): + # Disable preview until playback completes + self.btn_preview_mix.setEnabled(False) + self.btn_preview_mix.setText("Loading...") + self._loading = True + parent = self.parent() + if parent and hasattr(parent, "preview_voice"): + # Apply mixed voices and selected language + parent.mixed_voice_state = self.get_selected_voices() + parent.selected_profile_name = None + lang = self.language_combo.currentData() + parent.selected_lang = lang + parent.subtitle_combo.setEnabled( + lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + ) + # Reset start flag and trigger preview + self._started = False + parent.preview_voice() + # Poll preview_playing: wait for start then end + self._preview_poll_timer = QTimer(self) + self._preview_poll_timer.timeout.connect(self._check_preview_done) + self._preview_poll_timer.start(200) + + def _check_preview_done(self): + parent = self.parent() + if parent and hasattr(parent, "preview_playing"): + # Mark when playback starts + if parent.preview_playing: + self._started = True + # Update button text to "Playing..." when playback starts + self.btn_preview_mix.setText("Playing...") + # Once started and then stopped, re-enable + elif getattr(self, "_started", False): + self.btn_preview_mix.setEnabled(True) + self.btn_preview_mix.setText("Preview") + self._loading = False + self._preview_poll_timer.stop() diff --git a/abogen/queue_manager_gui.py b/abogen/queue_manager_gui.py index 384be79..60023b1 100644 --- a/abogen/queue_manager_gui.py +++ b/abogen/queue_manager_gui.py @@ -1,860 +1,11 @@ -# a simple window with a list of items in the queue, no checkboxes -# button to remove an item from the queue -# button to clear the queue +"""Backwards-compatible re-export of the PyQt queue manager. -from PyQt6.QtWidgets import ( - QDialog, - QVBoxLayout, - QHBoxLayout, - QDialogButtonBox, - QPushButton, - QListWidget, - QListWidgetItem, - QFileIconProvider, - QLabel, - QWidget, - QSizePolicy, - QAbstractItemView, - QCheckBox, -) -from PyQt6.QtCore import QFileInfo, Qt -from abogen.constants import COLORS -from copy import deepcopy -from PyQt6.QtGui import QFontMetrics -from abogen.utils import load_config, save_config +The actual implementation lives in abogen.pyqt.queue_manager_gui. +""" -# Define attributes that are safe to override with global settings -OVERRIDE_FIELDS = [ - "lang_code", - "speed", - "voice", - "save_option", - "output_folder", - "subtitle_mode", - "output_format", - "replace_single_newlines", - "use_silent_gaps", - "subtitle_speed_method", -] +from __future__ import annotations +from abogen.pyqt.queue_manager_gui import * # noqa: F401, F403 +from abogen.pyqt.queue_manager_gui import QueueManager -class ElidedLabel(QLabel): - def __init__(self, text): - super().__init__(text) - self._full_text = text - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - self.setTextFormat(Qt.TextFormat.PlainText) - - def setText(self, text): - self._full_text = text - super().setText(text) - self.update() - - def resizeEvent(self, event): - metrics = QFontMetrics(self.font()) - elided = metrics.elidedText( - self._full_text, Qt.TextElideMode.ElideRight, self.width() - ) - super().setText(elided) - super().resizeEvent(event) - - def fullText(self): - return self._full_text - - -class QueueListItemWidget(QWidget): - def __init__(self, file_name, char_count): - super().__init__() - layout = QHBoxLayout() - layout.setContentsMargins(12, 0, 6, 0) - layout.setSpacing(0) - import os - - name_label = ElidedLabel(os.path.basename(file_name)) - char_label = QLabel(f"Chars: {char_count}") - char_label.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']};") - char_label.setAlignment( - Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter - ) - char_label.setSizePolicy( - QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred - ) - layout.addWidget(name_label, 1) - layout.addWidget(char_label, 0) - self.setLayout(layout) - - -class DroppableQueueListWidget(QListWidget): - def __init__(self, parent_dialog): - super().__init__() - self.parent_dialog = parent_dialog - self.setAcceptDrops(True) - # Overlay for drag hover - self.drag_overlay = QLabel("", self) - self.drag_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.drag_overlay.setStyleSheet( - f"border:2px dashed {COLORS['BLUE_BORDER_HOVER']}; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG_HOVER']};" - ) - self.drag_overlay.setVisible(False) - self.drag_overlay.setAttribute( - Qt.WidgetAttribute.WA_TransparentForMouseEvents, True - ) - - def dragEnterEvent(self, event): - if event.mimeData().hasUrls(): - for url in event.mimeData().urls(): - file_path = url.toLocalFile().lower() - if url.isLocalFile() and ( - file_path.endswith(".txt") - or file_path.endswith((".srt", ".ass", ".vtt")) - ): - self.drag_overlay.resize(self.size()) - self.drag_overlay.setVisible(True) - event.acceptProposedAction() - return - self.drag_overlay.setVisible(False) - event.ignore() - - def dragMoveEvent(self, event): - if event.mimeData().hasUrls(): - for url in event.mimeData().urls(): - file_path = url.toLocalFile().lower() - if url.isLocalFile() and ( - file_path.endswith(".txt") - or file_path.endswith((".srt", ".ass", ".vtt")) - ): - event.acceptProposedAction() - return - event.ignore() - - def dragLeaveEvent(self, event): - self.drag_overlay.setVisible(False) - event.accept() - - def dropEvent(self, event): - self.drag_overlay.setVisible(False) - if event.mimeData().hasUrls(): - file_paths = [ - url.toLocalFile() - for url in event.mimeData().urls() - if url.isLocalFile() - and ( - url.toLocalFile().lower().endswith(".txt") - or url.toLocalFile().lower().endswith((".srt", ".ass", ".vtt")) - ) - ] - if file_paths: - self.parent_dialog.add_files_from_paths(file_paths) - event.acceptProposedAction() - else: - event.ignore() - else: - event.ignore() - - def resizeEvent(self, event): - super().resizeEvent(event) - if hasattr(self, "drag_overlay"): - self.drag_overlay.resize(self.size()) - - -class QueueManager(QDialog): - def __init__(self, parent, queue: list, title="Queue Manager", size=(600, 700)): - super().__init__() - self.queue = queue - self._original_queue = deepcopy( - queue - ) # Store a deep copy of the original queue - self.parent = parent - self.config = load_config() # Load config for persistence - - layout = QVBoxLayout() - layout.setContentsMargins(15, 15, 15, 15) # set main layout margins - layout.setSpacing(12) # set spacing between widgets in main layout - # list of queued items - self.listwidget = DroppableQueueListWidget(self) - self.listwidget.setSelectionMode( - QAbstractItemView.SelectionMode.ExtendedSelection - ) - self.listwidget.setAlternatingRowColors(True) - self.listwidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.listwidget.customContextMenuRequested.connect(self.show_context_menu) - # Add informative instructions at the top - instructions = QLabel( - "

    How Queue Works?

    " - "You can add text and subtitle files (.txt, .srt, .ass, .vtt) directly using the 'Add files' button below. " - "To add PDF, EPUB or markdown files, use the input box in the main window and click the 'Add to Queue' button. " - "By default, each file in the queue keeps the configuration settings active when they were added. " - "Enabling the 'Override item settings with current selection' option below will force all items to use the configuration currently selected in the main window. " - "You can view each file's configuration by hovering over them." - ) - instructions.setAlignment(Qt.AlignmentFlag.AlignLeft) - instructions.setWordWrap(True) - layout.addWidget(instructions) - - # Override Checkbox - self.override_chk = QCheckBox("Override item settings with current selection") - self.override_chk.setToolTip( - "If checked, all items in the queue will be processed using the \n" - "settings currently selected in the main window, ignoring their saved state." - ) - # Load saved state (default to False) - self.override_chk.setChecked(self.config.get("queue_override_settings", False)) - # Trigger process_queue to update tooltips immediately when toggled - self.override_chk.stateChanged.connect(self.process_queue) - self.override_chk.setStyleSheet("margin-bottom: 8px;") - layout.addWidget(self.override_chk) - - # Overlay label for empty queue - self.empty_overlay = QLabel( - "Drag and drop your text or subtitle files here or use the 'Add files' button.", - self.listwidget, - ) - self.empty_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.empty_overlay.setStyleSheet( - f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;" - ) - self.empty_overlay.setWordWrap(True) - self.empty_overlay.setAttribute( - Qt.WidgetAttribute.WA_TransparentForMouseEvents, True - ) - self.empty_overlay.hide() - # add queue items to the list - self.process_queue() - - button_row = QHBoxLayout() - button_row.setContentsMargins(0, 0, 0, 0) # optional: no margins for button row - button_row.setSpacing(7) # set spacing between buttons - # Add files button - add_files_button = QPushButton("Add files") - add_files_button.setFixedHeight(40) - add_files_button.clicked.connect(self.add_more_files) - button_row.addWidget(add_files_button) - - # Remove button - self.remove_button = QPushButton("Remove selected") - self.remove_button.setFixedHeight(40) - self.remove_button.clicked.connect(self.remove_item) - button_row.addWidget(self.remove_button) - - # Clear button - self.clear_button = QPushButton("Clear Queue") - self.clear_button.setFixedHeight(40) - self.clear_button.clicked.connect(self.clear_queue) - button_row.addWidget(self.clear_button) - - layout.addLayout(button_row) - layout.addWidget(self.listwidget) - - # Connect selection change to update button state - self.listwidget.currentItemChanged.connect(self.update_button_states) - self.listwidget.itemSelectionChanged.connect(self.update_button_states) - - buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, - self, - ) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - - layout.addWidget(buttons) - - self.setLayout(layout) - - self.setWindowTitle(title) - self.resize(*size) - - self.update_button_states() - - def process_queue(self): - """Process the queue items.""" - import os - - self.listwidget.clear() - if not self.queue: - self.empty_overlay.show() - self.update_button_states() - return - else: - self.empty_overlay.hide() - - # Get current global settings and checkbox state for overrides - current_global_settings = self.get_current_attributes() - is_override_active = self.override_chk.isChecked() - - icon_provider = QFileIconProvider() - for item in self.queue: - # Dynamic Attribute Retrieval Helper - def get_val(attr, default=""): - # If override is ON and attr is overrideable, use global setting - if is_override_active and attr in OVERRIDE_FIELDS: - return current_global_settings.get(attr, default) - # Otherwise return the item's saved attribute - return getattr(item, attr, default) - - # Determine display file path (prefer save_base_path for original file) - display_file_path = getattr(item, "save_base_path", None) or item.file_name - processing_file_path = item.file_name - - # Normalize paths for consistent display (fixes Windows path separator issues) - display_file_path = ( - os.path.normpath(display_file_path) - if display_file_path - else display_file_path - ) - processing_file_path = ( - os.path.normpath(processing_file_path) - if processing_file_path - else processing_file_path - ) - - # Only show the file name, not the full path - display_name = display_file_path - - if os.path.sep in display_file_path: - display_name = os.path.basename(display_file_path) - # Get icon for the display file - icon = icon_provider.icon(QFileInfo(display_file_path)) - list_item = QListWidgetItem() - - # Tooltip Generation - tooltip = "" - # If override is active, add the warning header on its own line - if is_override_active: - tooltip += "(Global Override Active)
    " - - output_folder = get_val("output_folder") - # For plain .txt inputs we don't need to show a separate processing file - show_processing = True - try: - if isinstance( - display_file_path, str - ) and display_file_path.lower().endswith(".txt"): - show_processing = False - except Exception: - show_processing = True - - tooltip += f"Input File: {display_file_path}
    " - if ( - show_processing - and processing_file_path - and processing_file_path != display_file_path - ): - tooltip += f"Processing File: {processing_file_path}
    " - - tooltip += ( - f"Language: {get_val('lang_code')}
    " - f"Speed: {get_val('speed')}
    " - f"Voice: {get_val('voice')}
    " - f"Save Option: {get_val('save_option')}
    " - ) - if output_folder not in (None, "", "None"): - tooltip += f"Output Folder: {output_folder}
    " - tooltip += ( - f"Subtitle Mode: {get_val('subtitle_mode')}
    " - f"Output Format: {get_val('output_format')}
    " - f"Characters: {getattr(item, 'total_char_count', '')}
    " - f"Replace Single Newlines: {get_val('replace_single_newlines', True)}
    " - f"Use Silent Gaps: {get_val('use_silent_gaps', False)}
    " - f"Speed Method: {get_val('subtitle_speed_method', 'tts')}" - ) - # Add book handler options if present (Preserve logic: specific to file structure) - save_chapters_separately = getattr(item, "save_chapters_separately", None) - merge_chapters_at_end = getattr(item, "merge_chapters_at_end", None) - if save_chapters_separately is not None: - tooltip += f"
    Save chapters separately: {'Yes' if save_chapters_separately else 'No'}" - # Only show merge option if saving chapters separately - if save_chapters_separately and merge_chapters_at_end is not None: - tooltip += f"
    Merge chapters at the end: {'Yes' if merge_chapters_at_end else 'No'}" - list_item.setToolTip(tooltip) - list_item.setIcon(icon) - # Store both paths for context menu - list_item.setData( - Qt.ItemDataRole.UserRole, - { - "display_path": display_file_path, - "processing_path": processing_file_path, - }, - ) - # Use custom widget for display - char_count = getattr(item, "total_char_count", 0) - widget = QueueListItemWidget(display_file_path, char_count) - self.listwidget.addItem(list_item) - self.listwidget.setItemWidget(list_item, widget) - self.update_button_states() - - def remove_item(self): - items = self.listwidget.selectedItems() - if not items: - return - from PyQt6.QtWidgets import QMessageBox - - # Remove by index to ensure correct mapping - rows = sorted([self.listwidget.row(item) for item in items], reverse=True) - # Warn user if removing multiple files - if len(rows) > 1: - reply = QMessageBox.question( - self, - "Confirm Remove", - f"Are you sure you want to remove {len(rows)} selected items from the queue?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if reply != QMessageBox.StandardButton.Yes: - return - for row in rows: - if 0 <= row < len(self.queue): - del self.queue[row] - self.process_queue() - self.update_button_states() - - def clear_queue(self): - from PyQt6.QtWidgets import QMessageBox - - if len(self.queue) > 1: - reply = QMessageBox.question( - self, - "Confirm Clear Queue", - f"Are you sure you want to clear {len(self.queue)} items from the queue?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if reply != QMessageBox.StandardButton.Yes: - return - self.queue.clear() - self.listwidget.clear() - self.empty_overlay.resize( - self.listwidget.size() - ) # Ensure overlay is sized correctly - self.empty_overlay.show() # Show the overlay when queue is empty - self.update_button_states() - - def get_queue(self): - return self.queue - - def get_current_attributes(self): - # Fetch current attribute values from the parent abogen GUI - attrs = {} - parent = self.parent - if parent is not None: - # lang_code: use parent's get_voice_formula and get_selected_lang - if hasattr(parent, "get_voice_formula") and hasattr( - parent, "get_selected_lang" - ): - voice_formula = parent.get_voice_formula() - attrs["lang_code"] = parent.get_selected_lang(voice_formula) - attrs["voice"] = voice_formula - else: - attrs["lang_code"] = getattr(parent, "selected_lang", "") - attrs["voice"] = getattr(parent, "selected_voice", "") - # speed - if hasattr(parent, "speed_slider"): - attrs["speed"] = parent.speed_slider.value() / 100.0 - else: - attrs["speed"] = getattr(parent, "speed", 1.0) - # save_option - attrs["save_option"] = getattr(parent, "save_option", "") - # output_folder - attrs["output_folder"] = getattr(parent, "selected_output_folder", "") - # subtitle_mode - if hasattr(parent, "get_actual_subtitle_mode"): - attrs["subtitle_mode"] = parent.get_actual_subtitle_mode() - else: - attrs["subtitle_mode"] = getattr(parent, "subtitle_mode", "") - # output_format - attrs["output_format"] = getattr(parent, "selected_format", "") - # total_char_count - attrs["total_char_count"] = getattr(parent, "char_count", "") - # replace_single_newlines - attrs["replace_single_newlines"] = getattr( - parent, "replace_single_newlines", True - ) - # use_silent_gaps - attrs["use_silent_gaps"] = getattr(parent, "use_silent_gaps", False) - # subtitle_speed_method - attrs["subtitle_speed_method"] = getattr( - parent, "subtitle_speed_method", "tts" - ) - # book handler options - attrs["save_chapters_separately"] = getattr( - parent, "save_chapters_separately", None - ) - attrs["merge_chapters_at_end"] = getattr( - parent, "merge_chapters_at_end", None - ) - else: - # fallback: empty values - attrs = { - k: "" - for k in [ - "lang_code", - "speed", - "voice", - "save_option", - "output_folder", - "subtitle_mode", - "output_format", - "total_char_count", - "replace_single_newlines", - ] - } - attrs["save_chapters_separately"] = None - attrs["merge_chapters_at_end"] = None - return attrs - - def add_files_from_paths(self, file_paths): - from abogen.subtitle_utils import calculate_text_length - from PyQt6.QtWidgets import QMessageBox - import os - - current_attrs = self.get_current_attributes() - duplicates = [] - for file_path in file_paths: - - class QueueItem: - pass - - item = QueueItem() - item.file_name = file_path - item.save_base_path = ( - file_path # For .txt files, processing and save paths are the same - ) - for attr, value in current_attrs.items(): - setattr(item, attr, value) - # Override subtitle_mode to "Disabled" for subtitle files - if file_path.lower().endswith((".srt", ".ass", ".vtt")): - item.subtitle_mode = "Disabled" - # Read file content and calculate total_char_count using calculate_text_length - try: - with open(file_path, "r", encoding="utf-8", errors="ignore") as f: - file_content = f.read() - item.total_char_count = calculate_text_length(file_content) - except Exception: - item.total_char_count = 0 - # Prevent adding duplicate items to the queue (check all attributes) - is_duplicate = False - for queued_item in self.queue: - if ( - getattr(queued_item, "file_name", None) - == getattr(item, "file_name", None) - and getattr(queued_item, "lang_code", None) - == getattr(item, "lang_code", None) - and getattr(queued_item, "speed", None) - == getattr(item, "speed", None) - and getattr(queued_item, "voice", None) - == getattr(item, "voice", None) - and getattr(queued_item, "save_option", None) - == getattr(item, "save_option", None) - and getattr(queued_item, "output_folder", None) - == getattr(item, "output_folder", None) - and getattr(queued_item, "subtitle_mode", None) - == getattr(item, "subtitle_mode", None) - and getattr(queued_item, "output_format", None) - == getattr(item, "output_format", None) - and getattr(queued_item, "total_char_count", None) - == getattr(item, "total_char_count", None) - and getattr(queued_item, "replace_single_newlines", True) - == getattr(item, "replace_single_newlines", True) - and getattr(queued_item, "use_silent_gaps", False) - == getattr(item, "use_silent_gaps", False) - and getattr(queued_item, "subtitle_speed_method", "tts") - == getattr(item, "subtitle_speed_method", "tts") - and getattr(queued_item, "save_base_path", None) - == getattr(item, "save_base_path", None) - and getattr(queued_item, "save_chapters_separately", None) - == getattr(item, "save_chapters_separately", None) - and getattr(queued_item, "merge_chapters_at_end", None) - == getattr(item, "merge_chapters_at_end", None) - ): - is_duplicate = True - break - if is_duplicate: - duplicates.append(os.path.basename(file_path)) - continue - self.queue.append(item) - if duplicates: - QMessageBox.warning( - self, - "Duplicate Item(s)", - f"Skipping {len(duplicates)} file(s) with the same attributes, already in the queue.", - ) - self.process_queue() - self.update_button_states() - - def add_more_files(self): - from PyQt6.QtWidgets import QFileDialog - - # Allow .txt, .srt, .ass, and .vtt files - files, _ = QFileDialog.getOpenFileNames( - self, - "Select text or subtitle files", - "", - "Supported Files (*.txt *.srt *.ass *.vtt)", - ) - if not files: - return - self.add_files_from_paths(files) - - def resizeEvent(self, event): - super().resizeEvent(event) - if hasattr(self, "empty_overlay"): - self.empty_overlay.resize(self.listwidget.size()) - - def update_button_states(self): - # Enable Remove if at least one item is selected, else disable - if hasattr(self, "remove_button"): - selected_count = len(self.listwidget.selectedItems()) - self.remove_button.setEnabled(selected_count > 0) - if selected_count > 1: - self.remove_button.setText(f"Remove selected ({selected_count})") - else: - self.remove_button.setText("Remove selected") - # Disable Clear if queue is empty - if hasattr(self, "clear_button"): - self.clear_button.setEnabled(bool(self.queue)) - - def show_context_menu(self, pos): - from PyQt6.QtWidgets import QMenu - from PyQt6.QtGui import QAction, QDesktopServices - from PyQt6.QtCore import QUrl - import os - - global_pos = self.listwidget.viewport().mapToGlobal(pos) - selected_items = self.listwidget.selectedItems() - menu = QMenu(self) - if len(selected_items) == 1: - # Add Remove action - remove_action = QAction("Remove this item", self) - remove_action.triggered.connect(self.remove_item) - menu.addAction(remove_action) - - # Get paths for determining if it's a document input - item = selected_items[0] - paths = item.data(Qt.ItemDataRole.UserRole) - if isinstance(paths, dict): - display_path = paths.get("display_path", "") - processing_path = paths.get("processing_path", "") - else: - display_path = paths - processing_path = paths - - doc_exts = (".md", ".markdown", ".pdf", ".epub") - is_document_input = ( - isinstance(display_path, str) - and display_path.lower().endswith(doc_exts) - ) or ( - isinstance(processing_path, str) - and processing_path.lower().endswith(doc_exts) - ) - - # Add Open file action(s) - def open_file_by_path(path_label: str): - from PyQt6.QtWidgets import QMessageBox - - p = display_path if path_label == "display" else processing_path - if not p: - QMessageBox.warning( - self, "File Not Found", "Path is not available." - ) - return - - # Find the queue item and resolve the target path - target_path = None - for q in self.queue: - if ( - getattr(q, "save_base_path", None) == display_path - or q.file_name == display_path - ): - if path_label == "display": - target_path = ( - getattr(q, "save_base_path", None) or q.file_name - ) - else: - target_path = q.file_name - break - if ( - getattr(q, "save_base_path", None) == processing_path - or q.file_name == processing_path - ): - if path_label == "display": - target_path = ( - getattr(q, "save_base_path", None) or q.file_name - ) - else: - target_path = q.file_name - break - - # Fallback to the raw path if resolution failed - if not target_path: - target_path = p - - if not os.path.exists(target_path): - QMessageBox.warning( - self, "File Not Found", f"The file does not exist." - ) - return - QDesktopServices.openUrl(QUrl.fromLocalFile(target_path)) - - if is_document_input: - # For documents, show two open options - open_processed_action = QAction("Open processed file", self) - open_processed_action.triggered.connect( - lambda: open_file_by_path("processing") - ) - menu.addAction(open_processed_action) - - open_input_action = QAction("Open input file", self) - open_input_action.triggered.connect( - lambda: open_file_by_path("display") - ) - menu.addAction(open_input_action) - else: - # For plain text files, show single open option - open_file_action = QAction("Open file", self) - open_file_action.triggered.connect(lambda: open_file_by_path("display")) - menu.addAction(open_file_action) - - # Add Go to folder action - # If the queued item represents a converted document (markdown, pdf, epub) - # show two actions: Go to processed file (the cached .txt) and Go to input file (original source) - - from PyQt6.QtWidgets import QMessageBox - - def open_folder_for(path_label: str): - # path_label should be either 'display' or 'processing' - p = display_path if path_label == "display" else processing_path - if not p: - QMessageBox.warning( - self, "File Not Found", "Path is not available." - ) - return - # If the stored path is the display path (original) but the actual file may be - # stored on the queue object differently, try to resolve via the queue entry. - target_path = None - for q in self.queue: - if ( - getattr(q, "save_base_path", None) == display_path - or q.file_name == display_path - ): - if path_label == "display": - target_path = ( - getattr(q, "save_base_path", None) or q.file_name - ) - else: - target_path = q.file_name - break - if ( - getattr(q, "save_base_path", None) == processing_path - or q.file_name == processing_path - ): - if path_label == "display": - target_path = ( - getattr(q, "save_base_path", None) or q.file_name - ) - else: - target_path = q.file_name - break - # Fallback to the raw path if resolution failed - if not target_path: - target_path = p - - if not os.path.exists(target_path): - QMessageBox.warning( - self, - "File Not Found", - f"The file does not exist: {target_path}", - ) - return - folder = os.path.dirname(target_path) - if os.path.exists(folder): - QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) - - if is_document_input: - processed_action = QAction("Go to processed file", self) - processed_action.triggered.connect( - lambda: open_folder_for("processing") - ) - menu.addAction(processed_action) - - input_action = QAction("Go to input file", self) - input_action.triggered.connect(lambda: open_folder_for("display")) - menu.addAction(input_action) - else: - # Default behavior for non-document inputs: single "Go to folder" action - go_to_folder_action = QAction("Go to folder", self) - - def go_to_folder(): - item = selected_items[0] - paths = item.data(Qt.ItemDataRole.UserRole) - if isinstance(paths, dict): - file_path = paths.get( - "display_path", paths.get("processing_path", "") - ) - else: - file_path = paths # Fallback for old format - # Find the queue item - for q in self.queue: - if ( - getattr(q, "save_base_path", None) == file_path - or q.file_name == file_path - ): - target_path = ( - getattr(q, "save_base_path", None) or q.file_name - ) - if not os.path.exists(target_path): - QMessageBox.warning( - self, "File Not Found", f"The file does not exist." - ) - return - folder = os.path.dirname(target_path) - if os.path.exists(folder): - QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) - break - - go_to_folder_action.triggered.connect(go_to_folder) - menu.addAction(go_to_folder_action) - - elif len(selected_items) > 1: - remove_action = QAction(f"Remove selected ({len(selected_items)})", self) - remove_action.triggered.connect(self.remove_item) - menu.addAction(remove_action) - # Always add Clear Queue - clear_action = QAction("Clear Queue", self) - clear_action.triggered.connect(self.clear_queue) - menu.addAction(clear_action) - menu.exec(global_pos) - - def accept(self): - # Save the override state to config so it persists globally - self.config["queue_override_settings"] = self.override_chk.isChecked() - save_config(self.config) - - super().accept() - - def reject(self): - # Cancel: restore original queue - from PyQt6.QtWidgets import QMessageBox - - # Warn if user changed a lot (e.g., more than 1 items difference) - original_count = len(self._original_queue) - current_count = len(self.queue) - if abs(original_count - current_count) > 1: - reply = QMessageBox.question( - self, - "Confirm Cancel", - f"Are you sure you want to cancel and discard all changes?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if reply != QMessageBox.StandardButton.Yes: - return - self.queue.clear() - self.queue.extend(deepcopy(self._original_queue)) - super().reject() - - def keyPressEvent(self, event): - from PyQt6.QtCore import Qt - - if event.key() == Qt.Key.Key_Delete: - self.remove_item() - else: - super().keyPressEvent(event) +__all__ = ["QueueManager"] diff --git a/abogen/spacy_contraction_resolver.py b/abogen/spacy_contraction_resolver.py new file mode 100644 index 0000000..d85ca7e --- /dev/null +++ b/abogen/spacy_contraction_resolver.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import os +import logging +from dataclasses import dataclass +from functools import lru_cache +from typing import Any, Dict, Optional, Tuple + +try: # pragma: no cover - optional dependency + import spacy +except Exception: # pragma: no cover - spaCy unavailable at runtime + spacy = None + +# Lazy spaCy type hints to avoid a hard dependency at import time. +Language = Any # type: ignore[assignment] +Token = Any # type: ignore[assignment] + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ContractionResolution: + start: int + end: int + surface: str + expansion: str + category: str + lemma: str + + @property + def span(self) -> Tuple[int, int]: + return self.start, self.end + + +_DEFAULT_MODEL = os.environ.get("ABOGEN_SPACY_MODEL", "en_core_web_sm") + + +@lru_cache(maxsize=1) +def _load_spacy_model(model: str = _DEFAULT_MODEL) -> Optional[Language]: + if spacy is None: + logger.debug("spaCy is not installed; skipping contraction disambiguation") + return None + + try: + nlp = spacy.load(model) + except Exception as exc: # pragma: no cover - depends on environment + logger.warning("Failed to load spaCy model '%s': %s", model, exc) + return None + return nlp + + +def resolve_ambiguous_contractions(text: str, *, model: Optional[str] = None) -> Dict[Tuple[int, int], ContractionResolution]: + """Use spaCy to disambiguate ambiguous contractions in *text*. + + Returns a mapping from (start, end) spans to their resolved expansion. + Only ambiguous `'s` and `'d` contractions are considered. + """ + if not text: + return {} + + nlp = _load_spacy_model(model or _DEFAULT_MODEL) + if nlp is None: + return {} + + doc = nlp(text) + resolutions: Dict[Tuple[int, int], ContractionResolution] = {} + for token in doc: + if token.text == "'s": + resolution = _resolve_apostrophe_s(token) + elif token.text == "'d": + resolution = _resolve_apostrophe_d(token) + else: + resolution = None + + if resolution is None: + continue + + if resolution.span not in resolutions: + resolutions[resolution.span] = resolution + return resolutions + + +def _resolution(prev: Token, token: Token, expansion_word: str, category: str, lemma_hint: str) -> Optional[ContractionResolution]: + if token is None or prev is None: + return None + + if prev.idx + len(prev.text) != token.idx: + # Not a contiguous contraction (whitespace or punctuation in between) + return None + + surface_start = prev.idx + surface_end = token.idx + len(token.text) + surface_text = token.doc.text[surface_start:surface_end] + + expansion = _assemble_expansion(prev.text, surface_text, expansion_word) + return ContractionResolution( + start=surface_start, + end=surface_end, + surface=surface_text, + expansion=expansion, + category=category, + lemma=lemma_hint, + ) + + +def _assemble_expansion(base_text: str, surface_text: str, expansion_word: str) -> str: + """Combine *base_text* with *expansion_word*, preserving coarse casing.""" + if not expansion_word: + return base_text + + if surface_text.isupper() and expansion_word.isalpha(): + adjusted = expansion_word.upper() + elif len(surface_text) > 2 and surface_text[:-2].istitle() and expansion_word: + # Surface like "It's" -> keep appended word lowercase + adjusted = expansion_word.lower() + else: + adjusted = expansion_word + + return f"{base_text} {adjusted}".strip() + + +def _resolve_apostrophe_s(token: Token) -> Optional[ContractionResolution]: + prev = token.nbor(-1) if token.i > 0 else None + if prev is None: + return None + + # Possessive marker e.g., dog's + if token.tag_ == "POS" or token.lemma_ == "'s": + return None + + prev_lower = prev.lemma_.lower() + surface = token.doc.text[prev.idx : token.idx + len(token.text)] + + if prev_lower == "let": + return _resolution(prev, token, "us", "contraction_let_us", "us") + + # Special check for 's been -> has been, overriding lemma + next_content = _next_content_token(token) + if next_content and next_content.text.lower() == "been": + return _resolution(prev, token, "has", "contraction_aux_have", "have") + + lemma = token.lemma_.lower() + if not lemma: + lemma = "be" if _favors_be(token) else "have" if _favors_have(token) else "be" + + if lemma == "be": + return _resolution(prev, token, "is", "contraction_aux_be", "be") + if lemma == "have": + return _resolution(prev, token, "has", "contraction_aux_have", "have") + + if _favors_have(token): + return _resolution(prev, token, "has", "contraction_aux_have", "have") + + if _favors_be(token): + return _resolution(prev, token, "is", "contraction_aux_be", "be") + + # Default to copula expansion. + return _resolution(prev, token, "is", "contraction_aux_be", lemma or "be") + + +def _resolve_apostrophe_d(token: Token) -> Optional[ContractionResolution]: + prev = token.nbor(-1) if token.i > 0 else None + if prev is None: + return None + + if token.morph.get("VerbForm") == ["Part"]: + # spaCy sometimes tags possessives oddly; guard anyway + return None + + lemma = token.lemma_.lower() + tense = set(token.morph.get("Tense")) + next_content = _next_content_token(token) + prefers_had = _context_prefers_had(token) + + if prefers_had: + return _resolution(prev, token, "had", "contraction_aux_have", "have") + + if "Past" in tense and lemma in {"have", "had"}: + return _resolution(prev, token, "had", "contraction_aux_have", "have") + + if next_content is not None: + next_tag = next_content.tag_ + next_lemma = next_content.lemma_.lower() + else: + next_tag = "" + next_lemma = "" + + if next_tag == "VB": + return _resolution(prev, token, "would", "contraction_modal_would", lemma or "will") + + if token.tag_ == "MD" or lemma in {"will", "would", "shall"}: + return _resolution(prev, token, "would", "contraction_modal_would", lemma or "will") + + if next_lemma in {"been", "gone", "had", "better"} or next_tag in {"VBN", "VBD"}: + return _resolution(prev, token, "had", "contraction_aux_have", "have") + + if lemma in {"have", "had"}: + return _resolution(prev, token, "had", "contraction_aux_have", lemma) + + return _resolution(prev, token, "would", "contraction_modal_would", lemma or "will") + + +def _next_content_token(token: Token) -> Optional[Token]: + doc = token.doc + for candidate in doc[token.i + 1 :]: + if candidate.is_space: + continue + if candidate.is_punct and candidate.text not in {"-"}: + break + if candidate.text in {"'", ""}: + continue + return candidate + return None + + +def _favors_have(token: Token) -> bool: + next_content = _next_content_token(token) + if next_content is None: + return False + if next_content.tag_ in {"VBN"}: + return True + if next_content.lemma_.lower() in {"been", "gone", "had"}: + return True + return False + + +def _favors_be(token: Token) -> bool: + next_content = _next_content_token(token) + if next_content is None: + return True + if next_content.tag_ in {"VBG", "JJ", "RB", "DT", "IN"}: + return True + return False + + +def _context_prefers_had(token: Token) -> bool: + head = token.head if token.head is not None else None + if head is not None and head.i > token.i: + head_tag = head.tag_ + head_lemma = head.lemma_.lower() + if head_tag in {"VBN", "VBD"} or head_lemma in {"gone", "been", "had"}: + return True + if head_lemma == "better": + return True + + next_content = _next_content_token(token) + if next_content is None: + return False + next_tag = next_content.tag_ + next_lemma = next_content.lemma_.lower() + if next_tag in {"VBN", "VBD"}: + return True + if next_lemma in {"been", "gone", "had"}: + return True + if next_lemma == "better": + return True + return False + diff --git a/abogen/speaker_analysis.py b/abogen/speaker_analysis.py new file mode 100644 index 0000000..40a7d66 --- /dev/null +++ b/abogen/speaker_analysis.py @@ -0,0 +1,715 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +import unicodedata + +_DIALOGUE_VERBS = ( + "said", + "asked", + "replied", + "whispered", + "shouted", + "cried", + "muttered", + "answered", + "hissed", + "called", + "added", + "continued", + "insisted", + "remarked", + "yelled", + "breathed", + "murmured", + "exclaimed", + "explained", + "noted", +) + +_VERB_PATTERN = "(?:" + "|".join(_DIALOGUE_VERBS) + ")" +_NAME_FRAGMENT = r"[A-ZÀ-ÖØ-Þ][\w'’\-]*" +_NAME_PATTERN = rf"{_NAME_FRAGMENT}(?:\s+{_NAME_FRAGMENT})*" + +_COLON_PATTERN = re.compile(rf"^\s*({_NAME_PATTERN})\s*:\s*(.+)$") +_NAME_BEFORE_VERB = re.compile(rf"({_NAME_PATTERN})\s+{_VERB_PATTERN}\b", re.IGNORECASE) +_VERB_BEFORE_NAME = re.compile(rf"{_VERB_PATTERN}\s+({_NAME_PATTERN})", re.IGNORECASE) +_PRONOUN_PATTERN = re.compile(r"\b(?:he|she|they)\b", re.IGNORECASE) +_QUOTE_PATTERN = re.compile(r'["“”]([^"“”\\]*(?:\\.[^"“”\\]*)*)["”]') +_MALE_PRONOUN_PATTERN = re.compile(r"\b(?:he|him|his|himself)\b", re.IGNORECASE) +_FEMALE_PRONOUN_PATTERN = re.compile(r"\b(?:she|her|hers|herself)\b", re.IGNORECASE) +_PRONOUN_LABELS = { + "he", + "she", + "they", + "them", + "theirs", + "their", + "themselves", + "him", + "his", + "himself", + "her", + "hers", + "herself", + "we", + "us", + "our", + "ours", + "ourselves", + "i", + "me", + "my", + "mine", + "myself", + "you", + "your", + "yours", + "yourself", + "yourselves", +} + +_CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3} + +_FEMALE_TITLE_HINTS = ( + "madame", + "mme", + "madam", + "mrs", + "miss", + "ms", + "lady", + "countess", + "baroness", + "princess", + "queen", + "mademoiselle", +) + +_MALE_TITLE_HINTS = ( + "monsieur", + "m.", + "mr", + "sir", + "lord", + "count", + "baron", + "prince", + "king", + "abbé", + "abbe", +) + +_MALE_TOKEN_WEIGHTS = { + "he": 1.0, + "him": 0.6, + "his": 0.75, + "himself": 1.0, +} + +_FEMALE_TOKEN_WEIGHTS = { + "she": 1.0, + "her": 0.4, + "hers": 0.75, + "herself": 1.0, +} + +_STOP_LABELS = { + "and", + "but", + "then", + "though", + "meanwhile", + "therefore", + "after", + "before", + "when", + "while", + "because", + "as", + "yet", + "nor", + "so", + "thus", + "suddenly", + "eventually", + "finally", + "until", + "unless", +} + + +@dataclass(slots=True) +class SpeakerGuess: + speaker_id: str + label: str + count: int = 0 + confidence: str = "low" + sample_quotes: List[Dict[str, str]] = field(default_factory=list) + suppressed: bool = False + gender: str = "unknown" + detected_gender: str = "unknown" + male_votes: int = 0 + female_votes: int = 0 + + def register_occurrence( + self, + confidence: str, + text: str, + quote: Optional[str], + male_votes: int, + female_votes: int, + sample_excerpt: Optional[str] = None, + ) -> None: + self.count += 1 + if _CONFIDENCE_RANK.get(confidence, 0) > _CONFIDENCE_RANK.get(self.confidence, 0): + self.confidence = confidence + + excerpt = sample_excerpt if sample_excerpt is not None else _build_excerpt(text, quote) + gender_hint = _format_gender_hint(male_votes, female_votes) + if excerpt: + payload = {"excerpt": excerpt, "gender_hint": gender_hint} + if payload not in self.sample_quotes: + self.sample_quotes.append(payload) + if len(self.sample_quotes) > 3: + self.sample_quotes = self.sample_quotes[:3] + + if male_votes: + self.male_votes += male_votes + if female_votes: + self.female_votes += female_votes + self.detected_gender = _derive_gender(self.male_votes, self.female_votes, self.detected_gender) + if self.gender in {"unknown", "male", "female"}: + self.gender = _derive_gender(self.male_votes, self.female_votes, self.gender) + + def as_dict(self) -> Dict[str, Any]: + return { + "id": self.speaker_id, + "label": self.label, + "count": self.count, + "confidence": self.confidence, + "sample_quotes": [dict(sample) for sample in self.sample_quotes], + "suppressed": self.suppressed, + "gender": self.gender, + "detected_gender": self.detected_gender, + } + + +@dataclass(slots=True) +class SpeakerAnalysis: + assignments: Dict[str, str] + speakers: Dict[str, SpeakerGuess] + suppressed: List[str] + narrator: str = "narrator" + version: str = "1.0" + stats: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return { + "version": self.version, + "narrator": self.narrator, + "assignments": dict(self.assignments), + "speakers": {speaker_id: guess.as_dict() for speaker_id, guess in self.speakers.items()}, + "suppressed": list(self.suppressed), + "stats": dict(self.stats), + } + + +def analyze_speakers( + chapters: Sequence[Dict[str, Any]] | Iterable[Dict[str, Any]], + chunks: Sequence[Dict[str, Any]] | Iterable[Dict[str, Any]], + *, + threshold: int = 3, + max_speakers: int = 8, +) -> SpeakerAnalysis: + narrator_id = "narrator" + speaker_guesses: Dict[str, SpeakerGuess] = { + narrator_id: SpeakerGuess(speaker_id=narrator_id, label="Narrator", confidence="low") + } + label_index: Dict[str, str] = {"Narrator": narrator_id} + assignments: Dict[str, str] = {} + suppressed: List[str] = [] + + ordered_chunks = sorted( + (dict(chunk) for chunk in chunks), + key=lambda entry: ( + _safe_int(entry.get("chapter_index")), + _safe_int(entry.get("chunk_index")), + ), + ) + last_explicit: Optional[str] = None + explicit_assignments = 0 + unique_speakers: set[str] = set() + + for index, chunk in enumerate(ordered_chunks): + chunk_id = str(chunk.get("id") or "") + text = _get_chunk_text(chunk) + speaker_id, confidence, quote = _infer_chunk_speaker(text, last_explicit) + if speaker_id is None: + speaker_id = last_explicit or narrator_id + confidence = "medium" if last_explicit else "low" + quote = quote or _extract_quote(text) + if speaker_id != narrator_id: + last_explicit = speaker_id + explicit_assignments += 1 + + if speaker_id in speaker_guesses: + record_id = speaker_id + guess = speaker_guesses[record_id] + label = guess.label + else: + label = _normalize_label(speaker_id) + record_id = label_index.get(label) + if record_id is None: + record_id = _dedupe_slug(_slugify(label), speaker_guesses) + label_index[label] = record_id + speaker_guesses[record_id] = SpeakerGuess(speaker_id=record_id, label=label) + guess = speaker_guesses[record_id] + assignments[chunk_id] = record_id + unique_speakers.add(record_id) + + if record_id != narrator_id and record_id != speaker_id and speaker_id == last_explicit: + last_explicit = record_id + + sample_excerpt = None + if record_id != narrator_id: + sample_excerpt = _select_sample_excerpt(ordered_chunks, index, guess.label, quote, confidence) + + male_votes, female_votes = _count_gender_votes(text, guess.label) + + guess.register_occurrence(confidence, text, quote, male_votes, female_votes, sample_excerpt) + + active_speakers = [sid for sid in speaker_guesses if sid != narrator_id] + # Apply minimum occurrence threshold. + for speaker_id in list(active_speakers): + guess = speaker_guesses[speaker_id] + if guess.count < max(1, threshold): + guess.suppressed = True + suppressed.append(speaker_id) + _reassign(assignments, speaker_id, narrator_id) + active_speakers.remove(speaker_id) + + # Apply maximum active speaker cap. + if max_speakers and len(active_speakers) > max_speakers: + active_speakers.sort(key=lambda sid: (-speaker_guesses[sid].count, sid)) + for speaker_id in active_speakers[max_speakers:]: + guess = speaker_guesses[speaker_id] + guess.suppressed = True + suppressed.append(speaker_id) + _reassign(assignments, speaker_id, narrator_id) + active_speakers = active_speakers[:max_speakers] + + narrator_guess = speaker_guesses[narrator_id] + narrator_guess.count = sum(1 for value in assignments.values() if value == narrator_id) + narrator_guess.confidence = "low" + + stats = { + "total_chunks": len(ordered_chunks), + "explicit_chunks": explicit_assignments, + "active_speakers": len(active_speakers), + "unique_speakers": len(unique_speakers), + "suppressed": len(suppressed), + } + + return SpeakerAnalysis( + assignments=assignments, + speakers=speaker_guesses, + suppressed=suppressed, + narrator=narrator_id, + stats=stats, + ) + + +def _infer_chunk_speaker(text: str, last_explicit: Optional[str]) -> Tuple[Optional[str], str, Optional[str]]: + normalized = text.strip() + if not normalized: + return None, "low", None + + colon_match = _COLON_PATTERN.match(normalized) + if colon_match: + raw_label = colon_match.group(1) + cleaned = _normalize_candidate_name(raw_label) + if cleaned is None: + return None, "low", colon_match.group(2).strip() + quote = colon_match.group(2).strip() + return cleaned, "high", quote + + quote = _extract_quote(normalized) + if not quote: + return None, "low", None + + before, after = _split_around_quote(normalized, quote) + + candidate = _match_name_near_quote(before, after) + if candidate: + cleaned = _normalize_candidate_name(candidate) + if cleaned: + return cleaned, "high", quote + + if last_explicit: + pronoun_after = _PRONOUN_PATTERN.search(after) + pronoun_before = _PRONOUN_PATTERN.search(before) + if pronoun_after or pronoun_before: + return last_explicit, "medium", quote + + return None, "low", quote + + +def _split_around_quote(text: str, quote: str) -> Tuple[str, str]: + quote_index = text.find(quote) + if quote_index == -1: + return text, "" + before = text[:quote_index] + after = text[quote_index + len(quote) :] + return before, after + + +def _match_name_near_quote(before: str, after: str) -> Optional[str]: + trailing = before[-120:] + leading = after[:120] + + match = _NAME_BEFORE_VERB.search(trailing) + if match: + name = match.group(1) + if _looks_like_name(name): + return name + + match = re.search(rf"({_NAME_PATTERN})\s*,?\s*{_VERB_PATTERN}", leading, flags=re.IGNORECASE) + if match: + name = match.group(1) + if _looks_like_name(name): + return name + + match = _VERB_BEFORE_NAME.search(leading) + if match: + name = match.group(1) + if _looks_like_name(name): + return name + + return None + + +def _looks_like_name(value: str) -> bool: + normalized = _normalize_candidate_name(value) + if not normalized: + return False + parts = normalized.split() + if not parts: + return False + return all(part and part[0].isupper() for part in parts) + + +def _extract_quote(text: str) -> Optional[str]: + match = _QUOTE_PATTERN.search(text) + if not match: + return None + return match.group(0) + + +def _slugify(label: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "_", label.lower()).strip("_") + return slug or "speaker" + + +def _dedupe_slug(slug: str, existing: Dict[str, SpeakerGuess]) -> str: + candidate = slug + index = 2 + while candidate in existing: + candidate = f"{slug}_{index}" + index += 1 + return candidate + + +def _normalize_label(label: str) -> str: + words = re.split(r"\s+", label.strip()) + return " ".join(word.capitalize() for word in words if word) + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _reassign(assignments: Dict[str, str], old: str, new: str) -> None: + for key, value in list(assignments.items()): + if value == old: + assignments[key] = new + + +def _strip_diacritics(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value) + return "".join(char for char in normalized if not unicodedata.combining(char)) + + +def _count_gender_votes(text: str, label: Optional[str]) -> Tuple[int, int]: + if not text: + return 0, 0 + + search_text = text + windows: List[Tuple[int, int]] = [] + degrade_factor = 1.0 + + if label: + pattern = re.compile(re.escape(label), re.IGNORECASE) + matches = list(pattern.finditer(search_text)) + if not matches: + alt_label = _strip_diacritics(label) + if alt_label and alt_label != label: + ascii_text = _strip_diacritics(search_text) + pattern_alt = re.compile(re.escape(alt_label), re.IGNORECASE) + windows = [match.span() for match in pattern_alt.finditer(ascii_text)] + # Map spans back roughly using proportional index + if windows: + mapped: List[Tuple[int, int]] = [] + for start, end in windows: + start_idx = min(len(search_text) - 1, int(start * len(search_text) / max(len(ascii_text), 1))) + end_idx = min(len(search_text), int(end * len(search_text) / max(len(ascii_text), 1))) + mapped.append((start_idx, end_idx)) + windows = mapped + else: + windows = [match.span() for match in matches] + + if not windows: + windows = [(0, len(search_text))] + degrade_factor = 0.25 + + radius = 60 + quote_spans: List[Tuple[int, int, str]] = [] + for match in _QUOTE_PATTERN.finditer(search_text): + try: + content_start, content_end = match.span(1) + except IndexError: + content_start, content_end = match.span() + if content_start < content_end: + quote_spans.append((content_start, content_end, search_text[content_start:content_end])) + + normalized_label = _normalize_candidate_name(label) if label else None + normalized_label_lower = normalized_label.lower() if normalized_label else None + + def _window_weight(position: int) -> float: + for start, end in windows: + if position < start - radius or position > end + radius: + continue + if position >= end: + return 1.0 + if position <= start: + return 0.2 + return 1.0 + return 0.0 + + def _quote_weight(position: int) -> float: + for start, end, content in quote_spans: + if position < start or position >= end: + continue + local_index = position - start + prefix = content[:local_index] + tail = prefix[-80:] + name_matches = list(re.finditer(_NAME_PATTERN, tail)) + if name_matches: + last_name = _normalize_candidate_name(name_matches[-1].group(0)) + if normalized_label_lower and last_name and last_name.lower() == normalized_label_lower: + return 0.6 + return 0.05 + if re.search(r"[.!?]\s", prefix): + return 0.2 + if prefix.strip(): + return 0.15 + return 0.1 + return 1.0 + + male_score = 0.0 + for match in _MALE_PRONOUN_PATTERN.finditer(search_text): + base_weight = _window_weight(match.start()) + if not base_weight: + continue + quote_modifier = _quote_weight(match.start()) + weight = base_weight * quote_modifier + if not weight: + continue + token = match.group(0).lower() + male_score += _MALE_TOKEN_WEIGHTS.get(token, 0.6) * weight + + female_score = 0.0 + for match in _FEMALE_PRONOUN_PATTERN.finditer(search_text): + base_weight = _window_weight(match.start()) + if not base_weight: + continue + quote_modifier = _quote_weight(match.start()) + weight = base_weight * quote_modifier + if not weight: + continue + if quote_modifier >= 0.95: + weight = max(weight, 0.4) + token = match.group(0).lower() + female_score += _FEMALE_TOKEN_WEIGHTS.get(token, 0.4) * weight + + for start, end in windows: + span_start = max(0, start - 40) + span_end = min(len(search_text), end + 40) + span_text = search_text[span_start:span_end].lower() + if any(title in span_text for title in _FEMALE_TITLE_HINTS): + female_score += 2.5 + if any(title in span_text for title in _MALE_TITLE_HINTS): + male_score += 2.5 + + male_votes = int(round(male_score * degrade_factor)) + female_votes = int(round(female_score * degrade_factor)) + return male_votes, female_votes + + +def _derive_gender(male_votes: int, female_votes: int, current: str) -> str: + if male_votes == 0 and female_votes == 0: + return current if current != "unknown" else "unknown" + + male_threshold = max(2, female_votes + 1) + female_threshold = max(2, male_votes + 1) + + if male_votes >= male_threshold: + return "male" + if female_votes >= female_threshold: + return "female" + + if current in {"male", "female"}: + return current + return "unknown" + + +def _get_chunk_text(chunk: Dict[str, Any]) -> str: + if not isinstance(chunk, dict): + return "" + value = chunk.get("normalized_text") or chunk.get("text") or "" + return str(value) + + +def _trim_paragraph(paragraph: str, limit: int = 600) -> str: + normalized = (paragraph or "").strip() + if not normalized: + return "" + if len(normalized) <= limit: + return normalized + return normalized[: limit - 1].rstrip() + "…" + + +def _compose_context_excerpt(before: str, current: str, after: str) -> str: + segments = [] + for value in (before, current, after): + trimmed = _trim_paragraph(value) + if trimmed: + segments.append(trimmed) + return "\n\n".join(segments) + + +def _contains_dialogue_attribution(label: str, text: str, quote: Optional[str]) -> bool: + if not label or not text: + return False + escaped_label = re.escape(label) + direct_pattern = re.compile(rf"\b{escaped_label}\b\s+(?:{_VERB_PATTERN})\b", re.IGNORECASE) + reverse_pattern = re.compile(rf"(?:{_VERB_PATTERN})\s+\b{escaped_label}\b", re.IGNORECASE) + colon_pattern = re.compile(rf"^\s*{escaped_label}\s*:\s*", re.IGNORECASE) + + if colon_pattern.search(text): + return True + if direct_pattern.search(text) or reverse_pattern.search(text): + return True + if quote: + before, after = _split_around_quote(text, quote) + if direct_pattern.search(before) or reverse_pattern.search(after): + return True + return False + + +def _select_sample_excerpt( + chunks: Sequence[Dict[str, Any]], + index: int, + label: str, + quote: Optional[str], + confidence: str, +) -> Optional[str]: + if confidence != "high" or not label: + return None + if index < 0 or index >= len(chunks): + return None + current = _get_chunk_text(chunks[index]) + if not current or not _contains_dialogue_attribution(label, current, quote): + return None + previous = _get_chunk_text(chunks[index - 1]) if index > 0 else "" + following = _get_chunk_text(chunks[index + 1]) if index + 1 < len(chunks) else "" + excerpt = _compose_context_excerpt(previous, current, following) + return excerpt or None + + +def _build_excerpt(text: str, quote: Optional[str]) -> str: + normalized = (text or "").strip() + if not normalized: + return "" + if quote: + location = normalized.find(quote) + if location != -1: + start = max(0, location - 120) + end = min(len(normalized), location + len(quote) + 120) + snippet = normalized[start:end].strip() + if start > 0: + snippet = "…" + snippet + if end < len(normalized): + snippet = snippet + "…" + return snippet + if len(normalized) > 240: + return normalized[:240].rstrip() + "…" + return normalized + + +def _format_gender_hint(male_votes: int, female_votes: int) -> str: + if male_votes and female_votes: + return "Context mentions both male and female pronouns." + if male_votes: + if male_votes >= 3: + return "Multiple male pronouns detected nearby." + return "Some male pronouns detected in the surrounding text." + if female_votes: + if female_votes >= 3: + return "Multiple female pronouns detected nearby." + return "Some female pronouns detected in the surrounding text." + return "No clear pronoun signal detected." + + +def _normalize_candidate_name(raw: str) -> Optional[str]: + if not raw: + return None + cleaned = raw.strip().strip('"“”\'’.,:;!') + cleaned = re.sub(r"\s+", " ", cleaned).strip() + if not cleaned: + return None + parts = cleaned.split() + filtered: List[str] = [] + for part in parts: + if not part: + continue + if not filtered and part.lower() in _STOP_LABELS: + continue + filtered.append(part) + while filtered and filtered[-1].lower() in _STOP_LABELS: + filtered.pop() + if not filtered: + return None + if all(part.lower() in _STOP_LABELS for part in filtered): + return None + contiguous: List[str] = [] + for part in filtered: + if part and part[0].isupper(): + contiguous.append(part) + else: + break + if contiguous: + candidate = " ".join(contiguous) + else: + candidate = "" + if not candidate: + return None + lowered = candidate.lower() + if lowered in _PRONOUN_LABELS or lowered in _STOP_LABELS: + return None + return candidate \ No newline at end of file diff --git a/abogen/speaker_configs.py b/abogen/speaker_configs.py new file mode 100644 index 0000000..283389e --- /dev/null +++ b/abogen/speaker_configs.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import json +import os +from typing import Any, Dict, List, Optional + +from abogen.constants import LANGUAGE_DESCRIPTIONS +from abogen.utils import get_user_config_path + +_CONFIG_WRAPPER_KEY = "abogen_speaker_configs" + + +def _config_path() -> str: + config_path = get_user_config_path() + config_dir = os.path.dirname(config_path) + os.makedirs(config_dir, exist_ok=True) + return os.path.join(config_dir, "speaker_configs.json") + + +def load_configs() -> Dict[str, Dict[str, Any]]: + path = _config_path() + if not os.path.exists(path): + return {} + try: + with open(path, "r", encoding="utf-8") as handle: + payload = json.load(handle) + except Exception: + return {} + if isinstance(payload, dict) and _CONFIG_WRAPPER_KEY in payload: + payload = payload[_CONFIG_WRAPPER_KEY] + if not isinstance(payload, dict): + return {} + sanitized: Dict[str, Dict[str, Any]] = {} + for name, entry in payload.items(): + if not isinstance(name, str) or not isinstance(entry, dict): + continue + sanitized[name] = _sanitize_config(entry) + return sanitized + + +def save_configs(configs: Dict[str, Dict[str, Any]]) -> None: + path = _config_path() + sanitized: Dict[str, Dict[str, Any]] = {} + for name, entry in configs.items(): + if not isinstance(name, str) or not name.strip(): + continue + sanitized[name] = _sanitize_config(entry) + with open(path, "w", encoding="utf-8") as handle: + json.dump({_CONFIG_WRAPPER_KEY: sanitized}, handle, indent=2, sort_keys=True) + + +def get_config(name: str) -> Optional[Dict[str, Any]]: + name = (name or "").strip() + if not name: + return None + configs = load_configs() + data = configs.get(name) + return dict(data) if isinstance(data, dict) else None + + +def upsert_config(name: str, payload: Dict[str, Any]) -> Dict[str, Any]: + name = (name or "").strip() + if not name: + raise ValueError("Configuration name is required") + configs = load_configs() + configs[name] = _sanitize_config(payload or {}) + save_configs(configs) + return configs[name] + + +def delete_config(name: str) -> None: + name = (name or "").strip() + if not name: + return + configs = load_configs() + if name in configs: + del configs[name] + save_configs(configs) + + +def _sanitize_config(entry: Dict[str, Any]) -> Dict[str, Any]: + language = str(entry.get("language") or "a").strip() or "a" + speakers_raw = entry.get("speakers") + if not isinstance(speakers_raw, dict): + speakers_raw = {} + speakers: Dict[str, Any] = {} + for speaker_id, payload in speakers_raw.items(): + if not isinstance(speaker_id, str) or not isinstance(payload, dict): + continue + record = _sanitize_speaker({"id": speaker_id, **payload}) + speakers[record["id"]] = record + allowed_languages = entry.get("languages") or entry.get("allowed_languages") or [] + if not isinstance(allowed_languages, list): + allowed_languages = [] + normalized_langs = [] + for code in allowed_languages: + if isinstance(code, str) and code: + normalized_langs.append(code.lower()) + default_voice = entry.get("default_voice") + if not isinstance(default_voice, str): + default_voice = "" + return { + "language": language.lower(), + "languages": normalized_langs, + "default_voice": default_voice, + "speakers": speakers, + "version": int(entry.get("version", 1)), + "notes": entry.get("notes") if isinstance(entry.get("notes"), str) else "", + } + + +def slugify_label(label: str) -> str: + normalized = (label or "").strip().lower() + if not normalized: + return "speaker" + slug = "".join(ch if ch.isalnum() else "_" for ch in normalized) + slug = "_".join(filter(None, slug.split("_"))) + return slug or "speaker" + + +def _sanitize_speaker(entry: Dict[str, Any]) -> Dict[str, Any]: + label = (entry.get("label") or entry.get("name") or "").strip() + gender = (entry.get("gender") or "unknown").strip().lower() + if gender not in {"male", "female", "unknown"}: + gender = "unknown" + voice = entry.get("voice") + voice_profile = entry.get("voice_profile") + voice_formula = entry.get("voice_formula") + voice_languages = entry.get("languages") or [] + if not isinstance(voice_languages, list): + voice_languages = [] + normalized_langs = [] + for code in voice_languages: + if isinstance(code, str) and code: + normalized_langs.append(code.lower()) + resolved_voice = entry.get("resolved_voice") or voice_formula or voice + resolved_label = label or entry.get("id") or "" + slug = entry.get("id") if isinstance(entry.get("id"), str) else slugify_label(resolved_label) + return { + "id": slug, + "label": resolved_label, + "gender": gender, + "voice": voice if isinstance(voice, str) else "", + "voice_profile": voice_profile if isinstance(voice_profile, str) else "", + "voice_formula": voice_formula if isinstance(voice_formula, str) else "", + "resolved_voice": resolved_voice if isinstance(resolved_voice, str) else "", + "languages": normalized_langs, + } + + +def list_configs() -> List[Dict[str, Any]]: + configs = load_configs() + ordered = [] + for name in sorted(configs): + entry = configs[name] + ordered.append({"name": name, **entry}) + return ordered + + +def describe_language(code: str) -> str: + code = (code or "a").lower() + return LANGUAGE_DESCRIPTIONS.get(code, code.upper()) diff --git a/abogen/text_extractor.py b/abogen/text_extractor.py new file mode 100644 index 0000000..b6e2ee9 --- /dev/null +++ b/abogen/text_extractor.py @@ -0,0 +1,977 @@ +from __future__ import annotations + +import datetime +import logging +import mimetypes +import re +import textwrap +import urllib.parse +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple, cast + +import ebooklib # type: ignore[import] +import fitz # type: ignore[import] +import markdown # type: ignore[import] +from bs4 import BeautifulSoup, NavigableString # type: ignore[import] +from ebooklib import epub # type: ignore[import] + +from .utils import calculate_text_length, clean_text, detect_encoding + +logger = logging.getLogger(__name__) + +METADATA_PATTERN = re.compile(r"<>", re.DOTALL) +CHAPTER_PATTERN = re.compile(r"<>", re.IGNORECASE) +METADATA_KEY_MAP: Dict[str, str] = { + "TITLE": "title", + "ARTIST": "artist", + "ALBUM": "album", + "YEAR": "year", + "ALBUM_ARTIST": "album_artist", + "ALBUMARTIST": "album_artist", + "COMPOSER": "composer", + "GENRE": "genre", + "DATE": "date", + "PUBLISHER": "publisher", + "COMMENT": "comment", + "LANGUAGE": "language", +} + + +@dataclass +class ExtractedChapter: + title: str + text: str + + @property + def characters(self) -> int: + return calculate_text_length(self.text) + + +@dataclass +class ExtractionResult: + chapters: List[ExtractedChapter] + metadata: Dict[str, str] = field(default_factory=dict) + cover_image: Optional[bytes] = None + cover_mime: Optional[str] = None + + @property + def combined_text(self) -> str: + return "\n\n".join(chapter.text for chapter in self.chapters) + + @property + def total_characters(self) -> int: + return sum(chapter.characters for chapter in self.chapters) + + +@dataclass +class MetadataSource: + title: Optional[str] = None + authors: List[str] = field(default_factory=list) + description: Optional[str] = None + publisher: Optional[str] = None + publication_year: Optional[str] = None + language: Optional[str] = None + series: Optional[str] = None + series_index: Optional[str] = None + + +@dataclass +class NavEntry: + src: str + title: str + doc_href: str + position: int + doc_order: int + + +def extract_from_path(path: Path) -> ExtractionResult: + suffix = path.suffix.lower() + if suffix == ".txt": + return _extract_plaintext(path) + if suffix == ".pdf": + return _extract_pdf(path) + if suffix in {".md", ".markdown"}: + return _extract_markdown(path) + if suffix == ".epub": + return _extract_epub(path) + raise ValueError(f"Unsupported input type: {suffix}") + + +def _extract_plaintext(path: Path) -> ExtractionResult: + encoding = detect_encoding(str(path)) + raw = path.read_text(encoding=encoding, errors="replace") + return _extract_from_string(raw, default_title=path.stem) + + +def _extract_from_string(raw: str, default_title: str) -> ExtractionResult: + raw_metadata, body = _strip_metadata(raw) + chapters = _split_chapters(body, default_title) + normalized_tags = _normalize_metadata_keys(raw_metadata) + chapter_count = len(chapters) + artist_value = normalized_tags.get("artist") + authors = [name.strip() for name in artist_value.split(",") if name.strip()] if artist_value else [] + metadata_source = MetadataSource( + title=normalized_tags.get("title") or default_title, + authors=authors, + publication_year=normalized_tags.get("year"), + ) + metadata = _build_metadata_payload(metadata_source, chapter_count, "text", default_title) + metadata.update(normalized_tags) + if not chapters: + chapters = [ExtractedChapter(title=default_title, text="")] + return ExtractionResult(chapters=chapters, metadata=metadata) + + +def _strip_metadata(content: str) -> Tuple[Dict[str, str], str]: + metadata: Dict[str, str] = {} + + def _replacer(match: re.Match) -> str: + key = match.group(1).strip().upper() + value = match.group(2).strip() + if value: + metadata[key] = value + return "" + + stripped = METADATA_PATTERN.sub(_replacer, content) + return metadata, stripped + + +def _split_chapters(content: str, default_title: str) -> List[ExtractedChapter]: + matches = list(CHAPTER_PATTERN.finditer(content)) + if not matches: + cleaned = clean_text(content) + return [ExtractedChapter(title=default_title, text=cleaned)] + + chapters: List[ExtractedChapter] = [] + last_index = 0 + current_title = default_title + + for match in matches: + segment = content[last_index:match.start()] + if segment.strip(): + chapters.append(ExtractedChapter(title=current_title, text=clean_text(segment))) + current_title = match.group(1).strip() or default_title + last_index = match.end() + + tail = content[last_index:] + if tail.strip(): + chapters.append(ExtractedChapter(title=current_title, text=clean_text(tail))) + + return chapters + + +def _normalize_metadata_keys(metadata: Dict[str, str]) -> Dict[str, str]: + normalized: Dict[str, str] = {} + for key, value in metadata.items(): + if not value: + continue + mapped = METADATA_KEY_MAP.get(key.upper(), key.lower()) + normalized[mapped] = value + return normalized + + +def _build_metadata_payload( + metadata_source: MetadataSource, + chapter_count: int, + file_type: str, + default_title: str, +) -> Dict[str, str]: + now_year = str(datetime.datetime.now().year) + title = metadata_source.title.strip() if metadata_source.title else default_title + if not title: + title = default_title + authors = [author for author in metadata_source.authors if author.strip()] + if not authors: + authors = ["Unknown"] + authors_text = ", ".join(authors) + if chapter_count <= 0: + chapter_count = 1 + chapter_label = "Chapters" if file_type in {"epub", "markdown"} else "Pages" + metadata = { + "TITLE": title, + "ARTIST": authors_text, + "ALBUM": title, + "YEAR": metadata_source.publication_year or now_year, + "ALBUM_ARTIST": authors_text, + "COMPOSER": authors_text, + "GENRE": "Audiobook", + "CHAPTER_COUNT": str(chapter_count), + } + if metadata_source.publisher: + metadata["PUBLISHER"] = metadata_source.publisher + if metadata_source.description: + metadata["COMMENT"] = metadata_source.description + if metadata_source.language: + metadata["LANGUAGE"] = metadata_source.language + normalized = _normalize_metadata_keys(metadata) + # Ensure chapter_count survives normalization even if upstream metadata provided it + normalized.setdefault("chapter_count", str(chapter_count)) + return normalized + + +def _extract_pdf(path: Path) -> ExtractionResult: + metadata_source = MetadataSource() + chapters: List[ExtractedChapter] = [] + with fitz.open(str(path)) as document: + metadata_source = _collect_pdf_metadata(document) + pages = cast(Iterable[fitz.Page], document) + for index, page in enumerate(pages): + page_obj = cast(Any, page) + text = _clean_pdf_text(page_obj.get_text()) + if not text: + continue + title = f"Page {index + 1}" + chapters.append(ExtractedChapter(title=title, text=text)) + if not chapters: + chapters.append(ExtractedChapter(title=path.stem, text="")) + metadata = _build_metadata_payload(metadata_source, len(chapters), "pdf", path.stem) + return ExtractionResult(chapters=chapters, metadata=metadata) + + +def _collect_pdf_metadata(document: fitz.Document) -> MetadataSource: + metadata = MetadataSource() + info = document.metadata or {} + if info.get("title"): + metadata.title = info["title"] + if info.get("author"): + metadata.authors = [info["author"]] + if info.get("subject"): + metadata.description = info["subject"] + if info.get("keywords"): + keywords = info["keywords"] + if metadata.description: + metadata.description = f"{metadata.description}\n\nKeywords: {keywords}" + else: + metadata.description = f"Keywords: {keywords}" + if info.get("creator"): + metadata.publisher = info["creator"] + for key in ("creationDate", "modDate"): + value = info.get(key) + if not value: + continue + match = re.search(r"D:(\d{4})", value) + if match: + metadata.publication_year = match.group(1) + break + return metadata + + +def _clean_pdf_text(text: str) -> str: + cleaned = clean_text(text) + cleaned = re.sub(r"\[\s*\d+\s*\]", "", cleaned) + cleaned = re.sub(r"^\s*\d+\s*$", "", cleaned, flags=re.MULTILINE) + cleaned = re.sub(r"\s+\d+\s*$", "", cleaned, flags=re.MULTILINE) + cleaned = re.sub(r"\s+[-–—]\s*\d+\s*[-–—]?\s*$", "", cleaned, flags=re.MULTILINE) + return cleaned.strip() + + +def _extract_markdown(path: Path) -> ExtractionResult: + encoding = detect_encoding(str(path)) + raw = path.read_text(encoding=encoding, errors="replace") + metadata_source, chapters = _parse_markdown(raw, path.stem) + if not chapters: + chapters = [ExtractedChapter(title=metadata_source.title or path.stem, text=clean_text(raw))] + metadata = _build_metadata_payload(metadata_source, len(chapters), "markdown", path.stem) + return ExtractionResult(chapters=chapters, metadata=metadata) + + +def _parse_markdown(raw: str, default_title: str) -> Tuple[MetadataSource, List[ExtractedChapter]]: + metadata = MetadataSource() + text = textwrap.dedent(raw) + frontmatter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL) + if frontmatter_match: + frontmatter = frontmatter_match.group(1) + _parse_markdown_frontmatter(frontmatter, metadata) + text_body = text[frontmatter_match.end():] + else: + text_body = text + + md = markdown.Markdown(extensions=["toc", "fenced_code"]) + html = md.convert(text_body) + toc_tokens = getattr(md, "toc_tokens", None) or [] + + if not toc_tokens: + cleaned = clean_text(text_body) + title = metadata.title or default_title + chapters = [ExtractedChapter(title=title, text=cleaned)] if cleaned else [] + return metadata, chapters + + headers: List[dict] = [] + + def _flatten_tokens(tokens): + for token in tokens: + headers.append(token) + if token.get("children"): + _flatten_tokens(token["children"]) + + _flatten_tokens(toc_tokens) + + header_positions: List[Tuple[str, int, str]] = [] + for header in headers: + header_id = header.get("id") + if not header_id: + continue + id_pattern = f'id="{header_id}"' + pos = html.find(id_pattern) + if pos == -1: + continue + tag_start = html.rfind("<", 0, pos) + name = str(header.get("name", header_id)) + header_positions.append((header_id, tag_start, name)) + + header_positions.sort(key=lambda item: item[1]) + + chapters: List[ExtractedChapter] = [] + for index, (header_id, start, name) in enumerate(header_positions): + end = header_positions[index + 1][1] if index + 1 < len(header_positions) else len(html) + section_html = html[start:end] + section_soup = BeautifulSoup(section_html, "html.parser") + header_tag = section_soup.find(attrs={"id": header_id}) + if header_tag: + header_tag.decompose() + section_text = clean_text(section_soup.get_text()).strip() + if not section_text: + continue + chapters.append(ExtractedChapter(title=name.strip(), text=section_text)) + + if not metadata.title: + first_h1 = next((header for header in headers if header.get("level") == 1 and header.get("name")), None) + if first_h1: + metadata.title = str(first_h1["name"]) + + return metadata, chapters + + +def _parse_markdown_frontmatter(frontmatter: str, metadata: MetadataSource) -> None: + title_match = re.search(r"^title:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE) + if title_match: + metadata.title = title_match.group(1).strip().strip('"\'') + + author_match = re.search(r"^author:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE) + if author_match: + metadata.authors = [author_match.group(1).strip().strip('"\'')] + + desc_match = re.search(r"^description:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE) + if desc_match: + metadata.description = desc_match.group(1).strip().strip('"\'') + + date_match = re.search(r"^date:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE) + if date_match: + date_str = date_match.group(1).strip().strip('\"\'') + year_match = re.search(r"\b(19|20)\d{2}\b", date_str) + if year_match: + metadata.publication_year = year_match.group(0) + + +def _extract_epub(path: Path) -> ExtractionResult: + extractor = EpubExtractor(path) + return extractor.extract() + + +class EpubExtractor: + def __init__(self, path: Path) -> None: + self.path = path + self.book = epub.read_epub(str(path)) + self.doc_content: Dict[str, str] = {} + self.spine_docs: List[str] = [] + + def extract(self) -> ExtractionResult: + metadata_source = self._collect_metadata() + try: + chapters = self._process_nav() + except Exception as exc: + logger.warning( + "EPUB navigation processing failed for %s: %s. Falling back to spine order.", + self.path.name, + exc, + exc_info=True, + ) + chapters = self._process_spine_fallback() + if not chapters: + chapters = [ExtractedChapter(title=self.path.stem, text="")] + metadata = _build_metadata_payload(metadata_source, len(chapters), "epub", self.path.stem) + metadata.setdefault("chapter_count", str(len(chapters))) + if metadata_source.series: + series_text = str(metadata_source.series).strip() + if series_text: + metadata.setdefault("series", series_text) + metadata.setdefault("series_name", series_text) + metadata.setdefault("seriesname", series_text) + if metadata_source.series_index: + idx_text = str(metadata_source.series_index).strip() + if idx_text: + metadata.setdefault("series_index", idx_text) + metadata.setdefault("series_sequence", idx_text) + metadata.setdefault("book_number", idx_text) + cover_image, cover_mime = self._extract_cover() + return ExtractionResult( + chapters=chapters, + metadata=metadata, + cover_image=cover_image, + cover_mime=cover_mime, + ) + + def _collect_metadata(self) -> MetadataSource: + metadata = MetadataSource() + try: + title_items = self.book.get_metadata("DC", "title") + if title_items: + metadata.title = title_items[0][0] + except Exception as exc: + logger.debug("Failed to extract EPUB title metadata: %s", exc) + + try: + author_items = self.book.get_metadata("DC", "creator") + if author_items: + metadata.authors = [author[0] for author in author_items if author and author[0]] + except Exception as exc: + logger.debug("Failed to extract EPUB author metadata: %s", exc) + + try: + desc_items = self.book.get_metadata("DC", "description") + if desc_items: + metadata.description = desc_items[0][0] + except Exception as exc: + logger.debug("Failed to extract EPUB description metadata: %s", exc) + + try: + publisher_items = self.book.get_metadata("DC", "publisher") + if publisher_items: + metadata.publisher = publisher_items[0][0] + except Exception as exc: + logger.debug("Failed to extract EPUB publisher metadata: %s", exc) + + try: + date_items = self.book.get_metadata("DC", "date") + if date_items: + date_str = date_items[0][0] + year_match = re.search(r"\b(19|20)\d{2}\b", date_str) + metadata.publication_year = year_match.group(0) if year_match else date_str + except Exception as exc: + logger.debug("Failed to extract EPUB publication year metadata: %s", exc) + + try: + language_items = self.book.get_metadata("DC", "language") + if language_items: + metadata.language = language_items[0][0] + except Exception as exc: + logger.debug("Failed to extract EPUB language metadata: %s", exc) + + # Series metadata (best-effort). Common sources: + # - Calibre embeds OPF meta tags: + # - EPUB3 collections via: ... + try: + meta_items = self.book.get_metadata("OPF", "meta") + except Exception as exc: + logger.debug("Failed to extract EPUB OPF meta tags: %s", exc) + meta_items = [] + + series_name: Optional[str] = None + series_index: Optional[str] = None + for value, attrs in meta_items or []: + attrs_dict = attrs or {} + name = str(attrs_dict.get("name") or "").strip().casefold() + prop = str(attrs_dict.get("property") or "").strip().casefold() + content = attrs_dict.get("content") + candidate = content if content is not None else value + candidate_text = str(candidate or "").strip() + if not candidate_text: + continue + + if name in {"calibre:series", "series"} and series_name is None: + series_name = candidate_text + continue + if name in {"calibre:series_index", "calibre:seriesindex", "series_index", "seriesindex"} and series_index is None: + series_index = candidate_text + continue + + if prop.endswith("belongs-to-collection") and series_name is None: + series_name = candidate_text + continue + + metadata.series = series_name + metadata.series_index = series_index + + return metadata + + def _extract_cover(self) -> Tuple[Optional[bytes], Optional[str]]: + try: + for item in self.book.get_items_of_type(ebooklib.ITEM_COVER): + data = item.get_content() + if data: + media_type = getattr(item, "media_type", None) + return data, media_type + except Exception as exc: + logger.debug("Failed to read dedicated EPUB cover image: %s", exc) + + try: + for item in self.book.get_items_of_type(ebooklib.ITEM_IMAGE): + name = item.get_name().lower() + if "cover" not in name and "front" not in name: + continue + data = item.get_content() + if not data: + continue + media_type = getattr(item, "media_type", None) + if not media_type: + media_type = mimetypes.guess_type(name)[0] + return data, media_type + except Exception as exc: + logger.debug("Failed to locate fallback EPUB cover image: %s", exc) + + return None, None + + def _process_nav(self) -> List[ExtractedChapter]: + nav_item, nav_type = self._find_navigation_item() + if not nav_item or not nav_type: + raise ValueError("No navigation document found") + + parser_type = "html.parser" if nav_type == "html" else "xml" + nav_content = nav_item.get_content().decode("utf-8", errors="ignore") + nav_soup = BeautifulSoup(nav_content, parser_type) + + self.spine_docs = self._build_spine_docs() + doc_order = {href: index for index, href in enumerate(self.spine_docs)} + doc_order_decoded = {urllib.parse.unquote(href): index for href, index in doc_order.items()} + + nav_targets = self._collect_nav_targets(nav_soup, nav_type) + self._cache_relevant_documents(doc_order, nav_targets) + + ordered_entries: List[NavEntry] = [] + if nav_type == "ncx": + nav_map = nav_soup.find("navMap") + if not nav_map: + raise ValueError("NCX navigation missing ") + for nav_point in nav_map.find_all("navPoint", recursive=False): + self._parse_ncx_navpoint(nav_point, ordered_entries, doc_order, doc_order_decoded) + else: + toc_nav = nav_soup.find("nav", attrs={"epub:type": "toc"}) + if toc_nav is None: + for nav in nav_soup.find_all("nav"): + if nav.find("ol"): + toc_nav = nav + break + if toc_nav is None: + raise ValueError("NAV HTML missing TOC structure") + top_ol = toc_nav.find("ol", recursive=False) + if top_ol is None: + raise ValueError("TOC navigation missing
      ") + for li in top_ol.find_all("li", recursive=False): + self._parse_html_nav_li(li, ordered_entries, doc_order, doc_order_decoded) + + if not ordered_entries: + raise ValueError("No navigation entries found") + + ordered_entries.sort(key=lambda entry: (entry.doc_order, entry.position)) + chapters = self._slice_entries(ordered_entries) + self._append_prefix_content(ordered_entries, chapters) + return chapters + + def _process_spine_fallback(self) -> List[ExtractedChapter]: + chapters: List[ExtractedChapter] = [] + self.spine_docs = self._build_spine_docs() + self.doc_content = {} + + for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): + href = item.get_name() + if href not in self.spine_docs: + continue + try: + html_content = item.get_content().decode("utf-8", errors="ignore") + except Exception as exc: + logger.error("Error decoding EPUB document %s: %s", href, exc) + html_content = "" + self.doc_content[href] = html_content + + for index, doc_href in enumerate(self.spine_docs): + html_content = self.doc_content.get(doc_href, "") + if not html_content: + continue + text = self._html_to_text(html_content) + if not text: + continue + title = self._resolve_document_title(html_content, fallback=f"Untitled Chapter {index + 1}") + chapters.append(ExtractedChapter(title=title, text=text)) + return chapters + + def _find_navigation_item(self) -> Tuple[Optional[epub.EpubItem], Optional[str]]: + nav_item: Optional[epub.EpubItem] = None + nav_type: Optional[str] = None + + nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION)) + if nav_items: + preferred = next( + ( + item + for item in nav_items + if "nav" in item.get_name().lower() and item.get_name().lower().endswith((".xhtml", ".html")) + ), + None, + ) + if preferred: + nav_item = preferred + nav_type = "html" + else: + html_nav = next( + ( + item + for item in nav_items + if item.get_name().lower().endswith((".xhtml", ".html")) + ), + None, + ) + if html_nav: + nav_item = html_nav + nav_type = "html" + + if not nav_item and nav_items: + ncx_candidate = next( + (item for item in nav_items if item.get_name().lower().endswith(".ncx")), + None, + ) + if ncx_candidate: + nav_item = ncx_candidate + nav_type = "ncx" + + if not nav_item: + ncx_constant = getattr(epub, "ITEM_NCX", None) + if ncx_constant is not None: + ncx_items = list(self.book.get_items_of_type(ncx_constant)) + if ncx_items: + nav_item = ncx_items[0] + nav_type = "ncx" + + if not nav_item: + for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): + try: + html_content = item.get_content().decode("utf-8", errors="ignore") + except Exception: + continue + if " List[str]: + docs: List[str] = [] + for spine_entry in self.book.spine: + item_id = spine_entry[0] + item = self.book.get_item_with_id(item_id) + if item: + docs.append(item.get_name()) + return docs + + def _collect_nav_targets(self, nav_soup: BeautifulSoup, nav_type: str) -> List[str]: + targets: List[str] = [] + if nav_type == "ncx": + for content_node in nav_soup.find_all("content"): + src = content_node.get("src") + if src: + src_value = str(src) + targets.append(src_value.split("#", 1)[0]) + else: + for link in nav_soup.find_all("a"): + href = link.get("href") + if href: + href_value = str(href) + targets.append(href_value.split("#", 1)[0]) + return targets + + def _cache_relevant_documents(self, doc_order: Dict[str, int], nav_targets: List[str]) -> None: + needed: set[str] = set(doc_order.keys()) + for target in nav_targets: + needed.add(target) + needed.add(urllib.parse.unquote(target)) + + self.doc_content = {} + for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): + href = item.get_name() + if href not in needed and urllib.parse.unquote(href) not in needed: + continue + try: + html_content = item.get_content().decode("utf-8", errors="ignore") + except Exception as exc: + logger.error("Error decoding EPUB document %s: %s", href, exc) + html_content = "" + self.doc_content[href] = html_content + + def _parse_ncx_navpoint( + self, + nav_point, + ordered_entries: List[NavEntry], + doc_order: Dict[str, int], + doc_order_decoded: Dict[str, int], + ) -> None: + nav_label = nav_point.find("navLabel") + content = nav_point.find("content") + title = ( + nav_label.find("text").get_text(strip=True) + if nav_label and nav_label.find("text") + else "Untitled Section" + ) + src = content.get("src") if content and content.has_attr("src") else None + + if src: + base_href, fragment = src.split("#", 1) if "#" in src else (src, None) + doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded) + if doc_key is not None and doc_idx is not None: + position = self._find_position_robust(doc_key, fragment) + ordered_entries.append( + NavEntry( + src=src, + title=title, + doc_href=doc_key, + position=position, + doc_order=doc_idx, + ) + ) + else: + logger.warning( + "Navigation entry '%s' points to '%s', which is not in the spine.", + title, + base_href, + ) + + for child_navpoint in nav_point.find_all("navPoint", recursive=False): + self._parse_ncx_navpoint(child_navpoint, ordered_entries, doc_order, doc_order_decoded) + + def _parse_html_nav_li( + self, + li_element, + ordered_entries: List[NavEntry], + doc_order: Dict[str, int], + doc_order_decoded: Dict[str, int], + ) -> None: + link = li_element.find("a", recursive=False) + span_text = li_element.find("span", recursive=False) + title = "Untitled Section" + + if link and link.has_attr("href"): + src = link["href"] + title = link.get_text(strip=True) or title + else: + src = None + if span_text: + title = span_text.get_text(strip=True) or title + else: + text = "".join(t for t in li_element.stripped_strings) + if text: + title = text + + title = title.strip() or "Untitled Section" + + if src: + base_href, fragment = src.split("#", 1) if "#" in src else (src, None) + doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded) + if doc_key is not None and doc_idx is not None: + position = self._find_position_robust(doc_key, fragment) + ordered_entries.append( + NavEntry( + src=src, + title=title, + doc_href=doc_key, + position=position, + doc_order=doc_idx, + ) + ) + else: + logger.warning( + "Navigation entry '%s' points to '%s', which is not in the spine.", + title, + base_href, + ) + + for child_ol in li_element.find_all("ol", recursive=False): + for child_li in child_ol.find_all("li", recursive=False): + self._parse_html_nav_li(child_li, ordered_entries, doc_order, doc_order_decoded) + + def _find_doc_key( + self, + base_href: str, + doc_order: Dict[str, int], + doc_order_decoded: Dict[str, int], + ) -> Tuple[Optional[str], Optional[int]]: + candidates = {base_href, urllib.parse.unquote(base_href)} + base_name = urllib.parse.unquote(base_href).split("/")[-1].lower() + for key in list(doc_order.keys()) + list(doc_order_decoded.keys()): + if key.split("/")[-1].lower() == base_name: + candidates.add(key) + for candidate in candidates: + if candidate in doc_order: + return candidate, doc_order[candidate] + if candidate in doc_order_decoded: + return candidate, doc_order_decoded[candidate] + return None, None + + def _find_position_robust(self, doc_href: str, fragment_id: Optional[str]) -> int: + if doc_href not in self.doc_content: + logger.warning("Document '%s' not found in cached EPUB content.", doc_href) + return 0 + html_content = self.doc_content[doc_href] + if not fragment_id: + return 0 + + try: + temp_soup = BeautifulSoup(f"
      {html_content}
      ", "html.parser") + target_element = temp_soup.find(id=fragment_id) + if target_element: + tag_str = str(target_element) + pos = html_content.find(tag_str[: min(len(tag_str), 200)]) + if pos != -1: + return pos + except Exception: + logger.debug("BeautifulSoup failed to locate id '%s' in %s", fragment_id, doc_href) + + safe_fragment_id = re.escape(fragment_id) + id_name_pattern = re.compile( + f"<[^>]+(?:id|name)\\s*=\\s*[\"']{safe_fragment_id}[\"']", + re.IGNORECASE, + ) + match = id_name_pattern.search(html_content) + if match: + return match.start() + + id_pos = html_content.find(f'id="{fragment_id}"') + name_pos = html_content.find(f'name="{fragment_id}"') + candidates = [pos for pos in (id_pos, name_pos) if pos != -1] + if candidates: + pos = min(candidates) + tag_start = html_content.rfind("<", 0, pos) + return tag_start if tag_start != -1 else pos + + logger.warning("Anchor '%s' not found in %s. Defaulting to start.", fragment_id, doc_href) + return 0 + + def _slice_entries(self, ordered_entries: List[NavEntry]) -> List[ExtractedChapter]: + chapters: List[ExtractedChapter] = [] + for index, entry in enumerate(ordered_entries): + next_entry = ordered_entries[index + 1] if index + 1 < len(ordered_entries) else None + slice_html = self._slice_entry(entry, next_entry) + text = self._html_to_text(slice_html) + if not text: + continue + title = entry.title or "Untitled Section" + chapters.append(ExtractedChapter(title=title, text=text)) + return chapters + + def _slice_entry( + self, + current_entry: NavEntry, + next_entry: Optional[NavEntry], + ) -> str: + current_doc = current_entry.doc_href + current_pos = current_entry.position + current_html = self.doc_content.get(current_doc, "") + if not current_html: + return "" + + if next_entry and next_entry.doc_href == current_doc: + return current_html[current_pos : next_entry.position] + + slice_html = current_html[current_pos:] + if next_entry: + docs_between = self._docs_between(current_doc, next_entry.doc_href) + for doc_href in docs_between: + slice_html += self.doc_content.get(doc_href, "") + next_doc_html = self.doc_content.get(next_entry.doc_href, "") + slice_html += next_doc_html[: next_entry.position] + else: + for doc_href in self._docs_between(current_doc, None): + slice_html += self.doc_content.get(doc_href, "") + + if not slice_html.strip(): + logger.warning( + "No content found for navigation source '%s'. Using full document fallback.", + current_entry.src, + ) + return current_html + return slice_html + + def _docs_between(self, current_doc: str, next_doc: Optional[str]) -> List[str]: + docs: List[str] = [] + try: + current_idx = self.spine_docs.index(current_doc) + except ValueError: + return docs + + if next_doc is None: + docs.extend(self.spine_docs[current_idx + 1 :]) + return docs + + try: + next_idx = self.spine_docs.index(next_doc) + except ValueError: + return docs + + if current_idx < next_idx: + docs.extend(self.spine_docs[current_idx + 1 : next_idx]) + elif current_idx > next_idx: + docs.extend(self.spine_docs[current_idx + 1 :]) + docs.extend(self.spine_docs[:next_idx]) + return docs + + def _append_prefix_content( + self, + ordered_entries: List[NavEntry], + chapters: List[ExtractedChapter], + ) -> None: + if not ordered_entries: + return + first_entry = ordered_entries[0] + first_doc = first_entry.doc_href + first_pos = first_entry.position + if first_pos <= 0: + return + + prefix_html = "" + try: + first_idx = self.spine_docs.index(first_doc) + except ValueError: + first_idx = -1 + + if first_idx > 0: + for doc_href in self.spine_docs[:first_idx]: + prefix_html += self.doc_content.get(doc_href, "") + prefix_html += self.doc_content.get(first_doc, "")[:first_pos] + prefix_text = self._html_to_text(prefix_html) + if prefix_text and (not chapters or prefix_text != chapters[0].text): + chapters.insert(0, ExtractedChapter(title="Introduction", text=prefix_text)) + + def _html_to_text(self, html: str) -> str: + if not html: + return "" + soup = BeautifulSoup(html, "html.parser") + for tag in soup.find_all(["p", "div"]): + tag.append("\n\n") + for ol in soup.find_all("ol"): + start_attr = ol.get("start") + try: + start = int(str(start_attr)) if start_attr is not None else 1 + except (TypeError, ValueError): + start = 1 + for idx, li in enumerate(ol.find_all("li", recursive=False)): + number_text = f"{start + idx}) " + existing = li.string + if isinstance(existing, NavigableString): + existing.replace_with(NavigableString(number_text + str(existing))) + else: + li.insert(0, NavigableString(number_text)) + for tag in soup.find_all(["sup", "sub"]): + tag.decompose() + text = clean_text(soup.get_text()) + return text.strip() + + def _resolve_document_title(self, html_content: str, fallback: str) -> str: + soup = BeautifulSoup(html_content, "html.parser") + if soup.title and soup.title.string: + return soup.title.string.strip() + for heading_tag in ("h1", "h2", "h3"): + heading = soup.find(heading_tag) + if heading and heading.get_text(strip=True): + return heading.get_text(strip=True) + return fallback diff --git a/abogen/tts_supertonic.py b/abogen/tts_supertonic.py new file mode 100644 index 0000000..68b7e69 --- /dev/null +++ b/abogen/tts_supertonic.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import ast +from dataclasses import dataclass +import logging +import math +import re +from typing import Any, Iterable, Iterator, Optional + +import numpy as np + + +logger = logging.getLogger(__name__) + + +DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5") + + +@dataclass +class SupertonicSegment: + graphemes: str + audio: np.ndarray + + +def _ensure_float32_mono(wav: Any) -> np.ndarray: + arr = np.asarray(wav, dtype="float32") + if arr.ndim == 2: + # (n, 1) or (1, n) or (n, channels) + if arr.shape[0] == 1 and arr.shape[1] > 1: + arr = arr.reshape(-1) + else: + arr = arr[:, 0] + return arr.reshape(-1) + + +def _resample_linear(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray: + if src_rate == dst_rate: + return audio + if audio.size == 0: + return audio + ratio = dst_rate / float(src_rate) + new_len = int(round(audio.size * ratio)) + if new_len <= 1: + return np.zeros(0, dtype="float32") + x_old = np.linspace(0.0, 1.0, num=audio.size, endpoint=False) + x_new = np.linspace(0.0, 1.0, num=new_len, endpoint=False) + return np.interp(x_new, x_old, audio).astype("float32", copy=False) + + +def _split_text(text: str, *, split_pattern: Optional[str], max_chunk_length: int) -> list[str]: + stripped = (text or "").strip() + if not stripped: + return [] + parts: list[str] + if split_pattern: + try: + parts = [p.strip() for p in re.split(split_pattern, stripped) if p.strip()] + except re.error: + parts = [stripped] + else: + parts = [stripped] + + # Enforce max length by hard-splitting long parts. + result: list[str] = [] + for part in parts: + if len(part) <= max_chunk_length: + result.append(part) + continue + start = 0 + while start < len(part): + end = min(len(part), start + max_chunk_length) + # Try to split at whitespace. + if end < len(part): + ws = part.rfind(" ", start, end) + if ws > start + 40: + end = ws + chunk = part[start:end].strip() + if chunk: + result.append(chunk) + start = end + return result + + +_UNSUPPORTED_CHARS_RE = re.compile(r"unsupported character\(s\):\s*(\[[^\]]*\])", re.IGNORECASE) + + +def _parse_unsupported_characters(error: BaseException) -> list[str]: + """Best-effort extraction of unsupported characters from SuperTonic errors.""" + + message = " ".join(str(part) for part in getattr(error, "args", ()) if part is not None) or str(error) + match = _UNSUPPORTED_CHARS_RE.search(message) + if not match: + return [] + + raw = match.group(1) + try: + value = ast.literal_eval(raw) + except Exception: + return [] + + if isinstance(value, (list, tuple)): + out: list[str] = [] + for item in value: + if item is None: + continue + s = str(item) + if s: + out.append(s) + return out + + if isinstance(value, str) and value: + return [value] + + return [] + + +def _remove_unsupported_characters(text: str, unsupported: Iterable[str]) -> str: + result = text + for item in unsupported: + if not item: + continue + result = result.replace(item, "") + return result + + +def _configure_supertonic_gpu() -> None: + """Patch supertonic's config to enable GPU acceleration if available.""" + try: + import onnxruntime as ort + available = ort.get_available_providers() + + # Use CUDA if available, skip TensorRT (requires extra libs not always present) + # TensorrtExecutionProvider may be listed as available but fail at runtime + # if TensorRT libraries (libnvinfer.so) are not installed + providers = [] + if "CUDAExecutionProvider" in available: + providers.append("CUDAExecutionProvider") + providers.append("CPUExecutionProvider") + + # Patch supertonic's config and loader before TTS import + # We must patch both because loader imports the value at module load time + import supertonic.config as supertonic_config + import supertonic.loader as supertonic_loader + supertonic_config.DEFAULT_ONNX_PROVIDERS = providers + supertonic_loader.DEFAULT_ONNX_PROVIDERS = providers + logger.info("Supertonic ONNX providers configured: %s", providers) + except Exception as exc: + logger.warning("Could not configure supertonic GPU providers: %s", exc) + + +class SupertonicPipeline: + """Minimal adapter that mimics Kokoro's pipeline iteration interface.""" + + def __init__( + self, + *, + sample_rate: int, + auto_download: bool = True, + total_steps: int = 5, + max_chunk_length: int = 300, + ) -> None: + self.sample_rate = int(sample_rate) + self.total_steps = int(total_steps) + self.max_chunk_length = int(max_chunk_length) + + # Configure GPU providers before importing TTS + _configure_supertonic_gpu() + + try: + from supertonic import TTS # type: ignore[import-not-found] + except Exception as exc: # pragma: no cover + raise RuntimeError( + "Supertonic is not installed. Install it with `pip install supertonic`." + ) from exc + + self._tts = TTS(auto_download=auto_download) + + def __call__( + self, + text: str, + *, + voice: str, + speed: float, + split_pattern: Optional[str] = None, + total_steps: Optional[int] = None, + ) -> Iterator[SupertonicSegment]: + voice_name = (voice or "").strip() or "M1" + steps = int(total_steps) if total_steps is not None else self.total_steps + steps = max(2, min(15, steps)) + speed_value = float(speed) if speed is not None else 1.0 + speed_value = max(0.7, min(2.0, speed_value)) + + style = self._tts.get_voice_style(voice_name=voice_name) + chunks = _split_text(text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length) + for chunk in chunks: + chunk_to_speak = chunk + removed: set[str] = set() + last_exc: Exception | None = None + + # SuperTonic can raise ValueError for unsupported characters; strip and retry. + for attempt in range(3): + try: + wav, duration = self._tts.synthesize( + text=chunk_to_speak, + voice_style=style, + total_steps=steps, + speed=speed_value, + max_chunk_length=self.max_chunk_length, + silence_duration=0.0, + verbose=False, + ) + break + except ValueError as exc: + last_exc = exc + unsupported = _parse_unsupported_characters(exc) + if not unsupported: + raise + + removed.update(unsupported) + sanitized = _remove_unsupported_characters(chunk_to_speak, unsupported).strip() + + # If we didn't change anything, don't loop forever. + if sanitized == chunk_to_speak.strip(): + raise + + chunk_to_speak = sanitized + if not chunk_to_speak: + logger.warning( + "SuperTonic: dropped a chunk after removing unsupported characters: %s", + sorted(removed), + ) + break + + if attempt == 0: + logger.warning( + "SuperTonic: removed unsupported characters %s and retried.", + sorted(removed), + ) + else: + # Exhausted retries. + assert last_exc is not None + raise last_exc + + if not chunk_to_speak: + continue + + audio = _ensure_float32_mono(wav) + + # If duration is present, infer the source sample rate and resample if needed. + src_rate = self.sample_rate + try: + dur = float(duration) + if dur > 0 and audio.size > 0: + inferred = int(round(audio.size / dur)) + if 8000 <= inferred <= 96000: + src_rate = inferred + except Exception: + pass + + if src_rate != self.sample_rate: + audio = _resample_linear(audio, src_rate, self.sample_rate) + + yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio) diff --git a/abogen/utils.py b/abogen/utils.py index f5d3633..e984157 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -1,23 +1,51 @@ -import os -import sys import json -import warnings +import logging +import os import platform +import re import shutil import subprocess -import re +import sys +import warnings from threading import Thread +from typing import Dict, Optional + +from functools import lru_cache + +from dotenv import load_dotenv, find_dotenv + +def _load_environment() -> None: + explicit_path = os.environ.get("ABOGEN_ENV_FILE") + if explicit_path: + load_dotenv(explicit_path, override=False) + return + dotenv_path = find_dotenv(usecwd=True) + if dotenv_path: + load_dotenv(dotenv_path, override=False) + + +_load_environment() warnings.filterwarnings("ignore") + def detect_encoding(file_path): - import chardet - import charset_normalizer + try: + import chardet # type: ignore[import-not-found] + except ImportError: # pragma: no cover - optional dependency + chardet = None # type: ignore[assignment] + + try: + import charset_normalizer # type: ignore[import-not-found] + except ImportError: # pragma: no cover - optional dependency + charset_normalizer = None # type: ignore[assignment] with open(file_path, "rb") as f: raw_data = f.read() detected_encoding = None for detectors in (charset_normalizer, chardet): + if detectors is None: + continue try: result = detectors.detect(raw_data)["encoding"] except Exception: @@ -28,7 +56,6 @@ def detect_encoding(file_path): encoding = detected_encoding if detected_encoding else "utf-8" return encoding.lower() - def get_resource_path(package, resource): """ Get the path to a resource file, with fallback to local file system. @@ -76,53 +103,214 @@ def get_resource_path(package, resource): def get_version(): """Return the current version of the application.""" try: - with open(get_resource_path("/", "VERSION"), "r") as f: + version_path = get_resource_path("/", "VERSION") + if not version_path: + raise FileNotFoundError("VERSION resource missing") + with open(version_path, "r") as f: return f.read().strip() except Exception: return "Unknown" # Define config path -def get_user_config_path(): +def ensure_directory(path): + resolved = os.path.abspath(os.path.expanduser(str(path))) + os.makedirs(resolved, exist_ok=True) + return resolved + + +@lru_cache(maxsize=1) +def get_user_settings_dir(): + override = os.environ.get("ABOGEN_SETTINGS_DIR") + if override: + return ensure_directory(override) + + data_root = os.environ.get("ABOGEN_DATA") or os.environ.get("ABOGEN_DATA_DIR") + if data_root: + try: + return ensure_directory(os.path.join(data_root, "settings")) + except OSError: + pass + + data_mount = "/data" + if os.path.isdir(data_mount): + try: + return ensure_directory(os.path.join(data_mount, "settings")) + except OSError: + pass + from platformdirs import user_config_dir - # TODO Config directory is changed for Linux and MacOS. But if old config exists, it will be used. - # On non‑Windows, prefer ~/.config/abogen if it already exists if platform.system() != "Windows": - custom_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen") - if os.path.exists(custom_dir): - config_dir = custom_dir - else: - config_dir = user_config_dir( - "abogen", appauthor=False, roaming=True, ensure_exists=True - ) - else: - # Windows and fallback case - config_dir = user_config_dir( - "abogen", appauthor=False, roaming=True, ensure_exists=True - ) + legacy_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen") + if os.path.exists(legacy_dir): + return ensure_directory(legacy_dir) - return os.path.join(config_dir, "config.json") + config_dir = user_config_dir("abogen", appauthor=False, roaming=True, ensure_exists=True) + return ensure_directory(config_dir) + + +def get_user_config_path(): + return os.path.join(get_user_settings_dir(), "config.json") # Define cache path -def get_user_cache_path(folder=None): - from platformdirs import user_cache_dir +@lru_cache(maxsize=1) +def get_user_cache_root(): + logger = logging.getLogger(__name__) - cache_dir = user_cache_dir( - "abogen", appauthor=False, opinion=True, ensure_exists=True - ) + def _try_paths(*paths): + last_error = None + for candidate in paths: + if not candidate: + continue + try: + return ensure_directory(candidate) + except OSError as exc: + last_error = exc + logger.debug("Unable to use cache directory %s: %s", candidate, exc) + if last_error is not None: + raise last_error + + def _configure_cache_env(root: Optional[str]) -> None: + temp_root = None + if root: + try: + temp_root = ensure_directory(root) + except OSError: + temp_root = None + + home_dir = os.environ.get("HOME") + if not home_dir: + home_dir = ensure_directory(os.path.join("/tmp", "abogen-home")) + os.environ["HOME"] = home_dir + else: + home_dir = ensure_directory(home_dir) + + cache_base = os.environ.get("XDG_CACHE_HOME") + if cache_base: + cache_base = ensure_directory(cache_base) + elif temp_root: + cache_base = temp_root + os.environ["XDG_CACHE_HOME"] = cache_base + else: + cache_base = ensure_directory(os.path.join(home_dir, ".cache")) + os.environ["XDG_CACHE_HOME"] = cache_base + + hf_cache = os.environ.get("HF_HOME") + if hf_cache: + hf_cache = ensure_directory(hf_cache) + elif temp_root: + hf_cache = ensure_directory(os.path.join(temp_root, "huggingface")) + os.environ["HF_HOME"] = hf_cache + else: + hf_cache = ensure_directory(os.path.join(cache_base, "huggingface")) + os.environ["HF_HOME"] = hf_cache + + for env_var in ("HUGGINGFACE_HUB_CACHE", "TRANSFORMERS_CACHE"): + os.environ.setdefault(env_var, hf_cache) + + os.environ.setdefault("ABOGEN_INTERNAL_CACHE_ROOT", cache_base) + + cache_root: Optional[str] = None + + override = os.environ.get("ABOGEN_TEMP_DIR") + if override: + try: + cache_root = ensure_directory(override) + except OSError as exc: + logger.warning("ABOGEN_TEMP_DIR=%s is not writable: %s", override, exc) + + if cache_root is None: + from platformdirs import user_cache_dir + + default_cache = user_cache_dir("abogen", appauthor=False, opinion=True) + + data_root = os.environ.get("ABOGEN_DATA") or os.environ.get("ABOGEN_DATA_DIR") + fallback_paths = [ + default_cache, + os.path.join(data_root, "cache") if data_root else None, + "/data/cache", + "/tmp/abogen-cache", + ] + + try: + cache_root = _try_paths(*fallback_paths) + except OSError: + # Final safety net – attempt a tmp directory unique to this process. + tmp_candidate = os.path.join("/tmp", f"abogen-cache-{os.getpid()}") + logger.warning("Falling back to temp cache directory %s", tmp_candidate) + cache_root = ensure_directory(tmp_candidate) + + if cache_root is None: + raise RuntimeError("Unable to determine cache directory") + + _configure_cache_env(cache_root) + return cache_root + + +def get_internal_cache_root(): + root = os.environ.get("ABOGEN_INTERNAL_CACHE_ROOT") or os.environ.get("XDG_CACHE_HOME") + if root: + return ensure_directory(root) + home_dir = os.environ.get("HOME") or os.path.join("/tmp", "abogen-home") + home_dir = ensure_directory(home_dir) + return ensure_directory(os.path.join(home_dir, ".cache")) + + +def get_internal_cache_path(folder=None): + base = get_internal_cache_root() if folder: - cache_dir = os.path.join(cache_dir, folder) - # Ensure the directory exists - os.makedirs(cache_dir, exist_ok=True) - return cache_dir + return ensure_directory(os.path.join(base, folder)) + return base -_sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes +def get_user_cache_path(folder=None): + base = get_user_cache_root() + if folder: + return ensure_directory(os.path.join(base, folder)) + return base + + +@lru_cache(maxsize=1) +def get_user_output_root(): + override = ( + os.environ.get("ABOGEN_OUTPUT_DIR") + or os.environ.get("ABOGEN_OUTPUT_ROOT") + ) + if override: + return ensure_directory(override) + return ensure_directory(os.path.join(get_user_cache_root(), "outputs")) + + +def get_user_output_path(folder=None): + base = get_user_output_root() + if folder: + return ensure_directory(os.path.join(base, folder)) + return base + + +_sleep_procs: Dict[str, Optional[subprocess.Popen[str]]] = {"Darwin": None, "Linux": None} # Store sleep prevention processes + + +def clean_text(text, *args, **kwargs): + # Load replace_single_newlines from config + cfg = load_config() + replace_single_newlines = cfg.get("replace_single_newlines", False) + # Collapse all whitespace (excluding newlines) into single spaces per line and trim edges + lines = [re.sub(r"[^\S\n]+", " ", line).strip() for line in text.splitlines()] + text = "\n".join(lines) + # Standardize paragraph breaks (multiple newlines become exactly two) and trim overall whitespace + text = re.sub(r"\n{3,}", "\n\n", text).strip() + # Optionally replace single newlines with spaces, but preserve double newlines + if replace_single_newlines: + text = re.sub(r"(?>", "", text) + # Ignore metadata patterns + text = re.sub(r"<]*>>", "", text) + # Ignore newlines + text = text.replace("\n", "") + # Ignore leading/trailing spaces + text = text.strip() + # Calculate character count + char_count = len(text) + return char_count + def get_gpu_acceleration(enabled): - """ - Check GPU acceleration availability. - - Note: On Windows, torch DLLs must be pre-loaded in main.py before PyQt6 - to avoid DLL initialization errors. - """ try: - import torch - from torch.cuda import is_available as cuda_available + import torch # type: ignore[import-not-found] + from torch.cuda import is_available as cuda_available # type: ignore[import-not-found] if not enabled: return "GPU available but using CPU.", False - + # Check for Apple Silicon MPS if platform.system() == "Darwin" and platform.processor() == "arm": if torch.backends.mps.is_available(): return "MPS GPU available and enabled.", True else: return "MPS GPU not available on Apple Silicon. Using CPU.", False - + # Check for CUDA if cuda_available(): return "CUDA GPU available and enabled.", True - + # Gather CUDA diagnostic info if not available try: cuda_devices = torch.cuda.device_count() @@ -272,7 +473,7 @@ def prevent_sleep_start(): if system == "Windows": import ctypes - ctypes.windll.kernel32.SetThreadExecutionState( + ctypes.windll.kernel32.SetThreadExecutionState( # type: ignore[attr-defined] 0x80000000 | 0x00000001 | 0x00000040 ) elif system == "Darwin": @@ -306,18 +507,21 @@ def prevent_sleep_end(): if system == "Windows": import ctypes - ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # ES_CONTINUOUS - elif system in ("Darwin", "Linux") and _sleep_procs[system]: - try: - _sleep_procs[system].terminate() - _sleep_procs[system] = None - except Exception: - pass + ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # type: ignore[attr-defined] + elif system in ("Darwin", "Linux"): + proc = _sleep_procs.get(system) + if proc: + try: + proc.terminate() + except Exception: + pass + finally: + _sleep_procs[system] = None def load_numpy_kpipeline(): import numpy as np - from kokoro import KPipeline + from kokoro import KPipeline # type: ignore[import-not-found] return np, KPipeline diff --git a/abogen/voice_cache.py b/abogen/voice_cache.py new file mode 100644 index 0000000..df1662f --- /dev/null +++ b/abogen/voice_cache.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import os +import threading +from typing import Callable, Dict, Iterable, Optional, Set, Tuple + +try: # pragma: no cover - optional dependency guard + from huggingface_hub import hf_hub_download # type: ignore + from huggingface_hub.utils import LocalEntryNotFoundError # type: ignore +except Exception: # pragma: no cover - import fallback + hf_hub_download = None # type: ignore[assignment] + LocalEntryNotFoundError = None # type: ignore[assignment] + +if LocalEntryNotFoundError is None: # pragma: no cover - fallback for tests + class LocalEntryNotFoundError(Exception): + pass + +from abogen.constants import VOICES_INTERNAL + +_CACHE_LOCK = threading.Lock() +_CACHED_VOICES: Set[str] = set() +_BOOTSTRAP_LOCK = threading.Lock() +_BOOTSTRAPPED = False + + +def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]: + if not voices: + return set(VOICES_INTERNAL) + normalized: Set[str] = set() + for voice in voices: + if not voice: + continue + voice_id = str(voice).strip() + if not voice_id: + continue + if voice_id in VOICES_INTERNAL: + normalized.add(voice_id) + return normalized + + +def ensure_voice_assets( + voices: Optional[Iterable[str]] = None, + *, + repo_id: str = "hexgrad/Kokoro-82M", + cache_dir: Optional[str] = None, + on_progress: Optional[Callable[[str], None]] = None, +) -> Tuple[Set[str], Dict[str, str]]: + """Ensure Kokoro voice weight files are present locally. + + Returns a tuple of (downloaded voices, errors) where errors maps the + voice id to the underlying exception message. + """ + + if hf_hub_download is None: + raise RuntimeError("huggingface_hub is required to cache voices") + + effective_cache_dir = cache_dir + if effective_cache_dir is None: + env_cache_dir = os.environ.get("ABOGEN_VOICE_CACHE_DIR", "").strip() + effective_cache_dir = env_cache_dir or None + + targets = _normalize_targets(voices) + if not targets: + return set(), {} + + with _CACHE_LOCK: + missing = [voice for voice in targets if voice not in _CACHED_VOICES] + + downloaded: Set[str] = set() + errors: Dict[str, str] = {} + + for voice_id in missing: + if on_progress: + on_progress(f"Fetching voice asset '{voice_id}'") + try: + downloaded_flag = _ensure_single_voice_asset( + voice_id, + repo_id=repo_id, + cache_dir=effective_cache_dir, + ) + except Exception as exc: # pragma: no cover - network variance + errors[voice_id] = str(exc) + continue + + if downloaded_flag: + downloaded.add(voice_id) + with _CACHE_LOCK: + _CACHED_VOICES.add(voice_id) + + return downloaded, errors + + +def bootstrap_voice_cache( + voices: Optional[Iterable[str]] = None, + *, + repo_id: str = "hexgrad/Kokoro-82M", + cache_dir: Optional[str] = None, + on_progress: Optional[Callable[[str], None]] = None, +) -> Tuple[Set[str], Dict[str, str]]: + """Ensure voices are cached once per process. + + Subsequent calls are no-ops and return empty structures. + """ + + global _BOOTSTRAPPED + with _BOOTSTRAP_LOCK: + if _BOOTSTRAPPED: + return set(), {} + downloaded, errors = ensure_voice_assets( + voices, + repo_id=repo_id, + cache_dir=cache_dir, + on_progress=on_progress, + ) + _BOOTSTRAPPED = True + return downloaded, errors + + +def _ensure_single_voice_asset( + voice_id: str, + *, + repo_id: str, + cache_dir: Optional[str], +) -> bool: + if hf_hub_download is None: + raise RuntimeError("huggingface_hub is required to cache voices") + + filename = f"voices/{voice_id}.pt" + common_kwargs = { + "repo_id": repo_id, + "filename": filename, + } + if cache_dir is not None: + common_kwargs["cache_dir"] = cache_dir + + try: + hf_hub_download(local_files_only=True, **common_kwargs) + return False + except LocalEntryNotFoundError: + pass + + hf_hub_download(resume_download=True, **common_kwargs) + return True \ No newline at end of file diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index 11f717d..3506848 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -1,1521 +1,11 @@ -import json -import os -from PyQt6.QtWidgets import ( - QDialog, - QVBoxLayout, - QCheckBox, - QLabel, - QHBoxLayout, - QDoubleSpinBox, - QSlider, - QScrollArea, - QWidget, - QPushButton, - QSizePolicy, - QMessageBox, - QFrame, - QLayout, - QStyle, - QListWidget, - QListWidgetItem, - QInputDialog, - QFileDialog, - QSplitter, - QMenu, - QApplication, - QComboBox, -) -from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize -from PyQt6.QtGui import QPixmap, QIcon, QAction -from abogen.constants import ( - VOICES_INTERNAL, - SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, - LANGUAGE_DESCRIPTIONS, - COLORS, -) -import re -import platform -from abogen.utils import get_resource_path -from abogen.voice_profiles import ( - load_profiles, - save_profiles, - delete_profile, - duplicate_profile, - export_profiles, -) +"""Backwards-compatible re-export of the PyQt voice formula dialog. +The actual implementation lives in abogen.pyqt.voice_formula_gui. +""" -# Constants -VOICE_MIXER_WIDTH = 100 -SLIDER_WIDTH = 32 -MIN_WINDOW_WIDTH = 600 -MIN_WINDOW_HEIGHT = 400 -INITIAL_WINDOW_WIDTH = 1200 -INITIAL_WINDOW_HEIGHT = 500 +from __future__ import annotations -# Language options for the language selector loaded from constants -LANGUAGE_OPTIONS = list(LANGUAGE_DESCRIPTIONS.items()) +from abogen.pyqt.voice_formula_gui import * # noqa: F401, F403 +from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog - -class SaveButtonWidget(QWidget): - def __init__(self, parent, profile_name, save_callback): - super().__init__(parent) - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - self.save_btn = QPushButton("Save", self) - self.save_btn.setFixedWidth(48) - self.save_btn.clicked.connect(lambda: save_callback(profile_name)) - layout.addStretch() - layout.addWidget(self.save_btn) - self.setLayout(layout) - - -class FlowLayout(QLayout): - def __init__(self, parent=None, margin=0, spacing=-1): - super().__init__(parent) - if parent: - self.setContentsMargins(margin, margin, margin, margin) - self.setSpacing(spacing) - self._item_list = [] - - def __del__(self): - item = self.takeAt(0) - while item: - item = self.takeAt(0) - - def addItem(self, item): - self._item_list.append(item) - - def count(self): - return len(self._item_list) - - def expandingDirections(self): - return Qt.Orientation(0) - - def hasHeightForWidth(self): - return True - - def sizeHint(self): - return self.minimumSize() - - def itemAt(self, index): - if 0 <= index < len(self._item_list): - return self._item_list[index] - return None - - def takeAt(self, index): - if 0 <= index < len(self._item_list): - return self._item_list.pop(index) - return None - - def heightForWidth(self, width): - return self._do_layout(QRect(0, 0, width, 0), True) - - def setGeometry(self, rect): - super().setGeometry(rect) - self._do_layout(rect, False) - - def minimumSize(self): - size = QSize() - for item in self._item_list: - size = size.expandedTo(item.minimumSize()) - margin, _, _, _ = self.getContentsMargins() - size += QSize(2 * margin, 2 * margin) - return size - - def _do_layout(self, rect, test_only): - x, y = rect.x(), rect.y() - line_height = 0 - spacing = self.spacing() - - for item in self._item_list: - style = self.parentWidget().style() if self.parentWidget() else QStyle() - layout_spacing_x = style.layoutSpacing( - QSizePolicy.ControlType.PushButton, - QSizePolicy.ControlType.PushButton, - Qt.Orientation.Horizontal, - ) - layout_spacing_y = style.layoutSpacing( - QSizePolicy.ControlType.PushButton, - QSizePolicy.ControlType.PushButton, - Qt.Orientation.Vertical, - ) - space_x = spacing if spacing >= 0 else layout_spacing_x - space_y = spacing if spacing >= 0 else layout_spacing_y - - next_x = x + item.sizeHint().width() + space_x - if next_x - space_x > rect.right() and line_height > 0: - x = rect.x() - y = y + line_height + space_y - next_x = x + item.sizeHint().width() + space_x - line_height = 0 - - if not test_only: - item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) - - x = next_x - line_height = max(line_height, item.sizeHint().height()) - - return y + line_height - rect.y() - - -class VoiceMixer(QWidget): - def __init__( - self, voice_name, language_code, initial_status=False, initial_weight=0.0 - ): - super().__init__() - self.voice_name = voice_name - self.setFixedWidth(VOICE_MIXER_WIDTH) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - - # TODO Set CSS for rounded corners - # self.setObjectName("VoiceMixer") - # self.setStyleSheet(self.ROUNDED_CSS) - - layout = QVBoxLayout() - - # Name label at the top - name = voice_name - layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter) - - # Voice name label with gender icon - is_female = self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f" - - # Icons layout (flag and gender) - icons_layout = QHBoxLayout() - icons_layout.setSpacing(3) - icons_layout.setAlignment( - Qt.AlignmentFlag.AlignCenter - ) # Center the icons horizontally - - # Flag icon - flag_icon_path = get_resource_path( - "abogen.assets.flags", f"{language_code}.png" - ) - gender_icon_path = get_resource_path( - "abogen.assets", "female.png" if is_female else "male.png" - ) - flag_label = QLabel() - gender_label = QLabel() - flag_pixmap = QPixmap(flag_icon_path) - flag_label.setPixmap( - flag_pixmap.scaled( - 16, - 16, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, - ) - ) - gender_pixmap = QPixmap(gender_icon_path) - gender_label.setPixmap( - gender_pixmap.scaled( - 16, - 16, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, - ) - ) - icons_layout.addWidget(flag_label) - icons_layout.addWidget(gender_label) - - # Add icons layout - layout.addLayout(icons_layout) - - # Checkbox (now below icons) - self.checkbox = QCheckBox() - self.checkbox.setChecked(initial_status) - self.checkbox.stateChanged.connect(self.toggle_inputs) - layout.addWidget(self.checkbox, alignment=Qt.AlignmentFlag.AlignCenter) - - # Spinbox and slider - self.spin_box = QDoubleSpinBox() - self.spin_box.setRange(0, 1) - self.spin_box.setSingleStep(0.01) - self.spin_box.setDecimals(2) - self.spin_box.setValue(initial_weight) - - self.slider = QSlider(Qt.Orientation.Vertical) - self.slider.setRange(0, 100) - self.slider.setValue(int(initial_weight * 100)) - self.slider.setSizePolicy( - QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding - ) - self.slider.setFixedWidth(SLIDER_WIDTH) - - # Apply slider styling after widget is added to window (see showEvent) - self._slider_style_applied = False - - # Connect controls - self.slider.valueChanged.connect(lambda val: self.spin_box.setValue(val / 100)) - self.spin_box.valueChanged.connect( - lambda val: self.slider.setValue(int(val * 100)) - ) - - # Layout for slider and labels - slider_layout = QVBoxLayout() - slider_layout.addWidget(self.spin_box) - slider_layout.addWidget(QLabel("1", alignment=Qt.AlignmentFlag.AlignCenter)) - - slider_center_layout = QHBoxLayout() - slider_center_layout.addWidget( - self.slider, alignment=Qt.AlignmentFlag.AlignHCenter - ) - slider_center_layout.setContentsMargins(0, 0, 0, 0) - - slider_center_widget = QWidget() - slider_center_widget.setLayout(slider_center_layout) - - slider_layout.addWidget(slider_center_widget, stretch=1) - slider_layout.addWidget(QLabel("0", alignment=Qt.AlignmentFlag.AlignCenter)) - slider_layout.setStretch(2, 1) - - layout.addLayout(slider_layout, stretch=1) - self.setLayout(layout) - self.toggle_inputs() - - def showEvent(self, event): - super().showEvent(event) - # Apply slider styling once when widget is shown and has access to parent - if not self._slider_style_applied: - self._slider_style_applied = True - - # Fix slider in Windows - if platform.system() == "Windows": - appstyle = QApplication.instance().style().objectName().lower() - if appstyle != "windowsvista": - # Set custom groove color for disabled state using COLORS["GREY_BACKGROUND"] - self.slider.setStyleSheet( - f""" - QSlider::groove:vertical:disabled {{ - background: {COLORS.get("GREY_BACKGROUND")}; - width: 4px; - border-radius: 4px; - }} - """ - ) - else: - # Apply same fix for Light theme on non-Windows systems - # Get theme from parent window's config - parent_window = self.window() - theme = "system" - while parent_window: - if hasattr(parent_window, "config"): - theme = parent_window.config.get("theme", "system") - break - parent_window = parent_window.parent() - - if theme == "light": - self.slider.setStyleSheet( - f""" - QSlider::groove:vertical:disabled {{ - background: {COLORS.get("GREY_BACKGROUND")}; - width: 4px; - border-radius: 4px; - }} - """ - ) - - def toggle_inputs(self): - is_enabled = self.checkbox.isChecked() - self.spin_box.setEnabled(is_enabled) - self.slider.setEnabled(is_enabled) - - def get_voice_weight(self): - if self.checkbox.isChecked(): - return self.voice_name, self.spin_box.value() - return None - - -class HoverLabel(QLabel): - def __init__(self, text, voice_name, parent=None): - super().__init__(text, parent) - self.voice_name = voice_name - self.setMouseTracking(True) - self.setStyleSheet( - "background-color: rgba(140, 140, 140, 0.15); border-radius: 4px; padding: 3px 6px 3px 6px; margin: 2px;" - ) - - # Create delete button - self.delete_button = QPushButton("×", self) - self.delete_button.setFixedSize(16, 16) - self.delete_button.setStyleSheet( - f""" - QPushButton {{ - background-color: {COLORS.get("RED")}; - color: white; - border-radius: 7px; - font-weight: bold; - font-size: 12px; - border: none; - padding: 0px; - margin: 0px; - }} - QPushButton:hover {{ - background-color: red; - }} - """ - ) - # Make sure the entire button is clickable, not just the text - self.delete_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.delete_button.setAttribute( - Qt.WidgetAttribute.WA_TransparentForMouseEvents, False - ) - self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.delete_button.hide() - - def resizeEvent(self, event): - super().resizeEvent(event) - # Position the button in the top-right corner with a small margin - self.delete_button.move(self.width() - 16, +0) - - def enterEvent(self, event): - self.delete_button.show() - - def leaveEvent(self, event): - self.delete_button.hide() - - -class VoiceFormulaDialog(QDialog): - def __init__(self, parent=None, initial_state=None, selected_profile=None): - super().__init__(parent) - # Store original profile/mix state for restoration on cancel - self._original_profile_name = None - self._original_mixed_voice_state = None - if parent is not None: - self._original_profile_name = getattr(parent, "selected_profile_name", None) - self._original_mixed_voice_state = getattr( - parent, "mixed_voice_state", None - ) - profiles = load_profiles() - self._virtual_new_profile = False - if not profiles: - # No profiles: show 'New profile' in the list, unsaved, not in JSON - self.current_profile = "New profile" - self._profile_dirty = {"New profile": True} - self._virtual_new_profile = True - profiles = {} # Do not add to JSON yet - else: - self.current_profile = ( - selected_profile - if selected_profile in profiles - else list(profiles.keys())[0] - ) - self._profile_dirty = {name: False for name in profiles} - # Track unsaved states per profile - self._profile_states = {} - # Add subtitle_combo reference if parent has it - self.subtitle_combo = None - if parent is not None and hasattr(parent, "subtitle_combo"): - self.subtitle_combo = parent.subtitle_combo - # Create main container layout with profile section and mixer section - splitter = QSplitter(Qt.Orientation.Horizontal) - # Profile section - profile_widget = QWidget() - profile_layout = QVBoxLayout(profile_widget) - profile_layout.setContentsMargins(0, 0, 0, 0) - # Profile header and save/new buttons - header_layout = QHBoxLayout() - header_layout.addWidget(QLabel("Profiles:")) - header_layout.addStretch() - self.btn_new_profile = QPushButton("New profile") - header_layout.addWidget(self.btn_new_profile) - profile_layout.addLayout(header_layout) - # Profile list - self.profile_list = QListWidget() - self.profile_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection) - self.profile_list.setSelectionBehavior(QListWidget.SelectionBehavior.SelectRows) - self.profile_list.setStyleSheet( - "QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }" - ) - icon = QIcon(get_resource_path("abogen.assets", "profile.png")) - if self._virtual_new_profile: - item = QListWidgetItem(icon, "New profile") - self.profile_list.addItem(item) - self.profile_list.setCurrentRow(0) - else: - for name in profiles: - item = QListWidgetItem(icon, name) - self.profile_list.addItem(item) - idx = list(profiles.keys()).index(self.current_profile) - self.profile_list.setCurrentRow(idx) - profile_layout.addWidget(self.profile_list) - self.profile_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.profile_list.customContextMenuRequested.connect( - self.show_profile_context_menu - ) - self.profile_list.setItemWidget = ( - self.profile_list.setItemWidget - ) # for type hints - # Save and management buttons - mgmt_layout = QVBoxLayout() - self.btn_import_profiles = QPushButton("Import profile(s)") - mgmt_layout.addWidget(self.btn_import_profiles) - self.btn_export_profiles = QPushButton("Export profiles") - mgmt_layout.addWidget(self.btn_export_profiles) - profile_layout.addLayout(mgmt_layout) - # prepare mixer widget - mixer_widget = QWidget() - mixer_layout = QVBoxLayout(mixer_widget) - mixer_layout.setContentsMargins(5, 0, 0, 0) - - self.setWindowTitle("Voice Mixer") - self.setWindowFlags( - Qt.WindowType.Window - | Qt.WindowType.WindowCloseButtonHint - | Qt.WindowType.WindowMaximizeButtonHint - ) - self.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) - self.resize(INITIAL_WINDOW_WIDTH, INITIAL_WINDOW_HEIGHT) - self.voice_mixers = [] - self.last_enabled_voice = None - - # Header label and language selector - self.header_label = QLabel( - "Adjust voice weights to create your preferred voice mix." - ) - self.header_label.setStyleSheet("font-size: 13px;") - self.header_label.setWordWrap(True) - header_row = QHBoxLayout() - header_row.addWidget(self.header_label, 1) - header_row.addStretch() - header_row.addWidget(QLabel("Language:")) - self.language_combo = QComboBox() - for code, desc in LANGUAGE_OPTIONS: - flag = get_resource_path("abogen.assets.flags", f"{code}.png") - if flag and os.path.exists(flag): - self.language_combo.addItem(QIcon(flag), desc, code) - else: - self.language_combo.addItem(desc, code) - # set current language for profile - prof = profiles.get(self.current_profile, {}) - lang = prof.get("language") if isinstance(prof, dict) else None - if not lang: - lang = list(LANGUAGE_DESCRIPTIONS.keys())[0] - idx = self.language_combo.findData(lang) - if idx >= 0: - self.language_combo.setCurrentIndex(idx) - self.language_combo.currentIndexChanged.connect(self.mark_profile_modified) - header_row.addWidget(self.language_combo) - # Preview current voice mix using main window's preview - self.btn_preview_mix = QPushButton("Preview", self) - self.btn_preview_mix.setToolTip("Preview current voice mix") - self.btn_preview_mix.clicked.connect(self.preview_current_mix) - header_row.addWidget(self.btn_preview_mix) - mixer_layout.addLayout(header_row) - - # Error message - self.error_label = QLabel( - "Please select at least one voice and set its weight above 0." - ) - self.error_label.setStyleSheet("color: red; font-weight: bold;") - self.error_label.setWordWrap(True) - self.error_label.hide() - mixer_layout.addWidget(self.error_label) - - # Voice weights display - self.weighted_sums_container = QWidget() - self.weighted_sums_layout = FlowLayout(self.weighted_sums_container) - self.weighted_sums_layout.setSpacing(5) - self.weighted_sums_layout.setContentsMargins(5, 5, 5, 5) - mixer_layout.addWidget(self.weighted_sums_container) - - # Separator - separator = QFrame() - separator.setFrameShadow(QFrame.Shadow.Sunken) - mixer_layout.addWidget(separator) - - # Voice list scroll area - self.scroll_area = QScrollArea() - self.scroll_area.setWidgetResizable(True) - self.scroll_area.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded - ) - self.scroll_area.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded - ) - self.scroll_area.viewport().installEventFilter(self) - - self.voice_list_widget = QWidget() - self.voice_list_layout = QHBoxLayout() - self.voice_list_widget.setLayout(self.voice_list_layout) - self.voice_list_widget.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding - ) - self.scroll_area.setWidget(self.voice_list_widget) - mixer_layout.addWidget(self.scroll_area, stretch=1) - - # Buttons - button_layout = QHBoxLayout() - clear_all_button = QPushButton("Clear all") - ok_button = QPushButton("OK") - cancel_button = QPushButton("Cancel") - - # Set OK button as default - ok_button.setDefault(True) - ok_button.setFocus() - - # Connect buttons - clear_all_button.clicked.connect(self.clear_all_voices) - ok_button.clicked.connect(self.accept) - # Connect buttons - clear_all_button.clicked.connect(self.clear_all_voices) - ok_button.clicked.connect(self.accept) - cancel_button.clicked.connect(self.reject) - - button_layout.addStretch() - button_layout.addWidget(clear_all_button) - button_layout.addWidget(ok_button) - button_layout.addWidget(cancel_button) - mixer_layout.addLayout(button_layout) - - self.add_voices(initial_state or []) - self.update_weighted_sums() - - # assemble splitter - splitter.addWidget(profile_widget) - splitter.addWidget(mixer_widget) - splitter.setStretchFactor(1, 1) - # set as main layout - self.setLayout(QHBoxLayout()) - self.layout().addWidget(splitter) - - # Connect profile actions - self.profile_list.currentRowChanged.connect(self.on_profile_selection_changed) - # Track initial profile for proper dirty-state saving - self.last_profile_row = self.profile_list.currentRow() - self.btn_new_profile.clicked.connect(self.new_profile) - self.btn_export_profiles.clicked.connect(self.export_all_profiles) - self.btn_import_profiles.clicked.connect(self.import_profiles_dialog) - # Detect modifications in voice mixers - for vm in self.voice_mixers: - vm.spin_box.valueChanged.connect(self.mark_profile_modified) - vm.checkbox.stateChanged.connect(lambda *_: self.mark_profile_modified()) - - def keyPressEvent(self, event): - # Bind Delete key to delete_profile when a profile is selected - if event.key() == Qt.Key.Key_Delete and self.profile_list.hasFocus(): - item = self.profile_list.currentItem() - if item: - self.delete_profile(item) - return - super().keyPressEvent(event) - - def _has_unsaved_changes(self): - # Only return True if there are actually modified (yellow background) profiles - for i in range(self.profile_list.count()): - item = self.profile_list.item(i) - # Only consider as unsaved if profile is marked dirty (yellow background) - if item.text().startswith("*"): - return True - return False - - def _prompt_save_changes(self): - dirty_indices = [ - i - for i in range(self.profile_list.count()) - if self.profile_list.item(i).text().startswith("*") - ] - parent = self.parent() - if len(dirty_indices) > 1: - msg = f"You have unsaved changes in {len(dirty_indices)} profiles. Do you want to save all?" - ret = QMessageBox.question( - self, - "Unsaved Changes", - msg, - QMessageBox.StandardButton.Save - | QMessageBox.StandardButton.Discard - | QMessageBox.StandardButton.Cancel, - QMessageBox.StandardButton.Save, - ) - if ret == QMessageBox.StandardButton.Save: - # Save all using stored states - profiles = load_profiles() - for i in dirty_indices: - name = self.profile_list.item(i).text().lstrip("*") - state = self._profile_states.get(name) - if state is not None: - profiles[name] = state - self._profile_dirty[name] = False - save_profiles(profiles) - # clear states - for name in list(self._profile_states.keys()): - if name not in profiles: - continue - del self._profile_states[name] - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - # clear markers - for i in dirty_indices: - item = self.profile_list.item(i) - n = item.text().lstrip("*") - item.setText(n) - self.update_profile_save_buttons() - self.update_profile_list_colors() - return True - elif ret == QMessageBox.StandardButton.Discard: - # Discard all modifications - self._profile_states.clear() - for i in dirty_indices: - item = self.profile_list.item(i) - n = item.text().lstrip("*") - item.setText(n) - self._profile_dirty[n] = False - self.update_profile_save_buttons() - self.update_profile_list_colors() - # reload current profile - profiles = load_profiles() - if self.current_profile in profiles: - self.load_profile_state(self.current_profile) - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - return True - else: - return False - else: - # Fallback to original logic for 0 or 1 dirty profile - box = QMessageBox(self) - box.setIcon(QMessageBox.Icon.Warning) - box.setWindowTitle("Unsaved Changes") - box.setText( - "You have unsaved changes in your profile. Do you want to save the changes?" - ) - box.setStandardButtons( - QMessageBox.StandardButton.Save - | QMessageBox.StandardButton.Discard - | QMessageBox.StandardButton.Cancel - ) - box.setDefaultButton(QMessageBox.StandardButton.Save) - ret = box.exec() - if ret == QMessageBox.StandardButton.Save: - for i in range(self.profile_list.count()): - item = self.profile_list.item(i) - name = item.text().lstrip("*") - if ( - self._profile_dirty.get(name, False) - or item.text().startswith("*") - or (name == self.current_profile) - ): - self.profile_list.setCurrentRow(i) - self.save_profile_by_name(name) - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - return True - elif ret == QMessageBox.StandardButton.Discard: - profiles = load_profiles() - for i in range(self.profile_list.count()): - item = self.profile_list.item(i) - name = item.text().lstrip("*") - self._profile_dirty[name] = False - if item.text().startswith("*"): - item.setText(name) - self.update_profile_save_buttons() - self.update_profile_list_colors() - if self.current_profile in profiles: - self.load_profile_state(self.current_profile) - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - return True - else: - return False - - def on_profile_selection_changed(self, row): - # Save dirty state for previous profile - if hasattr(self, "last_profile_row") and self.last_profile_row is not None: - prev_item = self.profile_list.item(self.last_profile_row) - if prev_item: - prev_name = prev_item.text().lstrip("*") - self._profile_dirty[prev_name] = prev_item.text().startswith("*") - # Do NOT auto-save if modifications pending - # load new profile - item = self.profile_list.item(row) - if item: - name = item.text().lstrip("*") - self.load_profile_state(name) - # Restore dirty state for this profile - dirty = self._profile_dirty.get(name, False) - if dirty and not item.text().startswith("*"): - item.setText("*" + item.text()) - elif not dirty and item.text().startswith("*"): - item.setText(item.text().lstrip("*")) - self.last_profile_row = row - self.update_profile_save_buttons() - self.update_profile_list_colors() - - def add_voices(self, initial_state): - first_enabled_voice = None - for voice in VOICES_INTERNAL: - language_code = voice[0] # First character is the language code - matching_voice = next( - (item for item in initial_state if item[0] == voice), None - ) - initial_status = matching_voice is not None - initial_weight = matching_voice[1] if matching_voice else 1.0 - voice_mixer = self.add_voice( - voice, language_code, initial_status, initial_weight - ) - if initial_status and first_enabled_voice is None: - first_enabled_voice = voice_mixer - - if first_enabled_voice: - QTimer.singleShot( - 0, lambda: self.scroll_area.ensureWidgetVisible(first_enabled_voice) - ) - - def add_voice( - self, voice_name, language_code, initial_status=False, initial_weight=1.0 - ): - voice_mixer = VoiceMixer( - voice_name, language_code, initial_status, initial_weight - ) - self.voice_mixers.append(voice_mixer) - self.voice_list_layout.addWidget(voice_mixer) - voice_mixer.checkbox.stateChanged.connect( - lambda state, vm=voice_mixer: self.handle_voice_checkbox(vm, state) - ) - voice_mixer.spin_box.valueChanged.connect(self.update_weighted_sums) - voice_mixer.checkbox.stateChanged.connect(self.update_weighted_sums) - voice_mixer.spin_box.valueChanged.connect(self.mark_profile_modified) - voice_mixer.checkbox.stateChanged.connect( - lambda *_: self.mark_profile_modified() - ) - return voice_mixer - - def handle_voice_checkbox(self, voice_mixer, state): - if state == Qt.CheckState.Checked.value: - self.last_enabled_voice = voice_mixer.voice_name - self.update_weighted_sums() - - def get_selected_voices(self): - return [ - v - for v in (m.get_voice_weight() for m in self.voice_mixers) - if v and v[1] > 0 - ] - - def update_weighted_sums(self): - # Clear previous labels - while self.weighted_sums_layout.count(): - item = self.weighted_sums_layout.takeAt(0) - if item and item.widget(): - item.widget().deleteLater() - - # Get selected voices - selected = [ - (m.voice_name, m.spin_box.value()) - for m in self.voice_mixers - if m.checkbox.isChecked() and m.spin_box.value() > 0 - ] - - total = sum(w for _, w in selected) - # disable Preview if no voices selected, but don't enable while loading - if not getattr(self, "_loading", False): - self.btn_preview_mix.setEnabled(total > 0) - - if total > 0: - self.error_label.hide() - self.weighted_sums_container.show() - - # Reorder so last enabled voice is at the end - if self.last_enabled_voice and any( - name == self.last_enabled_voice for name, _ in selected - ): - others = [(n, w) for n, w in selected if n != self.last_enabled_voice] - last = [(n, w) for n, w in selected if n == self.last_enabled_voice] - selected = others + last - - # Add voice labels - for name, weight in selected: - percentage = weight / total * 100 - # Make the voice name bold and include percentage - voice_label = HoverLabel( - f'{name}: {percentage:.1f}%', - name, - ) - voice_label.setSizePolicy( - QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred - ) - voice_label.delete_button.clicked.connect( - lambda _, vn=name: self.disable_voice_by_name(vn) - ) - self.weighted_sums_layout.addWidget(voice_label) - else: - self.error_label.show() - self.weighted_sums_container.hide() - - def disable_voice_by_name(self, voice_name): - for mixer in self.voice_mixers: - if mixer.voice_name == voice_name: - mixer.checkbox.setChecked(False) - break - - def clear_all_voices(self): - for mixer in self.voice_mixers: - mixer.checkbox.setChecked(False) - - def eventFilter(self, source, event): - if source is self.scroll_area.viewport() and event.type() == event.Type.Wheel: - # Skip if over an enabled slider - if any( - mixer.slider.underMouse() and mixer.slider.isEnabled() - for mixer in self.voice_mixers - ): - return False - - # Horizontal scrolling - horiz_bar = self.scroll_area.horizontalScrollBar() - delta = -120 if event.angleDelta().y() > 0 else 120 - horiz_bar.setValue(horiz_bar.value() + delta) - return True - return super().eventFilter(source, event) - - def load_profile_state(self, profile_name): - name = profile_name.lstrip("*") - profiles = load_profiles() - # load voices and language from state or JSON - if name in self._profile_states: - state = self._profile_states[name] - else: - state = profiles.get(name, {}) - voices = state.get("voices") if isinstance(state, dict) else state - lang = state.get("language") if isinstance(state, dict) else None - # apply language selection - if lang: - i = self.language_combo.findData(lang) - if i >= 0: - self.language_combo.blockSignals(True) - self.language_combo.setCurrentIndex(i) - self.language_combo.blockSignals(False) - self.current_profile = name - weights = {n: w for n, w in voices} - for vm in self.voice_mixers: - weight = weights.get(vm.voice_name, 0.0) - # block signals to avoid triggering updates - vm.checkbox.blockSignals(True) - vm.spin_box.blockSignals(True) - vm.slider.blockSignals(True) - vm.checkbox.setChecked(weight > 0) - val = weight if weight > 0 else 1.0 - vm.spin_box.setValue(val) - vm.slider.setValue(int(val * 100)) - # restore signals - vm.checkbox.blockSignals(False) - vm.spin_box.blockSignals(False) - vm.slider.blockSignals(False) - # sync enabled state - vm.toggle_inputs() - self.update_weighted_sums() - - def save_profile_by_name(self, name): - profiles = load_profiles() - state = self._profile_states.get(name, None) - if state is not None: - # ensure dict format - if isinstance(state, dict): - entry = state - else: - entry = {"voices": state, "language": self.language_combo.currentData()} - profiles[name] = entry - save_profiles(profiles) - self._profile_dirty[name] = False - del self._profile_states[name] - self._virtual_new_profile = False - # Remove * marker - for i in range(self.profile_list.count()): - item = self.profile_list.item(i) - if item.text().lstrip("*") == name: - item.setText(name) - break - self.update_profile_list_colors() - self.update_profile_save_buttons() - self.update_weighted_sums() - - def _handle_zero_weight_profiles(self): - profiles = load_profiles() - if len(profiles) < 1: - return False - zero = [] - for i in range(self.profile_list.count()): - item = self.profile_list.item(i) - name = item.text().lstrip("*") - weights = profiles.get(name, {}).get("voices", []) - total = 0 - if isinstance(weights, list): - for entry in weights: - if ( - isinstance(entry, (list, tuple)) - and len(entry) == 2 - and isinstance(entry[1], (int, float)) - ): - total += entry[1] - if total == 0: - zero.append((i, name)) - if not zero: - return False - msg = f"{len(zero)} invalid profile(s) with no voices selected or their total weights are 0. They will be ignored and deleted. Do you want to delete?" - reply = QMessageBox.question( - self, - "Invalid Profiles", - msg, - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, - QMessageBox.StandardButton.Yes, - ) - if reply == QMessageBox.StandardButton.Yes: - for i, name in reversed(zero): - self.profile_list.takeItem(i) - delete_profile(name) - parent = self.parent() - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - self.update_profile_list_colors() - self.update_profile_save_buttons() - return False - else: - idx, _ = zero[0] - self.profile_list.setCurrentRow(idx) - return True - - def accept(self): - # If no profiles, treat as cancel - if self.profile_list.count() == 0: - # Update subtitle_mode to match combo before closing - if self.subtitle_combo: - parent = self.parent() - if parent is not None: - parent.subtitle_mode = self.subtitle_combo.currentText() - self.reject() - return - # Prompt to save if unsaved changes, then check for zero-weight error after save - if self._has_unsaved_changes(): - if not self._prompt_save_changes(): - return - if self._handle_zero_weight_profiles(): - return - selected_voices = self.get_selected_voices() - total_weight = sum(weight for _, weight in selected_voices) - if total_weight == 0: - QMessageBox.warning( - self, - "Invalid Weights", - "The total weight of selected voices cannot be zero. Please select at least one voice or adjust the weights.", - ) - self.update_weighted_sums() - return - # Save weights to current profile - profiles = load_profiles() - profiles[self.current_profile] = { - "voices": selected_voices, - "language": self.language_combo.currentData(), - } - save_profiles(profiles) - # Mark this profile as not dirty - self._profile_dirty[self.current_profile] = False - super().accept() - - def reject(self): - # Restore parent's profile/mix state on cancel - parent = self.parent() - if parent is not None: - if hasattr(self, "_original_profile_name"): - parent.selected_profile_name = self._original_profile_name - if hasattr(self, "_original_mixed_voice_state"): - parent.mixed_voice_state = self._original_mixed_voice_state - # Prompt to save if unsaved changes, then check for zero-weight error after save - if self._has_unsaved_changes(): - if not self._prompt_save_changes(): - return - if self._handle_zero_weight_profiles(): - return - super().reject() - - def closeEvent(self, event): - # Restore parent's profile/mix state on close - parent = self.parent() - if parent is not None: - if hasattr(self, "_original_profile_name"): - parent.selected_profile_name = self._original_profile_name - if hasattr(self, "_original_mixed_voice_state"): - parent.mixed_voice_state = self._original_mixed_voice_state - # Prompt to save if unsaved changes, then check for zero-weight error after save - if self._has_unsaved_changes(): - if not self._prompt_save_changes(): - event.ignore() - return - if self._handle_zero_weight_profiles(): - event.ignore() - return - super().closeEvent(event) - - def _parse_rgba_to_qcolor(self, rgba_str): - from PyQt6.QtCore import Qt - from PyQt6.QtGui import QColor - - """Helper to convert 'rgba(R,G,B,A_float)' string to QColor.""" - match = re.match(r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)", rgba_str) - if match: - r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3)) - a_float = float(match.group(4)) - a_int = int(a_float * 255) - return QColor(r, g, b, a_int) - return Qt.GlobalColor.transparent - - def mark_profile_modified(self): - item = self.profile_list.currentItem() - if item and not item.text().startswith("*"): - item.setText("*" + item.text()) - # Flag profile as dirty and store unsaved state - name = self.current_profile - self._profile_dirty[name] = True - self._profile_states[name] = { - "voices": self.get_selected_voices(), - "language": self.language_combo.currentData(), - } - self.update_profile_save_buttons() - self.update_profile_list_colors() - - def new_profile(self): - import re - - while True: - name, ok = QInputDialog.getText(self, "New Profile", "Enter profile name:") - if not ok or not name: - break - name = name.strip() # Remove leading/trailing spaces - if not name: - continue - if not re.match(r"^[\w\- ]+$", name): - QMessageBox.warning( - self, - "Invalid Name", - "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.", - ) - continue - profiles = load_profiles() - # Remove 'New profile' placeholder if not persisted in JSON - if ( - self.profile_list.count() == 1 - and self.profile_list.item(0).text() == "New profile" - and "New profile" not in profiles - ): - self.profile_list.takeItem(0) - self._virtual_new_profile = False - self._profile_dirty.pop("New profile", None) - if name in profiles: - QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") - continue - profiles[name] = { - "voices": [], - "language": self.language_combo.currentData(), - } - save_profiles(profiles) - self.profile_list.addItem( - QListWidgetItem( - QIcon(get_resource_path("abogen.assets", "profile.png")), name - ) - ) - self.profile_list.setCurrentRow(self.profile_list.count() - 1) - # reset UI mixers - for vm in self.voice_mixers: - vm.checkbox.setChecked(False) - vm.spin_box.setValue(1.0) - parent = self.parent() - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - break - self.update_profile_save_buttons() - self.update_profile_list_colors() - self.update_weighted_sums() - - def export_all_profiles(self): - # Prevent export if any profile has total weight 0 - profiles = load_profiles() - for name, weights in profiles.items(): - total = 0 - voices = weights.get("voices", []) - if isinstance(voices, list): - for entry in voices: - if ( - isinstance(entry, (list, tuple)) - and len(entry) == 2 - and isinstance(entry[1], (int, float)) - ): - total += entry[1] - if total == 0: - QMessageBox.warning( - self, - "Export Blocked", - f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.", - ) - return - path, _ = QFileDialog.getSaveFileName( - self, "Export Profiles", "voice_profiles", "JSON Files (*.json)" - ) - if path: - export_profiles(path) - - def import_profiles_dialog(self): - path, _ = QFileDialog.getOpenFileName( - self, "Import Profiles", "", "JSON Files (*.json)" - ) - if path: - from abogen.voice_profiles import load_profiles, save_profiles - - # Try to read the file and count profiles - try: - import json - - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - # always expect abogen_voice_profiles wrapper - if not (isinstance(data, dict) and "abogen_voice_profiles" in data): - QMessageBox.warning( - self, - "Invalid File", - "This file is not a valid abogen voice profiles file.", - ) - return - imported_profiles = data["abogen_voice_profiles"] - if not isinstance(imported_profiles, dict): - QMessageBox.warning( - self, - "Invalid File", - "This file is not a valid abogen voice profiles file.", - ) - return - count = len(imported_profiles) - except Exception: - QMessageBox.warning( - self, "Import Error", "Could not read the selected file." - ) - return - if count == 0: - QMessageBox.information( - self, "No Profiles", "No profiles found in the selected file." - ) - return - profiles = load_profiles() - collisions = [name for name in imported_profiles if name in profiles] - # Combine prompts: show both import count and overwrite count if any - if count == 1: - orig_name = next(iter(imported_profiles.keys())) - msg = f"Profile '{orig_name}' will be imported." - if collisions: - msg += f"\nThis will overwrite an existing profile." - msg += "\nContinue?" - reply = QMessageBox.question( - self, - "Import Profile", - msg, - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if reply != QMessageBox.StandardButton.Yes: - return - profiles.update(imported_profiles) - save_profiles(profiles) - QMessageBox.information( - self, - "Profile Imported", - f"Profile '{orig_name}' imported successfully.", - ) - else: - msg = f"{count} profiles will be imported." - if collisions: - msg += f"\n{len(collisions)} profile(s) will be overwritten." - msg += "\nContinue?" - reply = QMessageBox.question( - self, - "Import Profiles", - msg, - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if reply != QMessageBox.StandardButton.Yes: - return - profiles.update(imported_profiles) - save_profiles(profiles) - QMessageBox.information( - self, - "Profiles Imported", - f"{count} profiles imported successfully.", - ) - # Refresh list - self.profile_list.clear() - profiles = load_profiles() - for nm in profiles: - self.profile_list.addItem( - QListWidgetItem( - QIcon(get_resource_path("abogen.assets", "profile.png")), nm - ) - ) - if self.profile_list.count() > 0: - self.profile_list.setCurrentRow(0) - parent = self.parent() - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - self._virtual_new_profile = False - self.update_profile_save_buttons() - self.update_profile_list_colors() - - def show_profile_context_menu(self, pos): - item = self.profile_list.itemAt(pos) - if not item: - return - name = item.text().lstrip("*") - menu = QMenu(self) - rename_act = QAction("Rename", self) - delete_act = QAction("Delete", self) - dup_act = QAction("Duplicate", self) - export_act = QAction("Export this profile", self) - menu.addAction(rename_act) - menu.addAction(dup_act) - menu.addAction(export_act) - menu.addAction(delete_act) - act = menu.exec(self.profile_list.viewport().mapToGlobal(pos)) - if act == rename_act: - self.rename_profile(item) - elif act == delete_act: - self.delete_profile(item) - elif act == dup_act: - self.duplicate_profile(item) - elif act == export_act: - self.export_selected_profile_item(item) - - def export_selected_profile_item(self, item): - if not item: - return - name = item.text().lstrip("*") - profiles = load_profiles() - weights = profiles.get(name, {}).get("voices", []) - total = 0 - if isinstance(weights, list): - for entry in weights: - if ( - isinstance(entry, (list, tuple)) - and len(entry) == 2 - and isinstance(entry[1], (int, float)) - ): - total += entry[1] - if total == 0: - QMessageBox.warning( - self, - "Export Blocked", - f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.", - ) - return - path, _ = QFileDialog.getSaveFileName( - self, "Export Profile", f"{name}.json", "JSON Files (*.json)" - ) - if path: - # Use abogen_voice_profiles wrapper for single profile export - with open(path, "w", encoding="utf-8") as f: - json.dump( - {"abogen_voice_profiles": {name: profiles.get(name, {})}}, - f, - indent=2, - ) - - def rename_profile(self, item): - name = item.text().lstrip("*") - # block if profile has unsaved changes and it's not a virtual New profile - if self._profile_dirty.get(name, False) and not ( - self._virtual_new_profile and name == "New profile" - ): - QMessageBox.warning( - self, "Unsaved Changes", "Please save the profile before renaming." - ) - return - old = item.text().lstrip("*") - import re - - while True: - new, ok = QInputDialog.getText( - self, "Rename Profile", f"Profile name:", text=old - ) - if not ok or not new or new == old: - break - new = new.strip() # Remove leading/trailing spaces - if not new: - continue - if not re.match(r"^[\w\- ]+$", new): - QMessageBox.warning( - self, - "Invalid Name", - "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.", - ) - continue - - profiles = load_profiles() - if new in profiles: - QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") - continue - - # Special case for renaming the virtual "New profile" - if self._virtual_new_profile and name == "New profile": - # Create the profile with the new name - profiles[new] = { - "voices": self.get_selected_voices(), - "language": self.language_combo.currentData(), - } - save_profiles(profiles) - - # Update tracking properties - self._virtual_new_profile = False - self._profile_dirty.pop("New profile", None) - self._profile_dirty[new] = False - - # Update the current profile name - self.current_profile = new - item.setText(new) - else: - # Standard renaming for regular profiles - profiles[new] = profiles.pop(old) - save_profiles(profiles) - item.setText(new) - - # Update the current profile name if it was renamed - if self.current_profile == old: - self.current_profile = new - - parent = self.parent() - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - break - self.update_profile_save_buttons() - self.update_profile_list_colors() - - def delete_profile(self, item): - name = item.text().lstrip("*") - if self._virtual_new_profile and name == "New profile": - row = self.profile_list.row(item) - self.profile_list.takeItem(row) - self._virtual_new_profile = False - self._profile_dirty.pop("New profile", None) - self.update_profile_save_buttons() - self.update_profile_list_colors() - return - reply = QMessageBox.question( - self, - "Delete Profile", - f"Delete profile '{name}'?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if reply == QMessageBox.StandardButton.Yes: - delete_profile(name) - row = self.profile_list.row(item) - self.profile_list.takeItem(row) - parent = self.parent() - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - self.update_profile_save_buttons() - self.update_profile_list_colors() - - def duplicate_profile(self, item): - name = item.text().lstrip("*") - # block duplicating if profile has unsaved changes - if self._profile_dirty.get(name, False): - QMessageBox.warning( - self, "Unsaved Changes", "Please save the profile before duplicating." - ) - return - src = item.text().lstrip("*") - profiles = load_profiles() - base = f"{src}_duplicate" - new = base - i = 1 - while new in profiles: - new = f"{base}{i}" - i += 1 - duplicate_profile(src, new) - self.profile_list.addItem( - QListWidgetItem( - QIcon(get_resource_path("abogen.assets", "profile.png")), new - ) - ) - parent = self.parent() - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - self.update_profile_save_buttons() - self.update_profile_list_colors() - - def update_profile_save_buttons(self): - # Remove all save buttons first - for i in range(self.profile_list.count()): - self.profile_list.setItemWidget(self.profile_list.item(i), None) - # Add save button to dirty profiles - for i in range(self.profile_list.count()): - item = self.profile_list.item(i) - name = item.text().lstrip("*") - if item.text().startswith("*"): - widget = SaveButtonWidget( - self.profile_list, name, self.save_profile_by_name - ) - self.profile_list.setItemWidget(item, widget) - - def update_profile_list_colors(self): - from PyQt6.QtCore import Qt - - profiles = load_profiles() - for i in range(self.profile_list.count()): - item = self.profile_list.item(i) - name = item.text().lstrip("*") - if self._virtual_new_profile and name == "New profile": - color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND")) - item.setData(Qt.ItemDataRole.BackgroundRole, color) - elif item.text().startswith("*"): - color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND")) - item.setData(Qt.ItemDataRole.BackgroundRole, color) - else: - item.setData( - Qt.ItemDataRole.BackgroundRole, - self.profile_list.palette().base().color(), - ) - weights = profiles.get(name, {}).get("voices", []) - total = 0 - if isinstance(weights, list): - for entry in weights: - if ( - isinstance(entry, (list, tuple)) - and len(entry) == 2 - and isinstance(entry[1], (int, float)) - ): - total += entry[1] - if total == 0: - color = self._parse_rgba_to_qcolor(COLORS.get("RED_BACKGROUND")) - item.setData(Qt.ItemDataRole.BackgroundRole, color) - self.update_profile_save_buttons() - - def preview_current_mix(self): - # Disable preview until playback completes - self.btn_preview_mix.setEnabled(False) - self.btn_preview_mix.setText("Loading...") - self._loading = True - parent = self.parent() - if parent and hasattr(parent, "preview_voice"): - # Apply mixed voices and selected language - parent.mixed_voice_state = self.get_selected_voices() - parent.selected_profile_name = None - lang = self.language_combo.currentData() - parent.selected_lang = lang - parent.subtitle_combo.setEnabled( - lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION - ) - # Reset start flag and trigger preview - self._started = False - parent.preview_voice() - # Poll preview_playing: wait for start then end - self._preview_poll_timer = QTimer(self) - self._preview_poll_timer.timeout.connect(self._check_preview_done) - self._preview_poll_timer.start(200) - - def _check_preview_done(self): - parent = self.parent() - if parent and hasattr(parent, "preview_playing"): - # Mark when playback starts - if parent.preview_playing: - self._started = True - # Update button text to "Playing..." when playback starts - self.btn_preview_mix.setText("Playing...") - # Once started and then stopped, re-enable - elif getattr(self, "_started", False): - self.btn_preview_mix.setEnabled(True) - self.btn_preview_mix.setText("Preview") - self._loading = False - self._preview_poll_timer.stop() +__all__ = ["VoiceFormulaDialog"] diff --git a/abogen/voice_formulas.py b/abogen/voice_formulas.py index 813d27c..b91cffe 100644 --- a/abogen/voice_formulas.py +++ b/abogen/voice_formulas.py @@ -1,4 +1,6 @@ import re +from typing import List, Tuple + from abogen.constants import VOICES_INTERNAL @@ -15,38 +17,56 @@ def get_new_voice(pipeline, formula, use_gpu): raise ValueError(f"Failed to create voice: {str(e)}") -# Parse the formula and get the combined voice tensor -def parse_voice_formula(pipeline, formula): - if not formula.strip(): +def parse_formula_terms(formula: str) -> List[Tuple[str, float]]: + if not formula or not formula.strip(): raise ValueError("Empty voice formula") - # Initialize the weighted sum - weighted_sum = None - - total_weight = calculate_sum_from_formula(formula) - - # Split the formula into terms - voices = formula.split("+") - - for term in voices: - # Parse each term (format: "voice_name*0.333") - voice_name, weight = term.strip().split("*") - weight = float(weight.strip()) - # normalize the weight - weight /= total_weight if total_weight > 0 else 1.0 + terms: List[Tuple[str, float]] = [] + for segment in formula.split("+"): + part = segment.strip() + if not part: + continue + if "*" not in part: + raise ValueError("Each component must be in the form voice*weight") + voice_name, raw_weight = part.split("*", 1) voice_name = voice_name.strip() - - # Get the voice tensor if voice_name not in VOICES_INTERNAL: raise ValueError(f"Unknown voice: {voice_name}") + try: + weight = float(raw_weight.strip()) + except ValueError as exc: + raise ValueError(f"Invalid weight for {voice_name}") from exc + if weight <= 0: + raise ValueError(f"Weight for {voice_name} must be positive") + terms.append((voice_name, weight)) + + if not terms: + raise ValueError("Voice weights must sum to a positive value") + + return terms + + +def parse_voice_formula(pipeline, formula): + terms = parse_formula_terms(formula) + + total_weight = sum(weight for _, weight in terms) + if total_weight <= 0: + raise ValueError("Voice weights must sum to a positive value") + + weighted_sum = None + + for voice_name, weight in terms: + normalized_weight = weight / total_weight if total_weight > 0 else weight voice_tensor = pipeline.load_single_voice(voice_name) - # Add to weighted sum if weighted_sum is None: - weighted_sum = weight * voice_tensor + weighted_sum = normalized_weight * voice_tensor else: - weighted_sum += weight * voice_tensor + weighted_sum += normalized_weight * voice_tensor + + if weighted_sum is None: + raise ValueError("Voice formula produced no components") return weighted_sum @@ -55,3 +75,7 @@ def calculate_sum_from_formula(formula): weights = re.findall(r"\* *([\d.]+)", formula) total_sum = sum(float(weight) for weight in weights) return total_sum + + +def extract_voice_ids(formula: str) -> List[str]: + return [voice for voice, _ in parse_formula_terms(formula)] diff --git a/abogen/voice_profiles.py b/abogen/voice_profiles.py index 77a07d0..dede121 100644 --- a/abogen/voice_profiles.py +++ b/abogen/voice_profiles.py @@ -1,5 +1,9 @@ -import os import json +import os +from typing import Any, Dict, Iterable, List, Tuple + +from abogen.constants import VOICES_INTERNAL +from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES from abogen.utils import get_user_config_path @@ -57,3 +61,161 @@ def export_profiles(export_path): profiles = load_profiles() with open(export_path, "w", encoding="utf-8") as f: json.dump({"abogen_voice_profiles": profiles}, f, indent=2) + + +def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]: + """Return profiles in canonical dictionary form.""" + return load_profiles() + + +def _normalize_supertonic_voice(value: Any) -> str: + raw = str(value or "").strip().upper() + return raw if raw in DEFAULT_SUPERTONIC_VOICES else "M1" + + +def _coerce_supertonic_steps(value: Any) -> int: + try: + steps = int(value) + except (TypeError, ValueError): + return 5 + return max(2, min(15, steps)) + + +def _coerce_supertonic_speed(value: Any) -> float: + try: + speed = float(value) + except (TypeError, ValueError): + return 1.0 + return max(0.7, min(2.0, speed)) + + +def normalize_profile_entry(entry: Any) -> Dict[str, Any]: + """Normalize a stored profile entry. + + Backwards compatible: + - Legacy Kokoro-only entries: {language, voices} + - New entries: include provider. + """ + + if not isinstance(entry, dict): + return {} + + provider = str(entry.get("provider") or "kokoro").strip().lower() + if provider not in {"kokoro", "supertonic"}: + provider = "kokoro" + + language = str(entry.get("language") or "a").strip().lower() or "a" + + if provider == "supertonic": + return { + "provider": "supertonic", + "language": language, + "voice": _normalize_supertonic_voice(entry.get("voice") or entry.get("voice_name") or entry.get("name")), + "total_steps": _coerce_supertonic_steps(entry.get("total_steps") or entry.get("supertonic_total_steps") or entry.get("quality")), + "speed": _coerce_supertonic_speed(entry.get("speed") or entry.get("supertonic_speed")), + } + + voices = _normalize_voice_entries(entry.get("voices", [])) + if not voices: + return {} + return { + "provider": "kokoro", + "language": language, + "voices": voices, + } + + +def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: + normalized: List[Tuple[str, float]] = [] + for item in entries or []: + if isinstance(item, dict): + voice = item.get("id") or item.get("voice") + weight = item.get("weight") + elif isinstance(item, (list, tuple)) and len(item) >= 2: + voice, weight = item[0], item[1] + else: + continue + if voice not in VOICES_INTERNAL: + continue + if weight is None: + continue + try: + weight_val = float(weight) + except (TypeError, ValueError): + continue + if weight_val <= 0: + continue + normalized.append((voice, weight_val)) + return normalized + + +def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: + """Public helper to normalize voice-weight pairs from arbitrary payloads.""" + + return _normalize_voice_entries(entries) + + +def save_profile(name: str, *, language: str, voices: Iterable) -> None: + """Persist a single profile after validating its data.""" + + name = (name or "").strip() + if not name: + raise ValueError("Profile name is required") + + normalized = _normalize_voice_entries(voices) + if not normalized: + raise ValueError("At least one voice with a weight above zero is required") + + if not language: + language = "a" + + profiles = load_profiles() + profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized} + save_profiles(profiles) + + +def remove_profile(name: str) -> None: + delete_profile(name) + + +def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]: + """Merge profiles from a dictionary structure and persist them. + + Returns the list of profile names that were added or updated. + """ + + if not isinstance(data, dict): + raise ValueError("Invalid profile payload") + + if "abogen_voice_profiles" in data: + data = data["abogen_voice_profiles"] + + if not isinstance(data, dict): + raise ValueError("Invalid profile payload") + + current = load_profiles() + updated: List[str] = [] + for name, entry in data.items(): + normalized = normalize_profile_entry(entry) + if not normalized: + continue + if name in current and not replace_existing: + # skip duplicates unless explicit replacement is requested + continue + current[name] = normalized + updated.append(name) + + if updated: + save_profiles(current) + return updated + + +def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]: + """Return profiles limited to the provided names for download/export.""" + + profiles = load_profiles() + if names is None: + subset = profiles + else: + subset = {name: profiles[name] for name in names if name in profiles} + return {"abogen_voice_profiles": subset} diff --git a/abogen/webui/Dockerfile b/abogen/webui/Dockerfile new file mode 100644 index 0000000..4c65733 --- /dev/null +++ b/abogen/webui/Dockerfile @@ -0,0 +1,74 @@ +FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + VIRTUAL_ENV=/opt/venv \ + PATH=/opt/venv/bin:$PATH + +ARG TORCH_INDEX_URL=https://download.pytorch.org/whl/cu126 +ARG TORCH_VERSION= +ARG USE_GPU=true + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + python3 \ + python3-venv \ + python3-pip \ + ffmpeg \ + libsndfile1 \ + libgl1 \ + libglib2.0-0 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN python3 -m venv "$VIRTUAL_ENV" + +WORKDIR /app + +COPY pyproject.toml README.md ./ +COPY abogen ./abogen + +RUN pip install --upgrade pip \ + && if [ -n "$TORCH_VERSION" ]; then \ + pip install torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \ + else \ + pip install torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \ + fi \ + && pip install --no-cache-dir . \ + 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 \ + && pip install --no-cache-dir "mutagen>=1.47.0" + +# Install onnxruntime-gpu for CUDA acceleration (supertonic uses ONNX Runtime) +# Set USE_GPU=false to skip this for CPU-only deployments +RUN if [ "$USE_GPU" = "true" ]; then \ + pip install --no-cache-dir onnxruntime-gpu; \ + fi + +ENV ABOGEN_HOST=0.0.0.0 \ + ABOGEN_PORT=8808 + +EXPOSE 8808 + +VOLUME ["/data"] + +ENV ABOGEN_UPLOAD_ROOT=/data/uploads \ + 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 + +# Copy and setup entrypoint script +COPY abogen/webui/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Create non-root user and setup permissions +RUN useradd -m -u 1000 abogen \ + && mkdir -p /data/uploads /data/outputs /data/cache /data/voice-cache /data/huggingface \ + && chown -R abogen:abogen /data /app + +USER abogen + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["abogen-web"] diff --git a/abogen/webui/__init__.py b/abogen/webui/__init__.py new file mode 100644 index 0000000..89f8e6a --- /dev/null +++ b/abogen/webui/__init__.py @@ -0,0 +1,9 @@ +__all__ = ["create_app"] + + +def __getattr__(name: str): + if name == "create_app": + from .app import create_app + + return create_app + raise AttributeError(name) diff --git a/abogen/webui/app.py b/abogen/webui/app.py new file mode 100644 index 0000000..211bfcb --- /dev/null +++ b/abogen/webui/app.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import atexit +import logging +import os +from pathlib import Path +from typing import Any, Optional + +from flask import Flask + +from abogen.utils import get_user_cache_path, get_user_output_path, get_user_settings_dir + +from .conversion_runner import run_conversion_job +from .service import build_service + + +class _SuppressSuccessfulAccessFilter(logging.Filter): + """Filter out successful (HTTP 200) werkzeug access logs.""" + + def filter(self, record: logging.LogRecord) -> bool: # pragma: no cover - small utility + try: + message = record.getMessage() + except Exception: # pragma: no cover - defensive + return True + # Werkzeug access logs include the status code near the end, e.g. + # "GET /path HTTP/1.1" 200 - + # Treat any 2xx response as success to suppress. + return " 200 " not in message and " 201 " not in message and " 204 " not in message + + +_access_log_filter_attached = False + + +def _default_dirs() -> tuple[Path, Path]: + uploads_override = os.environ.get("ABOGEN_UPLOAD_ROOT") + outputs_override = os.environ.get("ABOGEN_OUTPUT_ROOT") + + if uploads_override: + uploads = Path(os.path.expanduser(uploads_override)).resolve() + else: + uploads = Path(get_user_cache_path("web/uploads")) + + if outputs_override: + outputs = Path(os.path.expanduser(outputs_override)).resolve() + else: + outputs = Path(get_user_output_path("web")) + + uploads.mkdir(parents=True, exist_ok=True) + outputs.mkdir(parents=True, exist_ok=True) + return uploads, outputs + + +def _get_secret_key() -> str: + env_key = os.environ.get("ABOGEN_SECRET_KEY") + if env_key: + return env_key + + try: + settings_dir = Path(get_user_settings_dir()) + settings_dir.mkdir(parents=True, exist_ok=True) + secret_file = settings_dir / ".secret_key" + if secret_file.exists(): + return secret_file.read_text(encoding="utf-8").strip() + + key = os.urandom(24).hex() + secret_file.write_text(key, encoding="utf-8") + return key + except Exception: + # Fallback if we can't write to settings dir + return os.urandom(24).hex() + + +def create_app(config: Optional[dict[str, Any]] = None) -> Flask: + uploads_dir, outputs_dir = _default_dirs() + + app = Flask( + __name__, + static_folder="static", + template_folder="templates", + ) + base_config = { + "SECRET_KEY": _get_secret_key(), + "UPLOAD_FOLDER": str(uploads_dir), + "OUTPUT_FOLDER": str(outputs_dir), + "MAX_CONTENT_LENGTH": 1024 * 1024 * 400, # 400 MB uploads + } + if config: + base_config.update(config) + app.config.update(base_config) + + service = build_service( + runner=run_conversion_job, + output_root=Path(app.config["OUTPUT_FOLDER"]), + uploads_root=Path(app.config["UPLOAD_FOLDER"]), + ) + app.extensions["conversion_service"] = service + + from abogen.webui.routes import ( + main_bp, + jobs_bp, + settings_bp, + voices_bp, + entities_bp, + books_bp, + api_bp, + ) + + app.register_blueprint(main_bp) + app.register_blueprint(jobs_bp, url_prefix="/jobs") + app.register_blueprint(settings_bp, url_prefix="/settings") + app.register_blueprint(voices_bp, url_prefix="/voices") + app.register_blueprint(entities_bp, url_prefix="/overrides") + app.register_blueprint(books_bp, url_prefix="/find-books") + app.register_blueprint(api_bp, url_prefix="/api") + + atexit.register(service.shutdown) + + global _access_log_filter_attached + if not _access_log_filter_attached: + logging.getLogger("werkzeug").addFilter(_SuppressSuccessfulAccessFilter()) + _access_log_filter_attached = True + + return app + + +def main() -> None: + app = create_app() + host = os.environ.get("ABOGEN_HOST", "0.0.0.0") + port = int(os.environ.get("ABOGEN_PORT", "8808")) + debug = os.environ.get("ABOGEN_DEBUG", "false").lower() == "true" + app.run(host=host, port=port, debug=debug) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/abogen/webui/conversion_runner.py b/abogen/webui/conversion_runner.py new file mode 100644 index 0000000..f41e624 --- /dev/null +++ b/abogen/webui/conversion_runner.py @@ -0,0 +1,2717 @@ +from __future__ import annotations + +import json +import math +import os +import re +import subprocess +import sys +import tempfile +import traceback +import gc +from datetime import datetime +from collections import defaultdict +from contextlib import ExitStack +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Set, cast + +import numpy as np +import soundfile as sf +import static_ffmpeg + +from abogen.constants import VOICES_INTERNAL +from abogen.epub3.exporter import build_epub3_package +from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline, HAS_NUM2WORDS +from abogen.normalization_settings import ( + build_apostrophe_config, + build_llm_configuration, + get_runtime_settings, + apply_overrides as apply_normalization_overrides, +) +from abogen.entity_analysis import normalize_token as normalize_entity_token +from abogen.entity_analysis import normalize_manual_override_token +from abogen.text_extractor import ExtractedChapter, extract_from_path +from abogen.utils import ( + calculate_text_length, + create_process, + get_internal_cache_path, + get_user_cache_path, + get_user_output_path, + load_config, + load_numpy_kpipeline, +) +from abogen.voice_cache import ensure_voice_assets +from abogen.voice_formulas import extract_voice_ids, get_new_voice +from abogen.voice_profiles import load_profiles, normalize_profile_entry +from abogen.pronunciation_store import increment_usage +from abogen.llm_client import LLMClientError +from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES, SupertonicPipeline + +from .service import Job, JobStatus + + +SPLIT_PATTERN = r"\n+" +SAMPLE_RATE = 24000 + + +def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str: + raw = str(spec or "").strip() + fallback_raw = str(fallback or "").strip() + + # SuperTonic voices are discrete IDs (M1/F3/...). If we see a Kokoro mix + # formula (contains '*' or '+'), ignore it and fall back to a safe voice. + if not raw or "*" in raw or "+" in raw: + raw = fallback_raw + if not raw or "*" in raw or "+" in raw: + raw = "M1" + + upper = raw.upper() + if upper in DEFAULT_SUPERTONIC_VOICES: + return upper + + fallback_upper = fallback_raw.upper() if fallback_raw else "" + if fallback_upper in DEFAULT_SUPERTONIC_VOICES: + return fallback_upper + + return "M1" + + +def _split_speaker_reference(value: Any) -> tuple[Optional[str], str]: + raw = str(value or "").strip() + if not raw or ":" not in raw: + return None, raw + prefix, remainder = raw.split(":", 1) + prefix = prefix.strip().lower() + if prefix not in {"speaker", "profile"}: + return None, raw + name = remainder.strip() + return (name or None), raw + + +def _formula_from_kokoro_entry(entry: Mapping[str, Any]) -> str: + voices = entry.get("voices") or [] + if not voices: + return "" + total = 0.0 + parts: list[tuple[str, float]] = [] + for item in voices: + if not isinstance(item, (list, tuple)) or len(item) < 2: + continue + name = str(item[0] or "").strip() + try: + weight = float(item[1]) + except (TypeError, ValueError): + continue + if not name or weight <= 0: + continue + parts.append((name, weight)) + total += weight + if total <= 0 or not parts: + return "" + + def _format_weight(value: float) -> str: + normalized = value / total if total else 0.0 + return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0" + + return "+".join(f"{name}*{_format_weight(weight)}" for name, weight in parts) + + +def _infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str: + raw = str(value or "").strip() + if not raw: + return fallback + upper = raw.upper() + if upper in DEFAULT_SUPERTONIC_VOICES: + return "supertonic" + if "*" in raw or "+" in raw: + return "kokoro" + return fallback + + +class _JobCancelled(Exception): + """Raised internally to abort a conversion when the client cancels.""" + + +@dataclass +class AudioSink: + write: Callable[[np.ndarray], None] + + +def _coerce_truthy(value: Any, default: bool = True) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"true", "1", "yes", "on"}: + return True + if lowered in {"false", "0", "no", "off"}: + return False + return default + if value is None: + return default + return bool(value) + + +_HEADING_SANITIZE_RE = re.compile(r"[^a-z0-9]+") +_HEADING_NUMBER_PREFIX_RE = re.compile(r"^\s*(?P(?:\d+|[ivxlcdm]+))(?P(?:[\s.:;-].*)?)$", re.IGNORECASE) +_ACRONYM_ALLOWLIST = { + "AI", + "API", + "CPU", + "DIY", + "GPU", + "HTML", + "HTTP", + "HTTPS", + "ID", + "JSON", + "MP3", + "MP4", + "M4B", + "NASA", + "OCR", + "PDF", + "SQL", + "TV", + "TTS", + "UK", + "UN", + "UFO", + "OK", + "URL", + "USA", + "US", + "VR", +} +_ROMAN_NUMERAL_CHARS = frozenset("IVXLCDM") +_CAPS_WORD_RE = re.compile(r"[A-Z][A-Z0-9'\u2019-]*") +_OUTPUT_SANITIZE_RE = re.compile(r"[^\w\-_.]+") + + +def _simplify_heading_text(text: str) -> str: + raw = str(text or "").strip().lower() + if not raw: + return "" + simplified = _HEADING_SANITIZE_RE.sub("", raw) + if simplified.startswith("chapter"): + simplified = simplified[7:] + return simplified + + +def _headings_equivalent(left: str, right: str) -> bool: + simple_left = _simplify_heading_text(left) + simple_right = _simplify_heading_text(right) + if not simple_left or not simple_right: + return False + + # Exact match + if simple_left == simple_right: + return True + + # Check if one is a prefix of the other (e.g. "Chapter 2" vs "Chapter 2: The Return") + # But be careful not to match "Chapter 1" with "Chapter 10" + # _simplify_heading_text removes "chapter" prefix, so we are comparing "2" vs "2thereturn" + + # If left is "2" and right is "2thereturn", left is prefix of right. + if simple_right.startswith(simple_left): + return True + + # If left is "2thereturn" and right is "2", right is prefix of left. + if simple_left.startswith(simple_right): + return True + + # Also check if the line is contained in the heading if it's long enough + if len(simple_left) > 5 and simple_left in simple_right: + return True + + return False + + +def _format_spoken_chapter_title(title: str, index: int, apply_prefix: bool) -> str: + base = str(title or "").strip() + if not base: + return f"Chapter {index}" if apply_prefix else "" + if not apply_prefix: + return base + lowered = base.lower() + if lowered.startswith("chapter") and (len(lowered) == 7 or not lowered[7].isalpha()): + return base + match = _HEADING_NUMBER_PREFIX_RE.match(base) + if match: + number = match.group("number") or "" + suffix = match.group("suffix") or "" + cleaned_suffix = suffix.lstrip(" .,:;-_\t\u2013\u2014\u00b7\u2022") + if cleaned_suffix: + return f"Chapter {number}. {cleaned_suffix}" + return f"Chapter {number}" + return base + + +def _strip_duplicate_heading_line(text: str, heading: str) -> tuple[str, bool]: + source_text = str(text or "") + if not source_text: + return source_text, False + normalized_heading = _simplify_heading_text(heading) + if not normalized_heading: + return source_text, False + lines = source_text.splitlines() + new_lines: List[str] = [] + removed = False + for line in lines: + stripped = line.strip() + if not removed and stripped: + if _headings_equivalent(stripped, heading): + removed = True + continue + new_lines.append(line) + if not removed: + return source_text, False + while new_lines and not new_lines[0].strip(): + new_lines.pop(0) + return "\n".join(new_lines), True + + +def _normalize_caps_word(word: str) -> str: + upper = word.upper() + letters = [char for char in upper if char.isalpha()] + if not letters: + return word + if upper in _ACRONYM_ALLOWLIST: + return word + if len(letters) <= 1: + return word + if all(char in _ROMAN_NUMERAL_CHARS for char in letters) and len(letters) <= 7: + return word + + parts = re.split(r"(['\-\u2019])", word) + normalized_parts: List[str] = [] + for part in parts: + if part in {"'", "-", "\u2019"}: + normalized_parts.append(part) + continue + if not part: + continue + normalized_parts.append(part[0].upper() + part[1:].lower()) + return "".join(normalized_parts) or word + + +def _normalize_chapter_opening_caps(text: str) -> tuple[str, bool]: + if not text: + return text, False + + leading_len = len(text) - len(text.lstrip()) + leading = text[:leading_len] + working = text[leading_len:] + if not working: + return text, False + + builder: List[str] = [] + pos = 0 + changed = False + + while pos < len(working): + char = working[pos] + if char in "\r\n": + builder.append(working[pos:]) + pos = len(working) + break + if char.isspace(): + builder.append(char) + pos += 1 + continue + if char.islower(): + builder.append(working[pos:]) + pos = len(working) + break + if not char.isalpha(): + builder.append(char) + pos += 1 + continue + + match = _CAPS_WORD_RE.match(working, pos) + if not match: + builder.append(char) + pos += 1 + continue + + word = match.group(0) + if any(ch.islower() for ch in word): + builder.append(working[pos:]) + pos = len(working) + break + + normalized = _normalize_caps_word(word) + if normalized != word: + changed = True + builder.append(normalized) + pos = match.end() + + if pos < len(working): + builder.append(working[pos:]) + + if not changed: + return text, False + + return leading + "".join(builder), True + +def _normalize_metadata_map(values: Optional[Mapping[str, Any]]) -> Dict[str, str]: + normalized: Dict[str, str] = {} + if not values: + return normalized + for key, value in values.items(): + if value is None: + continue + text = str(value).strip() + if not text: + continue + normalized[str(key).casefold()] = text + return normalized + + +def _format_author_sentence(raw: Optional[str]) -> str: + if raw is None: + return "" + normalized = str(raw).strip() + if not normalized: + return "" + lowered = normalized.casefold() + if lowered in {"unknown", "various"}: + return "" + + working = normalized.replace("&", " and ") + segments = [segment.strip() for segment in working.split(",") if segment.strip()] + tokens: List[str] = [] + + if segments: + for segment in segments: + parts = [part.strip() for part in re.split(r"\band\b", segment, flags=re.IGNORECASE) if part.strip()] + if parts: + tokens.extend(parts) + else: + tokens.append(segment) + else: + parts = [part.strip() for part in re.split(r"\band\b", working, flags=re.IGNORECASE) if part.strip()] + tokens.extend(parts or [normalized]) + + cleaned = [token for token in tokens if token and token.casefold() not in {"unknown", "various"}] + if not cleaned: + return "" + if len(cleaned) == 1: + return f"By {cleaned[0]}" + if len(cleaned) == 2: + return f"By {cleaned[0]} and {cleaned[1]}" + return f"By {', '.join(cleaned[:-1])}, and {cleaned[-1]}" + + +def _ensure_sentence(text: str) -> str: + cleaned = text.strip() + if not cleaned: + return "" + if cleaned[-1] in ".!?": + return cleaned + return f"{cleaned}." + + +_SERIES_NAME_KEYS = ( + "series", + "series_name", + "series_title", +) +_SERIES_NUMBER_KEYS = ( + "series_index", + "series_position", + "series_sequence", + "book_number", + "series_number", +) +_SERIES_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?") + + +def _normalize_series_number(value: Any) -> Optional[str]: + text = str(value or "").strip() + if not text: + return None + candidate = text.replace(",", ".") + if candidate.replace(".", "", 1).isdigit(): + if "." in candidate: + normalized = candidate.rstrip("0").rstrip(".") + return normalized or "0" + try: + return str(int(candidate)) + except ValueError: + pass + match = _SERIES_NUMBER_RE.search(candidate) + if not match: + return None + normalized = match.group(0) + if "." in normalized: + normalized = normalized.rstrip("0").rstrip(".") + return normalized or "0" + try: + return str(int(normalized)) + except ValueError: + return normalized + + +def _extract_series_metadata(values: Mapping[str, str]) -> tuple[Optional[str], Optional[str]]: + series_name: Optional[str] = None + for key in _SERIES_NAME_KEYS: + raw = values.get(key) + if raw: + cleaned = str(raw).strip() + if cleaned: + series_name = cleaned + break + + series_number: Optional[str] = None + for key in _SERIES_NUMBER_KEYS: + raw = values.get(key) + if raw is None: + continue + normalized = _normalize_series_number(raw) + if normalized: + series_number = normalized + break + + return series_name, series_number + + +def _format_series_sentence(series_name: Optional[str], series_number: Optional[str]) -> str: + if not series_name or not series_number: + return "" + name = series_name.strip() + number = series_number.strip() + if not name or not number: + return "" + article = "the " if not name.lower().startswith("the ") else "" + phrase = f"Book {number} of {article}{name}" + return re.sub(r"\s+", " ", phrase).strip() + + +def _build_title_intro_text( + metadata: Optional[Mapping[str, Any]], + fallback_basename: str, +) -> str: + normalized = _normalize_metadata_map(metadata) + fallback_title = Path(fallback_basename).stem if fallback_basename else "" + title = normalized.get("title") or normalized.get("book_title") or normalized.get("album") or fallback_title + if not title: + title = fallback_title + subtitle = normalized.get("subtitle") or normalized.get("sub_title") + if subtitle and title and subtitle.casefold() == title.casefold(): + subtitle = "" + author_value = "" + for candidate in ("artist", "album_artist", "author", "authors", "writer", "composer"): + value = normalized.get(candidate) + if value: + author_value = value + break + + series_name, series_number = _extract_series_metadata(normalized) + series_sentence = _format_series_sentence(series_name, series_number) + + sentences: List[str] = [] + if series_sentence: + sentences.append(_ensure_sentence(series_sentence)) + if title: + sentences.append(_ensure_sentence(title)) + if subtitle: + sentences.append(_ensure_sentence(subtitle)) + author_sentence = _format_author_sentence(author_value) + if author_sentence: + sentences.append(_ensure_sentence(author_sentence)) + return " ".join(sentences).strip() + + +def _build_outro_text( + metadata: Optional[Mapping[str, Any]], + fallback_basename: str, +) -> str: + normalized = _normalize_metadata_map(metadata) + fallback_title = Path(fallback_basename).stem if fallback_basename else "" + title = ( + normalized.get("title") + or normalized.get("book_title") + or normalized.get("album") + or fallback_title + ) + author_value = "" + for candidate in ("authors", "author", "album_artist", "artist", "writer", "composer"): + value = normalized.get(candidate) + if value: + author_value = value + break + author_sentence = _format_author_sentence(author_value) + authors_fragment = author_sentence[3:].strip() if author_sentence.lower().startswith("by ") else author_sentence.strip() + + if title and authors_fragment: + closing_line = f"The end of {title} from {authors_fragment}" + elif title: + closing_line = f"The end of {title}" + elif authors_fragment: + closing_line = f"The end from {authors_fragment}" + else: + closing_line = "The end" + + series_name, series_number = _extract_series_metadata(normalized) + series_sentence = _format_series_sentence(series_name, series_number) + + sentences: List[str] = [_ensure_sentence(closing_line)] + if series_sentence: + sentences.append(_ensure_sentence(series_sentence)) + + return " ".join(sentence for sentence in sentences if sentence).strip() + + +def _spec_to_voice_ids(spec: Any) -> Set[str]: + text = str(spec or "").strip() + if not text: + return set() + if text == "__custom_mix": + return set() + if "*" in text: + try: + return set(extract_voice_ids(text)) + except ValueError: + return set() + if text in VOICES_INTERNAL: + return {text} + return set() + + +def _job_voice_fallback(job: Any) -> str: + base = str(getattr(job, "voice", "") or "").strip() + if base and base != "__custom_mix": + return base + + speakers = getattr(job, "speakers", None) + if isinstance(speakers, dict): + narrator = speakers.get("narrator") + if isinstance(narrator, dict): + for key in ("resolved_voice", "voice_formula", "voice"): + value = narrator.get(key) + candidate = str(value or "").strip() + if candidate and candidate != "__custom_mix": + return candidate + for payload in speakers.values() or []: + if not isinstance(payload, dict): + continue + for key in ("resolved_voice", "voice_formula", "voice"): + value = payload.get(key) + candidate = str(value or "").strip() + if candidate and candidate != "__custom_mix": + return candidate + + for chapter in getattr(job, "chapters", []) or []: + if not isinstance(chapter, dict): + continue + for key in ("resolved_voice", "voice_formula", "voice"): + candidate = str(chapter.get(key) or "").strip() + if candidate and candidate != "__custom_mix": + return candidate + + return "" + + +def _collect_required_voice_ids(job: Job) -> Set[str]: + voices: Set[str] = set() + voices.update(_spec_to_voice_ids(job.voice)) + voices.update(_spec_to_voice_ids(_job_voice_fallback(job))) + + for chapter in getattr(job, "chapters", []) or []: + if not isinstance(chapter, dict): + continue + for key in ("resolved_voice", "voice_formula", "voice"): + voices.update(_spec_to_voice_ids(chapter.get(key))) + + for chunk in getattr(job, "chunks", []) or []: + if not isinstance(chunk, dict): + continue + for key in ("resolved_voice", "voice_formula", "voice"): + voices.update(_spec_to_voice_ids(chunk.get(key))) + + speakers = getattr(job, "speakers", {}) + if isinstance(speakers, dict): + for payload in speakers.values() or []: + if not isinstance(payload, dict): + continue + for key in ("resolved_voice", "voice_formula", "voice"): + voices.update(_spec_to_voice_ids(payload.get(key))) + + voices.update(VOICES_INTERNAL) + return voices + + +def _initialize_voice_cache(job: Job) -> None: + try: + targets = _collect_required_voice_ids(job) + downloaded, errors = ensure_voice_assets( + targets, + on_progress=lambda message: job.add_log(message, level="debug"), + ) + except RuntimeError as exc: + job.add_log(f"Voice cache unavailable: {exc}", level="warning") + return + + if downloaded: + job.add_log( + f"Cached {len(downloaded)} voice asset{'s' if len(downloaded) != 1 else ''} locally.", + level="info", + ) + + for voice_id, error in errors.items(): + job.add_log(f"Failed to cache voice '{voice_id}': {error}", level="warning") + + +_SIGNIFICANT_LENGTH_THRESHOLDS: Dict[str, int] = {"epub": 1000, "markdown": 500} +_MIN_SHORT_CONTENT: Dict[str, int] = {"epub": 240, "markdown": 160} +_STRUCTURAL_KEYWORDS = ( + "preface", + "prologue", + "introduction", + "foreword", + "epilogue", + "afterword", + "appendix", + "acknowledgment", + "acknowledgement", +) +_STRUCTURAL_MIN_LENGTH = 120 +_MAX_SHORT_CHAPTERS = 2 + + +def _infer_file_type(path: Path) -> str: + suffix = path.suffix.lower() + if suffix == ".epub": + return "epub" + if suffix in {".md", ".markdown"}: + return "markdown" + if suffix == ".pdf": + return "pdf" + if suffix == ".txt": + return "text" + return suffix.lstrip(".") or "text" + + +def _looks_structural(title: str) -> bool: + lowered = title.strip().lower() + if not lowered: + return False + return any(keyword in lowered for keyword in _STRUCTURAL_KEYWORDS) + + +def _auto_select_relevant_chapters( + chapters: List[ExtractedChapter], + file_type: str, +) -> tuple[List[ExtractedChapter], List[tuple[str, int]]]: + if not chapters: + return [], [] + + normalized = file_type.lower() + threshold = _SIGNIFICANT_LENGTH_THRESHOLDS.get(normalized, 0) + min_short = _MIN_SHORT_CONTENT.get(normalized, 0) + + kept: List[ExtractedChapter] = [] + skipped: List[tuple[str, int]] = [] + short_kept = 0 + + for chapter in chapters: + stripped = chapter.text.strip() + length = len(stripped) + if length == 0: + skipped.append((chapter.title, length)) + continue + + keep = False + if threshold == 0: + keep = True + elif length >= threshold: + keep = True + elif not kept: + keep = True + elif min_short and length >= min_short and short_kept < _MAX_SHORT_CHAPTERS: + keep = True + short_kept += 1 + elif _looks_structural(chapter.title) and length >= _STRUCTURAL_MIN_LENGTH: + keep = True + + if keep: + kept.append(chapter) + else: + skipped.append((chapter.title, length)) + + if kept: + return kept, skipped + + # Fallback: retain the longest non-empty chapter so conversion can proceed. + longest_idx = None + longest_length = 0 + for idx, chapter in enumerate(chapters): + stripped_length = len(chapter.text.strip()) + if stripped_length > longest_length: + longest_length = stripped_length + longest_idx = idx + + if longest_idx is None or longest_length == 0: + return [], [] + + fallback_chapter = chapters[longest_idx] + kept = [fallback_chapter] + skipped = [ + (chapter.title, len(chapter.text.strip())) + for idx, chapter in enumerate(chapters) + if idx != longest_idx and chapter.text.strip() + ] + return kept, skipped + + +def _chapter_label(file_type: str) -> str: + return "chapters" if file_type.lower() in {"epub", "markdown"} else "pages" + + +def _update_metadata_for_chapter_count(metadata: Dict[str, Any], count: int, file_type: str) -> None: + if not metadata or count <= 0: + return + + label = "Chapters" if file_type.lower() in {"epub", "markdown"} else "Pages" + metadata["chapter_count"] = str(count) + + pattern = re.compile(r"\(\d+\s+(Chapters?|Pages?)\)") + replacement = f"({count} {label})" + for key in ("album", "ALBUM"): + value = metadata.get(key) + if not isinstance(value, str): + continue + metadata[key] = pattern.sub(replacement, value) + + +def _apply_chapter_overrides( + extracted: List[ExtractedChapter], + overrides: List[Dict[str, Any]], +) -> tuple[List[ExtractedChapter], Dict[str, str], List[str]]: + if not overrides: + return [], {}, [] + + selected: List[ExtractedChapter] = [] + metadata_updates: Dict[str, str] = {} + diagnostics: List[str] = [] + + for position, payload in enumerate(overrides): + if not isinstance(payload, dict): + diagnostics.append( + f"Skipped chapter override at position {position + 1}: unsupported payload type {type(payload).__name__}." + ) + continue + + enabled = _coerce_truthy(payload.get("enabled", True)) + payload["enabled"] = enabled + if not enabled: + continue + + metadata_payload = payload.get("metadata") or {} + if isinstance(metadata_payload, dict): + for key, value in metadata_payload.items(): + if value is None: + continue + metadata_updates[str(key)] = str(value) + + base: Optional[ExtractedChapter] = None + idx_candidate = payload.get("index") + idx_normalized: Optional[int] = None + if isinstance(idx_candidate, int): + idx_normalized = idx_candidate + elif isinstance(idx_candidate, str): + try: + idx_normalized = int(idx_candidate) + except ValueError: + idx_normalized = None + if idx_normalized is not None and 0 <= idx_normalized < len(extracted): + base = extracted[idx_normalized] + payload["index"] = idx_normalized + + if base is None: + source_title = payload.get("source_title") + if isinstance(source_title, str): + base = next((chapter for chapter in extracted if chapter.title == source_title), None) + + if base is None: + candidate_title = payload.get("title") + if isinstance(candidate_title, str): + base = next((chapter for chapter in extracted if chapter.title == candidate_title), None) + + text_override = payload.get("text") + if text_override is not None: + text_value = str(text_override) + elif base is not None: + text_value = base.text + else: + diagnostics.append( + f"Skipped chapter override at position {position + 1}: no text provided and no matching source chapter found." + ) + continue + + title_override = payload.get("title") + if title_override is not None: + title_value = str(title_override) + elif base is not None: + title_value = base.title + else: + title_value = f"Chapter {position + 1}" + + if base and not payload.get("source_title"): + payload["source_title"] = base.title + + payload["title"] = title_value + payload["text"] = text_value + payload["characters"] = len(text_value) + payload.setdefault("order", payload.get("order", position)) + + selected.append(ExtractedChapter(title=title_value, text=text_value)) + + return selected, metadata_updates, diagnostics + + +def _merge_metadata( + extracted: Optional[Dict[str, str]], + overrides: Dict[str, Any], +) -> Dict[str, str]: + merged: Dict[str, str] = {} + if extracted: + for key, value in extracted.items(): + if value is None: + continue + merged[str(key)] = str(value) + for key, value in (overrides or {}).items(): + key_str = str(key) + if value is None: + merged.pop(key_str, None) + else: + merged[key_str] = str(value) + return merged + + +_APOSTROPHE_CONFIG = ApostropheConfig() + + +def _normalize_for_pipeline( + text: str, + *, + normalization_overrides: Optional[Mapping[str, Any]] = None, +) -> str: + """Normalize text for tests or utilities with optional overrides.""" + + runtime_settings = get_runtime_settings() + if normalization_overrides: + runtime_settings = apply_normalization_overrides(runtime_settings, normalization_overrides) + apostrophe_config = build_apostrophe_config(settings=runtime_settings, base=_APOSTROPHE_CONFIG) + return normalize_for_pipeline(text, config=apostrophe_config, settings=runtime_settings) + + +def _merge_pronunciation_overrides(job: Any) -> List[Dict[str, Any]]: + """Return pronunciation override entries, ensuring manual overrides are included. + + Pending jobs keep both `manual_overrides` and `pronunciation_overrides`, but the + latter can be stale if the UI didn't resync before enqueue. During conversion, + we must merge manual overrides so they always apply (before TTS). + + Precedence: manual overrides win over existing entries for the same normalized key. + """ + + collected: Dict[str, Dict[str, Any]] = {} + + existing = getattr(job, "pronunciation_overrides", None) + if isinstance(existing, list): + for entry in existing: + if not isinstance(entry, Mapping): + continue + token_value = str(entry.get("token") or "").strip() + pronunciation_value = str(entry.get("pronunciation") or "").strip() + if not token_value or not pronunciation_value: + continue + normalized = str(entry.get("normalized") or "").strip() or normalize_entity_token(token_value) + if not normalized: + continue + collected[normalized] = { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": str(entry.get("voice") or "").strip() or None, + "notes": str(entry.get("notes") or "").strip() or None, + "context": str(entry.get("context") or "").strip() or None, + "source": str(entry.get("source") or "pronunciation"), + "language": getattr(job, "language", None), + } + + # Speaker pronunciation entries (optional), mirrored from the pending-job collector. + speakers = getattr(job, "speakers", None) + if isinstance(speakers, dict): + for payload in speakers.values(): + if not isinstance(payload, Mapping): + continue + token_value = str(payload.get("token") or "").strip() + pronunciation_value = str(payload.get("pronunciation") or "").strip() + if not token_value or not pronunciation_value: + continue + normalized = normalize_entity_token(token_value) + if not normalized: + continue + collected[normalized] = { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": str( + payload.get("resolved_voice") + or payload.get("voice") + or getattr(job, "voice", "") + ).strip() + or None, + "notes": None, + "context": None, + "source": "speaker", + "language": getattr(job, "language", None), + } + + # Manual overrides should take precedence. + manual = getattr(job, "manual_overrides", None) + if isinstance(manual, list): + for entry in manual: + if not isinstance(entry, Mapping): + continue + token_value = str(entry.get("token") or "").strip() + pronunciation_value = str(entry.get("pronunciation") or "").strip() + if not token_value or not pronunciation_value: + continue + normalized = str(entry.get("normalized") or "").strip() or normalize_manual_override_token(token_value) + if not normalized: + continue + collected[normalized] = { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": str(entry.get("voice") or "").strip() or None, + "notes": str(entry.get("notes") or "").strip() or None, + "context": str(entry.get("context") or "").strip() or None, + "source": str(entry.get("source") or "manual"), + "language": getattr(job, "language", None), + } + + return list(collected.values()) + + +def _compile_pronunciation_rules( + overrides: Optional[Iterable[Mapping[str, Any]]], +) -> List[Dict[str, Any]]: + if not overrides: + return [] + + candidates: List[Dict[str, Any]] = [] + seen: set[str] = set() + + for entry in overrides: + if not isinstance(entry, Mapping): + continue + pronunciation_value = str(entry.get("pronunciation") or "").strip() + if not pronunciation_value: + continue + + token_values: List[str] = [] + token_raw = entry.get("token") + if token_raw: + token_value = str(token_raw).strip() + if token_value: + token_values.append(token_value) + normalized_raw = entry.get("normalized") + if normalized_raw: + normalized_value = str(normalized_raw).strip() + if normalized_value: + token_values.append(normalized_value) + if token_raw and not token_values: + fallback = normalize_entity_token(str(token_raw)) + if fallback: + token_values.append(fallback) + + if not token_values: + continue + + usage_normalized = str(entry.get("normalized") or "").strip() + if not usage_normalized and token_values: + usage_normalized = normalize_entity_token(token_values[0]) or token_values[0] + usage_token = str(entry.get("token") or token_values[0]) + + for token_value in token_values: + key = token_value.casefold() + if key in seen: + continue + seen.add(key) + candidates.append( + { + "token": token_value, + "normalized": usage_normalized, + "replacement": pronunciation_value, + } + ) + + if not candidates: + return [] + + candidates.sort(key=lambda item: len(item["token"]), reverse=True) + compiled: List[Dict[str, Any]] = [] + for candidate in candidates: + token_value = candidate["token"] + pronunciation_value = candidate["replacement"] + escaped = re.escape(token_value) + pattern = re.compile(rf"(?i)(?'s|\u2019s|\u2019)?(?!\w)") + compiled.append( + { + "pattern": pattern, + "replacement": pronunciation_value, + "normalized": candidate.get("normalized") or token_value, + "token": candidate.get("token") or token_value, + } + ) + + return compiled + + +def _compile_heteronym_sentence_rules( + overrides: Optional[Iterable[Mapping[str, Any]]], +) -> List[Dict[str, Any]]: + """Compile sentence-level replacements for heteronym disambiguation. + + These are intentionally scoped to a specific sentence string rather than a token, + so we can apply different pronunciations for the same word in different contexts. + + Expected override entry shape (from pending/job): + - sentence: original sentence text + - choice: selected option key + - options: [{key, replacement_sentence, ...}] + """ + if not overrides: + return [] + + compiled: List[Dict[str, Any]] = [] + seen: set[str] = set() + + for entry in overrides: + if not isinstance(entry, Mapping): + continue + sentence = str(entry.get("sentence") or "").strip() + if not sentence: + continue + choice = str(entry.get("choice") or "").strip() + if not choice: + continue + + replacement_sentence = "" + options = entry.get("options") + if isinstance(options, list): + for opt in options: + if not isinstance(opt, Mapping): + continue + if str(opt.get("key") or "").strip() == choice: + replacement_sentence = str(opt.get("replacement_sentence") or "").strip() + break + if not replacement_sentence: + continue + + rule_key = f"{sentence}\n{choice}".casefold() + if rule_key in seen: + continue + seen.add(rule_key) + + parts = [p for p in re.split(r"\s+", sentence) if p] + if not parts: + continue + pattern_text = r"\s+".join(re.escape(p) for p in parts) + pattern = re.compile(pattern_text) + compiled.append({"pattern": pattern, "replacement": replacement_sentence}) + + # Replace longer sentences first to avoid partial matches. + compiled.sort(key=lambda item: len(item["pattern"].pattern), reverse=True) + return compiled + + +def _apply_heteronym_sentence_rules(text: str, rules: List[Dict[str, Any]]) -> str: + if not text or not rules: + return text + result = text + for rule in rules: + pattern = rule["pattern"] + replacement = rule["replacement"] + result = pattern.sub(replacement, result) + return result + + +def _apply_pronunciation_rules( + text: str, + rules: List[Dict[str, Any]], + usage_counter: Optional[Dict[str, int]] = None, +) -> str: + if not text or not rules: + return text + + result = text + for rule in rules: + pattern = rule["pattern"] + pronunciation_value = rule["replacement"] + usage_key = str(rule.get("normalized") or "").strip() + + def _replacement(match: re.Match[str]) -> str: + suffix = match.group("possessive") or "" + if usage_counter is not None and usage_key: + usage_counter[usage_key] = usage_counter.get(usage_key, 0) + 1 + return pronunciation_value + suffix + + result = pattern.sub(_replacement, result) + + return result + + +def _chapter_voice_spec(job: Job, override: Optional[Dict[str, Any]]) -> str: + if not override: + return _job_voice_fallback(job) + + resolved = str(override.get("resolved_voice", "")).strip() + if resolved: + return resolved + + formula = str(override.get("voice_formula", "")).strip() + if formula: + return formula + + voice = str(override.get("voice", "")).strip() + if voice: + return voice + + return _job_voice_fallback(job) + + +def _chunk_voice_spec(job: Any, chunk: Dict[str, Any], fallback: str) -> str: + for key in ("resolved_voice", "voice_formula", "voice"): + value = chunk.get(key) + if value: + return str(value) + + speaker_id = chunk.get("speaker_id") + speakers = getattr(job, "speakers", None) + if isinstance(speakers, dict) and speaker_id in speakers: + speaker_entry = speakers.get(speaker_id) or {} + if isinstance(speaker_entry, dict): + for key in ("resolved_voice", "voice_formula", "voice"): + value = speaker_entry.get(key) + if value: + return str(value) + profile_formula = speaker_entry.get("voice_formula") + if profile_formula: + return str(profile_formula) + + profile_name = chunk.get("voice_profile") + if profile_name: + if isinstance(speakers, dict): + speaker_entry = speakers.get(profile_name) + if isinstance(speaker_entry, dict): + for key in ("resolved_voice", "voice_formula", "voice"): + value = speaker_entry.get(key) + if value: + return str(value) + + if fallback: + return fallback + return _job_voice_fallback(job) + + +def _group_chunks_by_chapter(chunks: Iterable[Dict[str, Any]]) -> Dict[int, List[Dict[str, Any]]]: + grouped: Dict[int, List[Dict[str, Any]]] = defaultdict(list) + for entry in chunks or []: + if not isinstance(entry, dict): + continue + try: + chapter_index = int(entry.get("chapter_index", 0)) + except (TypeError, ValueError): + chapter_index = 0 + grouped[chapter_index].append(dict(entry)) + + for chapter_index, items in grouped.items(): + items.sort(key=lambda payload: _safe_int(payload.get("chunk_index"))) + + return grouped + + +def _record_override_usage( + job: Job, + usage_counter: Mapping[str, int], + token_map: Mapping[str, str], +) -> None: + if not usage_counter: + return + + language = getattr(job, "language", "") or "a" + for normalized, amount in usage_counter.items(): + if amount <= 0: + continue + token_value = token_map.get(normalized, normalized) + try: + increment_usage(language=language, token=token_value, amount=int(amount)) + except Exception: # pragma: no cover - defensive logging + job.add_log(f"Failed to record usage for override {token_value}", level="warning") + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _chunk_text_for_tts(entry: Mapping[str, Any]) -> str: + """Choose the best source text for synthesis. + + We must prefer the raw chunk text (`text` / `original_text`) so manual/pronunciation + overrides can match against the original tokens (e.g. censored words like `Unfu*k`). + `normalized_text` may have already been run through `normalize_for_pipeline`, which + can remove punctuation and prevent overrides from triggering. + """ + + if not isinstance(entry, Mapping): + return "" + return str( + entry.get("text") + or entry.get("original_text") + or entry.get("normalized_text") + or "" + ).strip() + + +def _escape_ffmetadata_value(value: str) -> str: + escaped = str(value).replace("\\", "\\\\").replace("\n", "\\n") + escaped = escaped.replace("=", "\\=").replace(";", "\\;").replace("#", "\\#") + return escaped + + +def _metadata_to_ffmpeg_args(metadata: Dict[str, Any]) -> List[str]: + args: List[str] = [] + for key, value in (metadata or {}).items(): + if value in (None, ""): + continue + key_str = str(key).strip() + if not key_str: + continue + normalized_key = key_str.lower() + if normalized_key == "year": + ffmpeg_key = "date" + else: + ffmpeg_key = key_str + args.extend(["-metadata", f"{ffmpeg_key}={value}"]) + return args + + +def _render_ffmetadata(metadata: Dict[str, Any], chapters: List[Dict[str, Any]]) -> str: + lines: List[str] = [";FFMETADATA1"] + for key, value in (metadata or {}).items(): + if value is None: + continue + key_str = str(key).strip() + if not key_str: + continue + lines.append(f"{key_str}={_escape_ffmetadata_value(value)}") + + for chapter in chapters or []: + start = chapter.get("start") + end = chapter.get("end") + if start is None or end is None: + continue + try: + start_ms = max(0, int(round(float(start) * 1000))) + end_ms = int(round(float(end) * 1000)) + except (TypeError, ValueError): + continue + if end_ms <= start_ms: + end_ms = start_ms + 1 + lines.append("[CHAPTER]") + lines.append("TIMEBASE=1/1000") + lines.append(f"START={start_ms}") + lines.append(f"END={end_ms}") + title = chapter.get("title") + if title: + lines.append(f"title={_escape_ffmetadata_value(title)}") + voice = chapter.get("voice") + if voice: + lines.append(f"voice={_escape_ffmetadata_value(voice)}") + + return "\n".join(lines) + "\n" + + +def _write_ffmetadata_file( + audio_path: Path, + metadata: Dict[str, Any], + chapters: List[Dict[str, Any]], +) -> Optional[Path]: + content = _render_ffmetadata(metadata, chapters) + if content.strip() == ";FFMETADATA1": + return None + directory = audio_path.parent if audio_path.parent.exists() else Path(tempfile.gettempdir()) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + suffix=".ffmeta", + delete=False, + dir=str(directory), + ) as handle: + handle.write(content) + return Path(handle.name) + + +def _apply_m4b_chapters_with_mutagen( + audio_path: Path, + chapters: List[Dict[str, Any]], + job: Job, +) -> bool: + if not chapters: + return False + + try: + from fractions import Fraction + from mutagen.mp4 import MP4, MP4Chapter # type: ignore[import] + except ImportError: + job.add_log( + "Unable to write MP4 chapter atoms because mutagen is not installed.", + level="warning", + ) + return False + + try: + mp4 = MP4(str(audio_path)) + except Exception as exc: # pragma: no cover - defensive + job.add_log(f"Failed to open m4b for chapter embedding: {exc}", level="warning") + return False + + chapter_objects: List[MP4Chapter] = [] + for index, entry in enumerate(sorted(chapters, key=lambda item: float(item.get("start") or 0.0))): + start_raw = entry.get("start") + if start_raw is None: + continue + try: + start_seconds = max(0.0, float(start_raw)) + except (TypeError, ValueError): + continue + + title_value = entry.get("title") + title_text = str(title_value) if title_value else f"Chapter {index + 1}" + + start_fraction = Fraction(int(round(start_seconds * 1000)), 1000) + chapter_atom = MP4Chapter(start_fraction, title_text) + + end_raw = entry.get("end") + if end_raw is not None: + try: + end_seconds = float(end_raw) + except (TypeError, ValueError): + end_seconds = None + if end_seconds is not None and end_seconds > start_seconds: + chapter_atom.end = Fraction(int(round(end_seconds * 1000)), 1000) + + chapter_objects.append(chapter_atom) + + if not chapter_objects: + return False + + try: + mp4.chapters = cast(Any, chapter_objects) + mp4.save() + except Exception as exc: # pragma: no cover - defensive + job.add_log(f"Failed to persist MP4 chapter atoms: {exc}", level="warning") + return False + + return True + + +def _embed_m4b_metadata( + audio_path: Path, + metadata_payload: Dict[str, Any], + job: Job, +) -> None: + metadata_map = dict(metadata_payload.get("metadata") or {}) + chapter_entries = list(metadata_payload.get("chapters") or []) + ffmetadata_path = _write_ffmetadata_file(audio_path, metadata_map, chapter_entries) + cover_path: Optional[Path] = None + if job.cover_image_path: + candidate = Path(job.cover_image_path) + if candidate.exists(): + cover_path = candidate + + metadata_args = _metadata_to_ffmpeg_args(metadata_map) + + if not ffmetadata_path and not cover_path and not metadata_args: + return + + job.add_log("Embedding metadata into m4b output") + + command: List[str] = ["ffmpeg", "-y", "-i", str(audio_path)] + metadata_index: Optional[int] = None + cover_index: Optional[int] = None + next_index = 1 + + if ffmetadata_path: + command += ["-f", "ffmetadata", "-i", str(ffmetadata_path)] + metadata_index = next_index + next_index += 1 + + if cover_path: + command += ["-i", str(cover_path)] + cover_index = next_index + next_index += 1 + + command += ["-map", "0:a"] + command += ["-c:a", "copy"] + + if cover_index is not None: + command += ["-map", f"{cover_index}:v:0"] + command += ["-c:v:0", "mjpeg"] + command += ["-disposition:v:0", "attached_pic"] + command += ["-metadata:s:v:0", "title=Cover Art"] + if job.cover_image_mime: + command += ["-metadata:s:v:0", f"mimetype={job.cover_image_mime}"] + + if metadata_index is not None: + command += ["-map_metadata", str(metadata_index)] + command += ["-map_chapters", str(metadata_index)] + else: + command += ["-map_metadata", "0"] + + if metadata_args: + command.extend(metadata_args) + + command += ["-movflags", "+faststart+use_metadata_tags"] + + temp_output = audio_path.with_suffix(audio_path.suffix + ".tmp") + if audio_path.suffix.lower() in {".m4b", ".mp4", ".m4a"}: + command += ["-f", "mp4"] + command.append(str(temp_output)) + + process = create_process(command, text=True) + try: + return_code = process.wait() + finally: + if ffmetadata_path and ffmetadata_path.exists(): + try: + ffmetadata_path.unlink() + except OSError: + pass + + if return_code != 0: + if temp_output.exists(): + temp_output.unlink(missing_ok=True) + raise RuntimeError(f"ffmpeg failed to embed metadata (exit code {return_code})") + + temp_output.replace(audio_path) + job.add_log("Embedded metadata and chapters into m4b output", level="info") + + mutagen_applied = _apply_m4b_chapters_with_mutagen(audio_path, chapter_entries, job) + if mutagen_applied: + job.add_log( + f"Applied {len(chapter_entries)} chapter markers via mutagen", level="info" + ) + + +def run_conversion_job(job: Job) -> None: + job.add_log("Preparing conversion pipeline") + canceller = _make_canceller(job) + + normalization_settings = get_runtime_settings() + job_overrides = getattr(job, "normalization_overrides", None) + if job_overrides: + normalization_settings = apply_normalization_overrides(normalization_settings, job_overrides) + apostrophe_config = build_apostrophe_config( + settings=normalization_settings, + base=_APOSTROPHE_CONFIG, + ) + + if apostrophe_config.convert_numbers and not HAS_NUM2WORDS: + job.add_log( + "Number normalization is enabled but 'num2words' library is not available. " + "Numbers (including years) will NOT be converted to words. " + "Please install 'num2words' to enable this feature.", + level="warning" + ) + + apostrophe_mode = str(normalization_settings.get("normalization_apostrophe_mode", "spacy")).lower() + if apostrophe_mode == "llm": + llm_config = build_llm_configuration(normalization_settings) + if not llm_config.is_configured(): + raise RuntimeError( + "LLM-based apostrophe normalization is selected, but the LLM configuration is incomplete." + ) + + sink_stack = ExitStack() + subtitle_writer: Optional[SubtitleWriter] = None + chapter_paths: list[Path] = [] + chapter_markers: List[Dict[str, Any]] = [] + chunk_markers: List[Dict[str, Any]] = [] + metadata_payload: Dict[str, Any] = {} + audio_output_path: Optional[Path] = None + extraction: Optional[Any] = None + pipeline: Any = None + pipelines: Dict[str, Any] = {} + kokoro_cache_ready = False + normalized_profiles: Dict[str, Dict[str, Any]] = {} + chunk_groups: Dict[int, List[Dict[str, Any]]] = {} + active_chapter_configs: List[Dict[str, Any]] = [] + usage_counter: Dict[str, int] = defaultdict(int) + override_token_map: Dict[str, str] = {} + try: + # Load saved speakers once so we can resolve speaker: references during conversion. + try: + profiles = load_profiles() + except Exception: + profiles = {} + for name, entry in (profiles or {}).items(): + normalized = normalize_profile_entry(entry) + if normalized: + normalized_profiles[str(name)] = normalized + + def get_pipeline(provider: str) -> Any: + nonlocal kokoro_cache_ready + provider_norm = str(provider or "kokoro").strip().lower() or "kokoro" + if provider_norm not in {"kokoro", "supertonic"}: + provider_norm = "kokoro" + + existing = pipelines.get(provider_norm) + if existing is not None: + return existing + + if provider_norm == "supertonic": + pipelines[provider_norm] = SupertonicPipeline( + sample_rate=SAMPLE_RATE, + auto_download=True, + total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5), + ) + return pipelines[provider_norm] + + # Kokoro + cfg = load_config() + disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True) + device = "cpu" + if not disable_gpu: + device = _select_device() + _np, KPipeline = load_numpy_kpipeline() + # Try to initialize with the selected device; fall back to CPU if CUDA fails + try: + pipelines[provider_norm] = KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device=device) + except RuntimeError as e: + if "CUDA" in str(e) and device != "cpu": + job.add_log(f"CUDA initialization failed, falling back to CPU: {e}", level="warning") + pipelines[provider_norm] = KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device="cpu") + else: + raise + if not kokoro_cache_ready: + _initialize_voice_cache(job) + kokoro_cache_ready = True + return pipelines[provider_norm] + + def resolve_voice_target(raw_spec: str) -> tuple[str, str, Optional[float], Optional[int]]: + """Return (provider, voice_spec, speed_override, steps_override).""" + spec = str(raw_spec or "").strip() + speaker_name, _ = _split_speaker_reference(spec) + if speaker_name and speaker_name in normalized_profiles: + entry = normalized_profiles[speaker_name] + provider = str(entry.get("provider") or "kokoro").strip().lower() or "kokoro" + if provider == "supertonic": + voice = str(entry.get("voice") or getattr(job, "voice", "M1") or "M1").strip() or "M1" + steps = int(entry.get("total_steps") or getattr(job, "supertonic_total_steps", 5) or 5) + speed = float(entry.get("speed") or getattr(job, "speed", 1.0) or 1.0) + return "supertonic", _supertonic_voice_from_spec(voice, getattr(job, "voice", "M1")), speed, steps + formula = _formula_from_kokoro_entry(entry) + return "kokoro", formula or spec, None, None + + fallback_provider = str(getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() or "kokoro" + inferred = _infer_provider_from_spec(spec, fallback=fallback_provider) + if inferred == "supertonic": + return "supertonic", _supertonic_voice_from_spec(spec, getattr(job, "voice", "M1")), None, None + return "kokoro", spec, None, None + + def resolve_voice_choice(raw_spec: str) -> tuple[str, str, Any, Optional[float], Optional[int]]: + """Resolve a raw voice spec into (provider, resolved_spec, choice, speed, steps). + + For Kokoro formulas, `choice` will be a resolved voice tensor (via `voice_formulas`). + For SuperTonic, `choice` will be a valid SuperTonic voice id. + """ + + provider, resolved, speed, steps = resolve_voice_target(raw_spec) + cache_key = f"{provider}:{resolved}" if resolved else provider + cached = voice_cache.get(cache_key) + if cached is not None: + return provider, resolved, cached, speed, steps + + if provider == "kokoro": + kokoro_pipeline = get_pipeline("kokoro") + choice = _resolve_voice(kokoro_pipeline, resolved, job.use_gpu) + else: + choice = resolved + + voice_cache[cache_key] = choice + return provider, resolved, choice, speed, steps + + extraction = extract_from_path(job.stored_path) + file_type = _infer_file_type(job.stored_path) + pronunciation_overrides = _merge_pronunciation_overrides(job) + pronunciation_rules = _compile_pronunciation_rules(pronunciation_overrides) + heteronym_sentence_rules = _compile_heteronym_sentence_rules( + getattr(job, "heteronym_overrides", None) + ) + if heteronym_sentence_rules: + job.add_log( + f"Applying {len(heteronym_sentence_rules)} heteronym override{'s' if len(heteronym_sentence_rules) != 1 else ''} during conversion.", + level="debug", + ) + if pronunciation_rules: + count = len(pronunciation_rules) + job.add_log( + f"Applying {count} pronunciation override{'s' if count != 1 else ''} during conversion.", + level="debug", + ) + for override_entry in pronunciation_overrides or []: + if not isinstance(override_entry, Mapping): + continue + raw_token = str(override_entry.get("token") or "").strip() + normalized_value = str(override_entry.get("normalized") or "").strip() + if not normalized_value and raw_token: + normalized_value = normalize_entity_token(raw_token) or raw_token + if normalized_value: + override_token_map.setdefault(normalized_value, raw_token or normalized_value) + + if not job.chapters: + filtered, skipped_info = _auto_select_relevant_chapters(extraction.chapters, file_type) + original_count = len(extraction.chapters) + if filtered and len(filtered) < original_count: + extraction.chapters = filtered + _update_metadata_for_chapter_count(extraction.metadata, len(filtered), file_type) + threshold = _SIGNIFICANT_LENGTH_THRESHOLDS.get(file_type.lower()) + label = _chapter_label(file_type) + qualifier = f" (< {threshold} characters)" if threshold else "" + job.add_log( + f"Auto-selected {len(filtered)} of {original_count} {label} based on content{qualifier}.", + level="info", + ) + if skipped_info: + preview_count = 5 + preview = ", ".join( + f"{title or 'Untitled'} ({length})" for title, length in skipped_info[:preview_count] + ) + if len(skipped_info) > preview_count: + preview += ", …" + job.add_log( + f"Skipped {len(skipped_info)} short {label}: {preview}", + level="debug", + ) + elif not filtered: + job.add_log( + "Auto-selection did not identify usable chapters; retaining original set.", + level="warning", + ) + + metadata_overrides: Dict[str, Any] = dict(job.metadata_tags or {}) + if job.chapters: + selected_chapters, chapter_metadata, diagnostics = _apply_chapter_overrides( + extraction.chapters, + job.chapters, + ) + for message in diagnostics: + job.add_log(message, level="warning") + if selected_chapters: + extraction.chapters = selected_chapters + metadata_overrides.update(chapter_metadata) + job.add_log( + f"Chapter overrides applied: {len(selected_chapters)} selected.", + level="info", + ) + active_chapter_configs = [ + entry for entry in job.chapters if _coerce_truthy(entry.get("enabled", True)) + ][: len(selected_chapters)] + if job.chunks: + chunk_groups = _group_chunks_by_chapter(job.chunks) + else: + raise ValueError("No chapters were enabled in the requested job.") + elif job.chunks: + chunk_groups = _group_chunks_by_chapter(job.chunks) + + job.metadata_tags = _merge_metadata(extraction.metadata, metadata_overrides) + + total_characters = extraction.total_characters or calculate_text_length(extraction.combined_text) + job.total_characters = total_characters + job.add_log(f"Total characters: {job.total_characters:,}") + + _apply_newline_policy(extraction.chapters, job.replace_single_newlines) + + base_output_dir = _prepare_output_dir(job) + project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(job, base_output_dir) + + if job.output_format.lower() == "m4b" and not job.merge_chapters_at_end: + job.add_log( + "Forcing merged output for m4b format; ignoring 'merge chapters at end' setting.", + level="warning", + ) + job.merge_chapters_at_end = True + + merged_required = job.merge_chapters_at_end or not job.save_chapters_separately + audio_path: Optional[Path] = None + audio_sink: Optional[AudioSink] = None + if merged_required: + audio_path = _build_output_path(audio_dir, job.original_filename, job.output_format) + meta_for_sink = job.metadata_tags if job.metadata_tags else None + audio_sink = _open_audio_sink(audio_path, job, sink_stack, metadata=meta_for_sink) + subtitle_writer = _create_subtitle_writer(job, audio_path) + job.result.audio_path = audio_path + if subtitle_writer: + job.result.subtitle_paths.append(subtitle_writer.path) + + chapter_dir: Optional[Path] = None + if job.save_chapters_separately: + chapter_dir = audio_dir / "chapters" + chapter_dir.mkdir(parents=True, exist_ok=True) + + base_voice_spec = _job_voice_fallback(job) + voice_cache: Dict[str, Any] = {} + base_provider, base_voice_resolved, _, _ = resolve_voice_target(base_voice_spec) + if base_provider == "kokoro" and base_voice_resolved and "*" not in base_voice_resolved: + kokoro_pipeline = get_pipeline("kokoro") + voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_pipeline, base_voice_resolved, job.use_gpu) + processed_chars = 0 + subtitle_index = 1 + current_time = 0.0 + total_chapters = len(extraction.chapters) + if chunk_groups: + chunk_groups = { + idx: items for idx, items in chunk_groups.items() if 0 <= idx < total_chapters + } + job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}") + auto_prefix_titles = getattr(job, "auto_prefix_chapter_titles", True) + read_title_intro = getattr(job, "read_title_intro", False) + book_intro_text = "" + intro_provider: Optional[str] = None + intro_voice_choice: Any = None + intro_speed: Optional[float] = None + intro_steps: Optional[int] = None + if read_title_intro: + book_intro_text = _build_title_intro_text(job.metadata_tags, job.original_filename) + if book_intro_text: + preview = book_intro_text if len(book_intro_text) <= 120 else f"{book_intro_text[:117]}…" + job.add_log(f"Title intro enabled: {preview}", level="debug") + + intro_voice_spec = base_voice_spec or job.voice + if intro_voice_spec == "__custom_mix": + intro_voice_spec = base_voice_spec or "" + if not intro_voice_spec: + fallback_key = next(iter(voice_cache.keys()), "") + if fallback_key and fallback_key != "__custom_mix": + intro_voice_spec = fallback_key.split(":", 1)[-1] + if not intro_voice_spec and VOICES_INTERNAL: + intro_voice_spec = VOICES_INTERNAL[0] + + if intro_voice_spec: + intro_provider, _, intro_voice_choice, intro_speed, intro_steps = resolve_voice_choice( + intro_voice_spec + ) + else: + job.add_log("Title intro enabled but no usable metadata was found.", level="debug") + intro_emitted = False + + def emit_text( + text: str, + *, + voice_choice: Any, + chapter_sink: Optional[AudioSink], + preview_prefix: Optional[str] = None, + split_pattern: Optional[str] = SPLIT_PATTERN, + tts_provider: Optional[str] = None, + speed_override: Optional[float] = None, + supertonic_steps_override: Optional[int] = None, + ) -> int: + nonlocal processed_chars, subtitle_index, current_time + source_text = str(text or "") + if heteronym_sentence_rules: + source_text = _apply_heteronym_sentence_rules(source_text, heteronym_sentence_rules) + if pronunciation_rules: + source_text = _apply_pronunciation_rules( + source_text, + pronunciation_rules, + usage_counter, + ) + try: + normalized = normalize_for_pipeline( + source_text, + config=apostrophe_config, + settings=normalization_settings, + ) + except LLMClientError as exc: + job.add_log(f"LLM normalization failed: {exc}", level="error") + raise + local_segments = 0 + + provider = str(tts_provider or getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() or "kokoro" + if provider == "supertonic": + supertonic_pipeline = get_pipeline("supertonic") + voice_name = _supertonic_voice_from_spec(voice_choice, getattr(job, "voice", "M1")) + segment_iter = supertonic_pipeline( + normalized, + voice=voice_name, + speed=float(speed_override if speed_override is not None else job.speed), + split_pattern=split_pattern, + total_steps=int(supertonic_steps_override if supertonic_steps_override is not None else getattr(job, "supertonic_total_steps", 5)), + ) + else: + kokoro_pipeline = get_pipeline("kokoro") + segment_iter = kokoro_pipeline( + normalized, + voice=voice_choice, + speed=float(speed_override if speed_override is not None else job.speed), + split_pattern=split_pattern, + ) + + for segment in segment_iter: + canceller() + graphemes_raw = getattr(segment, "graphemes", "") or "" + graphemes = graphemes_raw.strip() + + audio = _to_float32(getattr(segment, "audio", None)) + if audio.size == 0: + continue + + local_segments += 1 + if chapter_sink: + chapter_sink.write(audio) + if audio_sink: + audio_sink.write(audio) + + duration = len(audio) / SAMPLE_RATE + processed_chars += len(graphemes) + job.processed_characters = processed_chars + if job.total_characters: + job.progress = min(processed_chars / job.total_characters, 0.999) + else: + job.progress = 0.0 if processed_chars == 0 else 0.999 + + preview_text = graphemes or (graphemes_raw[:80] if graphemes_raw else "[silence]") + prefix = f"{preview_prefix} · " if preview_prefix else "" + job.add_log(f"{prefix}{processed_chars:,}/{job.total_characters or '—'}: {preview_text[:80]}") + + if subtitle_writer and audio_sink and graphemes: + subtitle_writer.write_segment( + index=subtitle_index, + text=graphemes, + start=current_time, + end=current_time + duration, + ) + subtitle_index += 1 + + if audio_sink: + current_time += duration + + return local_segments + + def append_silence( + duration_seconds: float, + *, + include_in_chapter: bool, + chapter_sink: Optional[AudioSink], + ) -> None: + nonlocal current_time + if duration_seconds <= 0: + return + samples = int(round(duration_seconds * SAMPLE_RATE)) + if samples <= 0: + return + silence = np.zeros(samples, dtype="float32") + if include_in_chapter and chapter_sink: + chapter_sink.write(silence) + if audio_sink: + audio_sink.write(silence) + current_time += duration_seconds + + for idx, chapter in enumerate(extraction.chapters, start=1): + canceller() + raw_title = str(getattr(chapter, "title", "") or "").strip() + spoken_title = _format_spoken_chapter_title(raw_title, idx, auto_prefix_titles) + heading_text = spoken_title or raw_title + chapter_display_title = heading_text or f"Chapter {idx}" + job.add_log(f"Processing chapter {idx}/{total_chapters}: {chapter_display_title}") + normalize_opening_caps = bool(getattr(job, "normalize_chapter_opening_caps", True)) + + chapter_start_time = current_time + chapter_override = ( + active_chapter_configs[idx - 1] if idx - 1 < len(active_chapter_configs) else None + ) + chapter_voice_spec = _chapter_voice_spec(job, chapter_override) + if not chapter_voice_spec: + chapter_voice_spec = base_voice_spec + + chapter_provider, chapter_voice_resolved, chapter_speed, chapter_steps = resolve_voice_target(chapter_voice_spec) + chapter_cache_key = f"{chapter_provider}:{chapter_voice_resolved}" if chapter_voice_resolved else chapter_provider + if chapter_provider == "kokoro": + voice_choice = voice_cache.get(chapter_cache_key) + if voice_choice is None: + kokoro_pipeline = get_pipeline("kokoro") + voice_choice = _resolve_voice(kokoro_pipeline, chapter_voice_resolved, job.use_gpu) + voice_cache[chapter_cache_key] = voice_choice + else: + voice_choice = chapter_voice_resolved + + chapter_audio_path: Optional[Path] = None + segments_emitted = 0 + + with ExitStack() as chapter_sink_stack: + chapter_sink: Optional[AudioSink] = None + + if chapter_dir is not None: + chapter_audio_path = _build_output_path( + chapter_dir, + f"{Path(job.original_filename).stem}_{_slugify(chapter_display_title, idx)}", + job.separate_chapters_format, + ) + chapter_sink = _open_audio_sink( + chapter_audio_path, + job, + chapter_sink_stack, + fmt=job.separate_chapters_format, + ) + + speak_heading = bool(heading_text) + first_line = "" + if chapter.text: + first_line = next((line.strip() for line in chapter.text.splitlines() if line.strip()), "") + remove_heading_from_body = False + if speak_heading and first_line: + if _headings_equivalent(first_line, heading_text) or (raw_title and _headings_equivalent(first_line, raw_title)): + remove_heading_from_body = True + + if not intro_emitted and book_intro_text: + intro_use_provider = intro_provider or chapter_provider + intro_use_voice_choice = intro_voice_choice if intro_voice_choice is not None else voice_choice + intro_use_speed = intro_speed if intro_speed is not None else chapter_speed + intro_use_steps = intro_steps if intro_steps is not None else chapter_steps + intro_segments = emit_text( + book_intro_text, + voice_choice=intro_use_voice_choice, + chapter_sink=chapter_sink, + preview_prefix="Book intro", + tts_provider=intro_use_provider, + speed_override=intro_use_speed, + supertonic_steps_override=intro_use_steps, + ) + intro_emitted = True + if intro_segments > 0 and job.chapter_intro_delay > 0: + append_silence( + job.chapter_intro_delay, + include_in_chapter=True, + chapter_sink=chapter_sink, + ) + + if speak_heading: + heading_segments = emit_text( + heading_text, + voice_choice=voice_choice, + chapter_sink=chapter_sink, + preview_prefix=f"Chapter {idx} title", + split_pattern=SPLIT_PATTERN, + tts_provider=chapter_provider, + speed_override=chapter_speed, + supertonic_steps_override=chapter_steps, + ) + segments_emitted += heading_segments + if heading_segments > 0 and job.chapter_intro_delay > 0: + append_silence( + job.chapter_intro_delay, + include_in_chapter=True, + chapter_sink=chapter_sink, + ) + + chunks_for_chapter = chunk_groups.get(idx - 1, []) if chunk_groups else [] + body_segments = 0 + pending_heading_strip = remove_heading_from_body + opening_caps_pending = normalize_opening_caps + opening_caps_logged = False + if chunks_for_chapter: + job.add_log( + f"Emitting {len(chunks_for_chapter)} {job.chunk_level} chunks for chapter {idx}.", + level="debug", + ) + for chunk_entry in chunks_for_chapter: + chunk_text = _chunk_text_for_tts(chunk_entry) + if not chunk_text: + continue + + mutated_entry = False + if pending_heading_strip and heading_text: + chunk_text, removed_heading = _strip_duplicate_heading_line(chunk_text, heading_text) + if not removed_heading and raw_title: + match = _HEADING_NUMBER_PREFIX_RE.match(raw_title) + if match: + number = match.group("number") + if number: + chunk_text, removed_heading = _strip_duplicate_heading_line(chunk_text, number) + + if removed_heading: + pending_heading_strip = False + chunk_entry = dict(chunk_entry) + chunk_entry["normalized_text"] = chunk_text + mutated_entry = True + if not chunk_text.strip(): + continue + + if opening_caps_pending and chunk_text: + normalized_text, normalized_changed = _normalize_chapter_opening_caps(chunk_text) + if normalized_changed: + if not mutated_entry: + chunk_entry = dict(chunk_entry) + mutated_entry = True + chunk_entry["normalized_text"] = normalized_text + chunk_text = normalized_text + if not opening_caps_logged: + job.add_log( + f"Normalized uppercase chapter opening for chapter {idx}.", + level="debug", + ) + opening_caps_logged = True + if chunk_text.strip(): + opening_caps_pending = False + + chunk_voice_spec = _chunk_voice_spec( + job, + chunk_entry, + chapter_voice_spec or base_voice_spec, + ) + if not chunk_voice_spec: + chunk_voice_spec = chapter_voice_spec or base_voice_spec + + if chunk_voice_spec == chapter_voice_spec: + chunk_provider = chapter_provider + chunk_voice_resolved = chapter_voice_resolved + chunk_speed_use = chapter_speed + chunk_steps_use = chapter_steps + chunk_voice_choice = voice_choice + else: + chunk_provider, chunk_voice_resolved, chunk_speed_use, chunk_steps_use = resolve_voice_target(chunk_voice_spec) + chunk_cache_key = f"{chunk_provider}:{chunk_voice_resolved}" if chunk_voice_resolved else chunk_provider + if chunk_provider == "kokoro": + chunk_voice_choice = voice_cache.get(chunk_cache_key) + if chunk_voice_choice is None: + kokoro_pipeline = get_pipeline("kokoro") + chunk_voice_choice = _resolve_voice( + kokoro_pipeline, + chunk_voice_resolved, + job.use_gpu, + ) + voice_cache[chunk_cache_key] = chunk_voice_choice + else: + chunk_voice_choice = chunk_voice_resolved + + chunk_start = current_time + emitted = emit_text( + chunk_text, + voice_choice=chunk_voice_choice, + chapter_sink=chapter_sink, + preview_prefix=f"Chunk {chunk_entry.get('id') or chunk_entry.get('chunk_index')}", + tts_provider=chunk_provider, + speed_override=chunk_speed_use, + supertonic_steps_override=chunk_steps_use, + ) + if emitted <= 0: + continue + + body_segments += emitted + segments_emitted += emitted + chunk_markers.append( + { + "id": chunk_entry.get("id"), + "chapter_index": idx - 1, + "chunk_index": _safe_int( + chunk_entry.get("chunk_index"), len(chunk_markers) + ), + "start": chunk_start, + "end": current_time, + "speaker_id": chunk_entry.get("speaker_id", "narrator"), + "voice": chunk_voice_spec, + "level": chunk_entry.get("level", job.chunk_level), + "characters": len(chunk_text), + } + ) + + if body_segments == 0: + chapter_body_start = current_time + chapter_text = str(chapter.text or "") + if pending_heading_strip and heading_text: + chapter_text, removed_heading = _strip_duplicate_heading_line(chapter_text, heading_text) + if not removed_heading and raw_title: + match = _HEADING_NUMBER_PREFIX_RE.match(raw_title) + if match: + number = match.group("number") + if number: + chapter_text, removed_heading = _strip_duplicate_heading_line(chapter_text, number) + + if removed_heading: + pending_heading_strip = False + if opening_caps_pending and chapter_text: + normalized_body, normalized_changed = _normalize_chapter_opening_caps(chapter_text) + if normalized_changed: + chapter_text = normalized_body + if not opening_caps_logged: + job.add_log( + f"Normalized uppercase chapter opening for chapter {idx}.", + level="debug", + ) + opening_caps_logged = True + if str(chapter_text or "").strip(): + opening_caps_pending = False + emitted = emit_text( + chapter_text, + voice_choice=voice_choice, + chapter_sink=chapter_sink, + tts_provider=chapter_provider, + speed_override=chapter_speed, + supertonic_steps_override=chapter_steps, + ) + if emitted > 0: + segments_emitted += emitted + chunk_markers.append( + { + "id": None, + "chapter_index": idx - 1, + "chunk_index": 0, + "start": chapter_body_start, + "end": current_time, + "speaker_id": "narrator", + "voice": chapter_voice_spec, + "level": job.chunk_level, + "characters": len(chapter_text or ""), + } + ) + elif chunks_for_chapter: + job.add_log( + "No audio generated for supplied chunks; chapter text also empty.", + level="warning", + ) + + chapter_end_time = current_time + + if chapter_audio_path is not None: + job.result.artifacts[f"chapter_{idx:02d}"] = chapter_audio_path + chapter_paths.append(chapter_audio_path) + + if segments_emitted == 0: + job.add_log( + f"No audio segments were generated for chapter {idx}.", + level="warning", + ) + else: + job.add_log(f"Finished chapter {idx} with {segments_emitted} segments.") + + if ( + audio_sink + and job.merge_chapters_at_end + and idx < total_chapters + and job.silence_between_chapters > 0 + ): + append_silence( + job.silence_between_chapters, + include_in_chapter=False, + chapter_sink=None, + ) + chapter_end_time = current_time + + marker = { + "index": idx, + "title": chapter_display_title, + "start": chapter_start_time, + "end": chapter_end_time, + "voice": chapter_voice_spec, + } + if raw_title and raw_title != chapter_display_title: + marker["original_title"] = raw_title + chapter_markers.append(marker) + + if getattr(job, "read_closing_outro", True): + outro_text = _build_outro_text(job.metadata_tags, job.original_filename) + outro_voice_spec = base_voice_spec or job.voice + if outro_voice_spec == "__custom_mix": + outro_voice_spec = base_voice_spec or "" + if not outro_voice_spec: + fallback_key = next(iter(voice_cache.keys()), "") + if fallback_key and fallback_key != "__custom_mix": + # `voice_cache` keys are internal and include provider prefixes. + outro_voice_spec = fallback_key.split(":", 1)[-1] + if not outro_voice_spec and VOICES_INTERNAL: + outro_voice_spec = VOICES_INTERNAL[0] + + if outro_text and outro_voice_spec: + outro_start_time = current_time + outro_audio_path: Optional[Path] = None + outro_segments = 0 + outro_index = total_chapters + 1 + outro_provider, _, outro_voice_choice, outro_speed, outro_steps = resolve_voice_choice(outro_voice_spec) + + with ExitStack() as outro_sink_stack: + chapter_sink: Optional[AudioSink] = None + if chapter_dir is not None: + outro_audio_path = _build_output_path( + chapter_dir, + f"{Path(job.original_filename).stem}_outro", + job.separate_chapters_format, + ) + chapter_sink = _open_audio_sink( + outro_audio_path, + job, + outro_sink_stack, + fmt=job.separate_chapters_format, + ) + + outro_segments = emit_text( + outro_text, + voice_choice=outro_voice_choice, + chapter_sink=chapter_sink, + preview_prefix="Outro", + tts_provider=outro_provider, + speed_override=outro_speed, + supertonic_steps_override=outro_steps, + ) + outro_end_time = current_time + + if outro_segments > 0: + job.add_log(f"Appended outro sequence: {outro_text}") + if outro_audio_path is not None: + job.result.artifacts[f"chapter_{outro_index:02d}"] = outro_audio_path + chapter_paths.append(outro_audio_path) + chapter_markers.append( + { + "index": outro_index, + "title": "Outro", + "start": outro_start_time, + "end": outro_end_time, + "voice": outro_voice_spec, + } + ) + else: + job.add_log("No audio generated for outro sequence.", level="warning") + + if not audio_path and chapter_paths: + job.result.audio_path = chapter_paths[0] + + metadata_payload = { + "metadata": dict(job.metadata_tags or {}), + "chapters": chapter_markers, + "chunks": chunk_markers, + "chunk_level": job.chunk_level, + "speaker_mode": job.speaker_mode, + "speakers": dict(getattr(job, "speakers", {}) or {}), + "generate_epub3": job.generate_epub3, + } + + if usage_counter: + _record_override_usage(job, usage_counter, override_token_map) + + if metadata_dir: + metadata_dir.mkdir(parents=True, exist_ok=True) + metadata_file = metadata_dir / "metadata.json" + metadata_file.write_text(json.dumps(metadata_payload, indent=2), encoding="utf-8") + job.result.artifacts["metadata"] = metadata_file + + if job.generate_epub3: + audio_asset = job.result.audio_path + if not audio_asset and chapter_paths: + audio_asset = chapter_paths[0] + + if audio_asset: + try: + epub_root = project_root + epub_output_path = _build_output_path(epub_root, job.original_filename, "epub") + job.add_log("Generating EPUB 3 package with synchronized narration…") + epub_path = build_epub3_package( + output_path=epub_output_path, + book_id=job.id, + extraction=extraction, + metadata_tags=metadata_payload.get("metadata") or {}, + chapter_markers=chapter_markers, + chunk_markers=chunk_markers, + chunks=job.chunks, + audio_path=audio_asset, + speaker_mode=job.speaker_mode, + cover_image_path=job.cover_image_path, + cover_image_mime=job.cover_image_mime, + ) + job.result.epub_path = epub_path + job.result.artifacts["epub3"] = epub_path + job.add_log(f"EPUB 3 package created at {epub_path}") + except Exception as exc: + job.add_log(f"Failed to generate EPUB 3 package: {exc}", level="error") + else: + job.add_log("Skipped EPUB 3 generation: audio output unavailable.", level="warning") + + if job.save_as_project: + job.result.artifacts["project_root"] = project_root + + if job.status != JobStatus.CANCELLED: + job.progress = 1.0 + + audio_output_path = job.result.audio_path + + except _JobCancelled: + job.status = JobStatus.CANCELLED + job.add_log("Job cancelled", level="warning") + except Exception as exc: # pragma: no cover - defensive guard + job.error = str(exc) + job.status = JobStatus.FAILED + exc_type = exc.__class__.__name__ + job.add_log(f"Job failed ({exc_type}): {exc}", level="error") + + chapter_count: Any + if extraction is not None and hasattr(extraction, "chapters"): + try: + chapter_count = len(getattr(extraction, "chapters", []) or []) + except Exception: # pragma: no cover - defensive fallback + chapter_count = "unavailable" + else: + chapter_count = "unavailable" + + try: + chunk_group_count = len(chunk_groups) + chunk_total = sum(len(items) for items in chunk_groups.values()) + except Exception: # pragma: no cover - defensive fallback + chunk_group_count = "unavailable" + chunk_total = "unavailable" + + job.add_log( + "Context => chunk_level=%s, chapters=%s, chunk_groups=%s, chunks=%s" + % (job.chunk_level, chapter_count, chunk_group_count, chunk_total), + level="debug", + ) + + first_nonempty_group = next((items for items in chunk_groups.values() if items), None) + if first_nonempty_group: + first_chunk = dict(first_nonempty_group[0]) + sample_text = str(first_chunk.get("text") or "")[:160].replace("\n", " ") + job.add_log( + "First chunk sample => id=%s, speaker=%s, chars=%s, preview=%s" + % ( + first_chunk.get("id") or first_chunk.get("chunk_index"), + first_chunk.get("speaker_id", "narrator"), + len(str(first_chunk.get("text") or "")), + sample_text, + ), + level="debug", + ) + + tb_lines = traceback.format_exception(exc.__class__, exc, exc.__traceback__) + for line in tb_lines[:20]: + trimmed = line.rstrip() + if trimmed: + for snippet in trimmed.splitlines(): + job.add_log(f"TRACE: {snippet}", level="debug") + finally: + sink_stack.close() + if subtitle_writer: + subtitle_writer.close() + + # Explicitly release the pipeline and force garbage collection to prevent + # memory accumulation in the worker process, which can lead to host lockups. + pipelines.clear() + pipeline = None + gc.collect() + try: + import torch # type: ignore[import-not-found] + if torch.cuda.is_available(): + torch.cuda.empty_cache() + except ImportError: + pass + + if ( + audio_output_path + and job.output_format.lower() == "m4b" + and not job.cancel_requested + and job.status not in {JobStatus.FAILED, JobStatus.CANCELLED} + ): + try: + _embed_m4b_metadata(audio_output_path, metadata_payload, job) + except Exception as exc: # pragma: no cover - ensure failure propagates + job.add_log( + f"Failed to embed metadata into m4b output: {exc}", + level="error", + ) + raise RuntimeError( + f"Failed to embed metadata into m4b output: {exc}" + ) from exc + + +def _load_pipeline(job: Job): + cfg = load_config() + disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True) + provider = str(getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() + if provider == "supertonic": + return SupertonicPipeline( + sample_rate=SAMPLE_RATE, + auto_download=True, + total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5), + ) + + device = "cpu" + if not disable_gpu: + device = _select_device() + _np, KPipeline = load_numpy_kpipeline() + return KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device=device) + + +def _select_device() -> str: + import platform + + system = platform.system() + if system == "Darwin" and platform.processor() == "arm": + return "mps" + return "cuda" + + +def _prepare_output_dir(job: Job) -> Path: + from platformdirs import user_desktop_dir # type: ignore[import-not-found] + + default_output = Path(str(get_user_cache_path("outputs"))) + if job.save_mode == "Save to Desktop": + directory = Path(user_desktop_dir()) + elif job.save_mode == "Save next to input file": + directory = job.stored_path.parent + elif job.save_mode == "Choose output folder" and job.output_folder: + directory = Path(job.output_folder) + elif job.save_mode == "Use default save location": + directory = Path(get_user_output_path()) + else: + directory = default_output + directory.mkdir(parents=True, exist_ok=True) + return directory + + +def _build_output_path(directory: Path, original_name: str, extension: str) -> Path: + sanitized = _sanitize_output_stem(original_name) + directory.mkdir(parents=True, exist_ok=True) + return directory / f"{sanitized}.{extension}" + + +def _prepare_project_layout(job: Job, base_dir: Path) -> tuple[Path, Path, Path, Optional[Path]]: + base_dir.mkdir(parents=True, exist_ok=True) + sanitized = _sanitize_output_stem(job.original_filename) + folder_name = f"{_output_timestamp_token()}_{sanitized}" + project_root = base_dir / folder_name + project_root.mkdir(parents=True, exist_ok=True) + + if job.save_as_project: + audio_dir = project_root / "audio" + subtitle_dir = project_root / "subtitles" + metadata_dir = project_root / "metadata" + for directory in (audio_dir, subtitle_dir, metadata_dir): + directory.mkdir(parents=True, exist_ok=True) + return project_root, audio_dir, subtitle_dir, metadata_dir + + return project_root, project_root, project_root, None + + +def _apply_newline_policy(chapters: List[ExtractedChapter], replace_single_newlines: bool) -> None: + if not replace_single_newlines: + return + newline_regex = re.compile(r"(? str: + sanitized = re.sub(r"[^\w\-]+", "_", title.lower()).strip("_") + if not sanitized: + sanitized = f"chapter_{index:02d}" + return sanitized[:80] + + +def _sanitize_output_stem(name: str) -> str: + base = Path(name or "").stem + sanitized = _OUTPUT_SANITIZE_RE.sub("_", base).strip("_") + return sanitized or "output" + + +def _output_timestamp_token() -> str: + return datetime.now().strftime("%Y%m%d-%H%M%S") + + +def _open_audio_sink( + path: Path, + job: Job, + stack: ExitStack, + *, + fmt: Optional[str] = None, + metadata: Optional[Dict[str, str]] = None, +) -> AudioSink: + ffmpeg_cache_root = get_internal_cache_path("ffmpeg") + platform_cache = os.path.join(ffmpeg_cache_root, sys.platform) + os.makedirs(platform_cache, exist_ok=True) + try: + import static_ffmpeg.run as static_ffmpeg_run # type: ignore + + static_ffmpeg_run.LOCK_FILE = os.path.join(ffmpeg_cache_root, "lock.file") + except Exception: + pass + + static_ffmpeg.add_paths(weak=True, download_dir=platform_cache) + fmt_value = (fmt or job.output_format).lower() + + if fmt_value in {"wav", "flac"}: + soundfile = stack.enter_context( + sf.SoundFile(path, mode="w", samplerate=SAMPLE_RATE, channels=1, format=fmt_value.upper()) + ) + return AudioSink(write=lambda data: soundfile.write(data)) + + cmd = _build_ffmpeg_command(path, fmt_value, metadata=metadata) + process = create_process(cmd, stdin=subprocess.PIPE, text=False) + + def _finalize() -> None: + if process.stdin and not process.stdin.closed: + process.stdin.close() + process.wait() + + stack.callback(_finalize) + + def _write(data: np.ndarray) -> None: + if job.cancel_requested or process.stdin is None: + return + process.stdin.write(data.tobytes()) # type: ignore[arg-type] + + return AudioSink(write=_write) + + +def _build_ffmpeg_command(path: Path, fmt: str, metadata: Optional[Dict[str, str]] = None) -> list[str]: + base = [ + "ffmpeg", + "-y", + "-f", + "f32le", + "-ar", + str(SAMPLE_RATE), + "-ac", + "1", + "-i", + "pipe:0", + ] + if fmt == "mp3": + base += ["-c:a", "libmp3lame", "-qscale:a", "2"] + elif fmt == "opus": + base += ["-c:a", "libopus", "-b:a", "24000"] + elif fmt == "m4b": + base += ["-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart+use_metadata_tags"] + else: + base += ["-c:a", "copy"] + + if metadata: + base.extend(_metadata_to_ffmpeg_args(metadata)) + base.append(str(path)) + return base + + +def _resolve_voice(pipeline, voice_spec: str, use_gpu: bool): + if "*" in voice_spec: + # Voice formulas are a Kokoro-only feature (they require a pipeline that can + # load individual Kokoro voices). When running with SuperTonic (or when the + # pipeline is otherwise unavailable), treat the spec as a plain string and + # allow downstream provider-specific resolution to choose a safe fallback. + if pipeline is None or not hasattr(pipeline, "load_single_voice"): + return voice_spec + return get_new_voice(pipeline, voice_spec, use_gpu) + return voice_spec + + +def _to_float32(audio_segment) -> np.ndarray: + if audio_segment is None: + return np.zeros(0, dtype="float32") + + tensor = audio_segment + if hasattr(tensor, "detach"): + tensor = tensor.detach() + if hasattr(tensor, "cpu"): + try: + tensor = tensor.cpu() + except Exception: + pass + if hasattr(tensor, "numpy"): + return np.asarray(tensor.numpy(), dtype="float32").reshape(-1) + return np.asarray(tensor, dtype="float32").reshape(-1) + + +class SubtitleWriter: + def __init__(self, path: Path, format_key: str) -> None: + self.path = path + self.format_key = format_key + self._file = path.open("w", encoding="utf-8", errors="replace") + if format_key == "ass": + self._write_ass_header() + + def write_segment(self, *, index: int, text: str, start: float, end: float) -> None: + if self.format_key == "ass": + self._write_ass_event(text, start, end) + else: + self._write_srt_line(index, text, start, end) + + def close(self) -> None: + self._file.close() + + def _write_srt_line(self, index: int, text: str, start: float, end: float) -> None: + self._file.write(f"{index}\n") + self._file.write(f"{_format_timestamp(start)} --> {_format_timestamp(end)}\n") + self._file.write(text.strip() + "\n\n") + + def _write_ass_header(self) -> None: + self._file.write("[Script Info]\n") + self._file.write("Title: Generated by Abogen\n") + self._file.write("ScriptType: v4.00+\n\n") + self._file.write("[V4+ Styles]\n") + self._file.write( + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" + ) + self._file.write( + "Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n" + ) + self._file.write("[Events]\n") + self._file.write( + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" + ) + + def _write_ass_event(self, text: str, start: float, end: float) -> None: + self._file.write( + f"Dialogue: 0,{_format_timestamp(start, ass=True)},{_format_timestamp(end, ass=True)},Default,,0000,0000,0000,,{text.strip()}\n" + ) + + +def _create_subtitle_writer(job: Job, audio_path: Path) -> Optional[SubtitleWriter]: + if job.subtitle_mode == "Disabled": + return None + + fmt = (job.subtitle_format or "srt").lower() + if job.subtitle_mode == "Sentence + Highlighting" and fmt == "srt": + job.add_log("Highlighting requires ASS subtitles. Switching format.", level="warning") + fmt = "ass" + + if fmt == "srt": + return SubtitleWriter(audio_path.with_suffix(".srt"), "srt") + if "ass" in fmt: + return SubtitleWriter(audio_path.with_suffix(".ass"), "ass") + + job.add_log(f"Unsupported subtitle format '{job.subtitle_format}'. Skipping.", level="warning") + return None + + +def _format_timestamp(value: float, ass: bool = False) -> str: + hours = int(value // 3600) + minutes = int((value % 3600) // 60) + seconds = int(value % 60) + milliseconds = int((value - math.floor(value)) * 1000) + if ass: + centiseconds = int(milliseconds / 10) + return f"{hours:d}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}" + return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}" + + +def _make_canceller(job: Job) -> Callable[[], None]: + def _cancel() -> None: + if job.cancel_requested: + raise _JobCancelled + + return _cancel diff --git a/abogen/webui/debug_tts_runner.py b/abogen/webui/debug_tts_runner.py new file mode 100644 index 0000000..abfab4c --- /dev/null +++ b/abogen/webui/debug_tts_runner.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import json +import re +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +import numpy as np + +from abogen.debug_tts_samples import MARKER_PREFIX, MARKER_SUFFIX, build_debug_epub, iter_expected_codes +from abogen.kokoro_text_normalization import normalize_for_pipeline +from abogen.normalization_settings import build_apostrophe_config +from abogen.text_extractor import extract_from_path +from abogen.voice_cache import ensure_voice_assets +from abogen.webui.conversion_runner import SAMPLE_RATE, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids +from abogen.utils import load_numpy_kpipeline + + +_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX)) + + +@dataclass(frozen=True) +class DebugWavArtifact: + label: str + filename: str + code: Optional[str] = None + text: Optional[str] = None + + +def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str]]: + """Resolve settings voice strings into a pipeline-ready voice spec. + + Supports "profile:" by converting it into a concrete voice formula. + Returns (resolved_voice_spec, profile_name, profile_language). + """ + + from abogen.webui.routes.utils.voice import resolve_voice_setting + + return resolve_voice_setting(value) + + +def _load_pipeline(language: str, use_gpu: bool) -> Any: + device = "cpu" + if use_gpu: + device = _select_device() + _np, KPipeline = load_numpy_kpipeline() + return KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device) + + +def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]: + raw = str(text or "") + matches = list(_MARKER_RE.finditer(raw)) + cases: List[Tuple[str, str]] = [] + if not matches: + return cases + for idx, match in enumerate(matches): + code = match.group("code") + start = match.end() + end = matches[idx + 1].start() if idx + 1 < len(matches) else len(raw) + snippet = raw[start:end] + # Keep it small and predictable: collapse whitespace. + snippet = " ".join(snippet.strip().split()) + cases.append((code, snippet)) + return cases + + +def _spoken_id(code: str) -> str: + # Make IDs pronounceable and stable (avoid reading as a word). + out: List[str] = [] + for ch in str(code or ""): + if ch == "_": + out.append(" ") + elif ch.isalnum(): + out.append(ch) + else: + out.append(" ") + # Add spaces between alnum to encourage letter-by-letter reading. + spaced = " ".join("".join(out).split()) + return spaced + + +def run_debug_tts_wavs( + *, + output_root: Path, + settings: Mapping[str, Any], + epub_path: Optional[Path] = None, +) -> Dict[str, Any]: + """Generate WAV artifacts for the debug EPUB samples. + + Writes: + - overall.wav: concatenation of all samples + - case_.wav: each sample rendered separately + - manifest.json: metadata + file list + """ + + output_root = Path(output_root) + output_root.mkdir(parents=True, exist_ok=True) + + run_id = uuid.uuid4().hex + run_dir = output_root / "debug" / run_id + run_dir.mkdir(parents=True, exist_ok=True) + + if epub_path is None: + epub_path = run_dir / "abogen_debug_samples.epub" + build_debug_epub(epub_path) + else: + epub_path = Path(epub_path) + + extraction = extract_from_path(epub_path) + combined_text = extraction.combined_text or "\n\n".join((c.text or "") for c in extraction.chapters) + cases = _extract_cases_from_text(combined_text) + + # Prefer the canonical sample catalog for text (EPUB extraction may include headings). + try: + from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES + + sample_text_by_code = {sample.code: sample.text for sample in DEBUG_TTS_SAMPLES} + except Exception: + sample_text_by_code = {} + + expected = list(iter_expected_codes()) + found_codes = {code for code, _ in cases} + missing = [code for code in expected if code not in found_codes] + if missing: + raise RuntimeError(f"Debug EPUB missing expected codes: {', '.join(missing)}") + + language = str(settings.get("language") or "a").strip() or "a" + # Kokoro's KPipeline expects short language codes like "a" (American English), + # but older settings may store ISO-like values such as "en". + language_aliases = { + "en": "a", + "en-us": "a", + "en_us": "a", + "en-gb": "b", + "en_gb": "b", + "es": "e", + "es-es": "e", + "fr": "f", + "fr-fr": "f", + "hi": "h", + "it": "i", + "pt": "p", + "pt-br": "p", + "ja": "j", + "jp": "j", + "zh": "z", + "zh-cn": "z", + } + language = language_aliases.get(language.lower(), language) + voice_spec = str(settings.get("default_voice") or "").strip() + use_gpu = bool(settings.get("use_gpu", False)) + speed = float(settings.get("default_speed", 1.0) or 1.0) + + # Settings may store "profile:" which is not a Kokoro voice ID. + # Resolve it to a concrete voice formula (e.g. "af_heart*0.5+...") so Kokoro + # doesn't attempt to download a non-existent "voices/profile:.pt". + try: + resolved_voice, _profile_name, profile_language = _resolve_voice_setting(voice_spec) + if resolved_voice: + voice_spec = resolved_voice + if profile_language: + language = str(profile_language).strip() or language + except Exception: + # Voice profile resolution is best-effort; fall back to raw voice_spec. + pass + + # Best-effort voice caching (only for known Kokoro internal voices). + voice_ids = _spec_to_voice_ids(voice_spec) + if voice_ids: + try: + ensure_voice_assets(voice_ids) + except Exception: + # Network / optional dependency variance; debug runner can still proceed. + pass + + pipeline = _load_pipeline(language, use_gpu) + voice_choice = _resolve_voice(pipeline, voice_spec, use_gpu) + + apostrophe_config = build_apostrophe_config(settings=settings) + normalization_settings = dict(settings) + + artifacts: List[DebugWavArtifact] = [] + + overall_path = run_dir / "overall.wav" + overall_audio: List[np.ndarray] = [] + + def synth(text: str, *, apply_normalization: bool = True) -> np.ndarray: + normalized = ( + normalize_for_pipeline( + text, + config=apostrophe_config, + settings=normalization_settings, + ) + if apply_normalization + else str(text or "") + ) + parts: List[np.ndarray] = [] + for segment in pipeline( + normalized, + voice=voice_choice, + speed=speed, + split_pattern=SPLIT_PATTERN, + ): + audio = _to_float32(getattr(segment, "audio", None)) + if audio.size: + parts.append(audio) + if not parts: + return np.zeros(0, dtype="float32") + return np.concatenate(parts).astype("float32", copy=False) + + pause_1s = np.zeros(int(1.0 * SAMPLE_RATE), dtype="float32") + between_cases = np.zeros(int(0.35 * SAMPLE_RATE), dtype="float32") + + # Per sample + for code, snippet in cases: + snippet = sample_text_by_code.get(code, snippet) + if not snippet: + continue + id_audio = synth(_spoken_id(code), apply_normalization=False) + text_audio = synth(snippet, apply_normalization=True) + audio = np.concatenate([id_audio, pause_1s, text_audio]).astype("float32", copy=False) + filename = f"case_{code}.wav" + path = run_dir / filename + # Write float32 PCM WAV. + import soundfile as sf + + sf.write(path, audio, SAMPLE_RATE, subtype="FLOAT") + artifacts.append(DebugWavArtifact(label=f"{code}", filename=filename, code=code, text=snippet)) + overall_audio.append(audio) + overall_audio.append(between_cases) + + # Overall + if overall_audio: + combined = np.concatenate(overall_audio).astype("float32", copy=False) + else: + combined = np.zeros(0, dtype="float32") + import soundfile as sf + + sf.write(overall_path, combined, SAMPLE_RATE, subtype="FLOAT") + artifacts.insert(0, DebugWavArtifact(label="Overall", filename="overall.wav", code=None, text=None)) + + manifest = { + "run_id": run_id, + "epub": str(epub_path), + "artifacts": [artifact.__dict__ for artifact in artifacts], + "sample_rate": SAMPLE_RATE, + } + (run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + return manifest diff --git a/abogen/webui/entrypoint.sh b/abogen/webui/entrypoint.sh new file mode 100755 index 0000000..b11305f --- /dev/null +++ b/abogen/webui/entrypoint.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Entrypoint script for abogen container +# Performs CUDA diagnostics and starts the web server + +set -e + +echo "=== Abogen Container Starting ===" + +# Check CUDA availability +if command -v nvidia-smi &> /dev/null; then + echo "NVIDIA Driver detected:" + nvidia-smi --query-gpu=name,driver_version,memory.total,memory.free --format=csv,noheader 2>/dev/null || echo " (nvidia-smi query failed)" + + # Check PyTorch CUDA support + python3 -c " +import torch +print(f'PyTorch version: {torch.__version__}') +print(f'CUDA available: {torch.cuda.is_available()}') +if torch.cuda.is_available(): + print(f'CUDA version (PyTorch): {torch.version.cuda}') + print(f'GPU count: {torch.cuda.device_count()}') + for i in range(torch.cuda.device_count()): + props = torch.cuda.get_device_properties(i) + print(f' GPU {i}: {props.name} ({props.total_memory // 1024**2} MB)') +else: + print('WARNING: PyTorch cannot access CUDA. Running on CPU.') +" 2>&1 || echo "PyTorch CUDA check failed" +else + echo "No NVIDIA driver detected. Running on CPU." +fi + +echo "=================================" +echo "" + +# Start the application +exec "$@" diff --git a/abogen/webui/routes/__init__.py b/abogen/webui/routes/__init__.py new file mode 100644 index 0000000..6cf27f0 --- /dev/null +++ b/abogen/webui/routes/__init__.py @@ -0,0 +1,18 @@ +from abogen.webui.routes.main import main_bp +from abogen.webui.routes.jobs import jobs_bp +from abogen.webui.routes.settings import settings_bp +from abogen.webui.routes.voices import voices_bp +from abogen.webui.routes.entities import entities_bp +from abogen.webui.routes.books import books_bp +from abogen.webui.routes.api import api_bp + +__all__ = [ + "main_bp", + "jobs_bp", + "settings_bp", + "voices_bp", + "entities_bp", + "books_bp", + "api_bp", +] + diff --git a/abogen/webui/routes/api.py b/abogen/webui/routes/api.py new file mode 100644 index 0000000..f02ab9d --- /dev/null +++ b/abogen/webui/routes/api.py @@ -0,0 +1,680 @@ +from typing import Any, Dict, Mapping, List, Optional +import base64 +import uuid +from pathlib import Path + +from flask import Blueprint, request, jsonify, send_file, url_for, current_app +from flask.typing import ResponseReturnValue + +from abogen.webui.routes.utils.settings import ( + load_settings, + load_integration_settings, + coerce_float, + coerce_bool, + audiobookshelf_settings_from_payload, + calibre_settings_from_payload, +) +from abogen.voice_profiles import ( + load_profiles, + save_profiles, + delete_profile, + duplicate_profile, + serialize_profiles, + import_profiles_data, + export_profiles_payload, + normalize_profile_entry, +) +from abogen.webui.routes.utils.common import split_profile_spec +from abogen.webui.routes.utils.preview import synthesize_preview, generate_preview_audio +from abogen.webui.routes.utils.voice import formula_from_profile +from abogen.normalization_settings import ( + build_llm_configuration, + build_apostrophe_config, + apply_overrides, +) +from abogen.llm_client import list_models, LLMClientError +from abogen.kokoro_text_normalization import normalize_for_pipeline +from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig +from abogen.integrations.calibre_opds import ( + CalibreOPDSClient, + CalibreOPDSError, +) +from abogen.webui.routes.utils.service import get_service +from abogen.webui.routes.utils.form import build_pending_job_from_extraction +from abogen.text_extractor import extract_from_path +from werkzeug.utils import secure_filename + +api_bp = Blueprint("api", __name__) + +# --- Voice Profile Routes --- + +@api_bp.get("/voice-profiles") +def api_get_voice_profiles() -> ResponseReturnValue: + profiles = load_profiles() + return jsonify(profiles) + +@api_bp.post("/voice-profiles") +def api_save_voice_profile() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=True) or {} + name = str(payload.get("name") or "").strip() + original_name = str(payload.get("originalName") or "").strip() or None + + profile = payload.get("profile") + if profile is None: + # Speaker Studio payload format + provider = str(payload.get("provider") or "kokoro").strip().lower() + if provider not in {"kokoro", "supertonic"}: + provider = "kokoro" + if provider == "supertonic": + profile = { + "provider": "supertonic", + "language": str(payload.get("language") or "a").strip().lower() or "a", + "voice": payload.get("voice"), + "total_steps": payload.get("total_steps") or payload.get("supertonic_total_steps"), + "speed": payload.get("speed") or payload.get("supertonic_speed"), + } + else: + profile = { + "provider": "kokoro", + "language": str(payload.get("language") or "a").strip().lower() or "a", + "voices": payload.get("voices") or [], + } + + if not name or not profile: + return jsonify({"error": "Name and profile are required"}), 400 + + profiles = load_profiles() + + normalized = normalize_profile_entry(profile) + if not normalized: + return jsonify({"error": "Invalid profile payload"}), 400 + + if original_name and original_name in profiles and original_name != name: + del profiles[original_name] + + profiles[name] = normalized + save_profiles(profiles) + + return jsonify({"success": True, "profile": name, "profiles": serialize_profiles()}) + +@api_bp.delete("/voice-profiles/") +def api_delete_voice_profile(name: str) -> ResponseReturnValue: + delete_profile(name) + return jsonify({"success": True, "profiles": serialize_profiles()}) + + +@api_bp.post("/voice-profiles//duplicate") +def api_duplicate_voice_profile(name: str) -> ResponseReturnValue: + payload = request.get_json(force=True, silent=True) or {} + new_name = str(payload.get("name") or "").strip() + if not new_name: + return jsonify({"error": "Name is required"}), 400 + duplicate_profile(name, new_name) + return jsonify({"success": True, "profile": new_name, "profiles": serialize_profiles()}) + + +@api_bp.post("/voice-profiles/import") +def api_import_voice_profiles() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=True) or {} + data = payload.get("data") + replace_existing = bool(payload.get("replace_existing")) + if not isinstance(data, dict): + return jsonify({"error": "Invalid profile payload"}), 400 + try: + imported = import_profiles_data(data, replace_existing=replace_existing) + except Exception as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify({"success": True, "imported": imported, "profiles": serialize_profiles()}) + + +@api_bp.get("/voice-profiles/export") +def api_export_voice_profiles() -> ResponseReturnValue: + names_param = request.args.get("names") + names = None + if names_param: + names = [item.strip() for item in names_param.split(",") if item.strip()] + payload = export_profiles_payload(names) + import io + import json + + data = json.dumps(payload, indent=2).encode("utf-8") + filename = "voice_profiles.json" if not names else "voice_profiles_export.json" + return send_file( + io.BytesIO(data), + mimetype="application/json", + as_attachment=True, + download_name=filename, + ) + + +@api_bp.post("/voice-profiles/preview") +def api_voice_profiles_preview() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=True) or {} + text = str(payload.get("text") or "").strip() or "Hello world" + language = str(payload.get("language") or "a").strip().lower() or "a" + speed = coerce_float(payload.get("speed"), 1.0) + max_seconds = coerce_float(payload.get("max_seconds"), 8.0) + + settings = load_settings() + use_gpu = settings.get("use_gpu", False) + + # Accept a direct formula string or a full profile entry. + formula = str(payload.get("formula") or "").strip() + profile_name = str(payload.get("profile") or "").strip() + provider = str(payload.get("tts_provider") or payload.get("provider") or "").strip().lower() or None + supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or settings.get("supertonic_total_steps") or 5) + + voice_spec = "" + resolved_provider = provider or "kokoro" + + profiles = load_profiles() + if resolved_provider == "supertonic" and not profile_name: + voice_spec = str(payload.get("voice") or payload.get("supertonic_voice") or "M1").strip() or "M1" + # Allow per-speaker overrides via payload. + supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or supertonic_total_steps) + speed = coerce_float(payload.get("supertonic_speed") or payload.get("speed"), speed) + elif profile_name: + entry = profiles.get(profile_name) + normalized_entry = normalize_profile_entry(entry) + if not normalized_entry: + return jsonify({"error": "Unknown profile"}), 404 + resolved_provider = str(normalized_entry.get("provider") or "kokoro") + if resolved_provider == "supertonic": + voice_spec = str(normalized_entry.get("voice") or "M1") + supertonic_total_steps = int(normalized_entry.get("total_steps") or supertonic_total_steps) + speed = float(normalized_entry.get("speed") or speed) + else: + voice_spec = formula_from_profile(normalized_entry) or "" + language = str(normalized_entry.get("language") or language) + elif formula: + voice_spec = formula + resolved_provider = "kokoro" + else: + # Raw voices payload -> Kokoro mix. + voices = payload.get("voices") or [] + pseudo = {"provider": "kokoro", "language": language, "voices": voices} + normalized_entry = normalize_profile_entry(pseudo) + voice_spec = formula_from_profile(normalized_entry) or "" + resolved_provider = "kokoro" + + if not voice_spec: + return jsonify({"error": "Unable to resolve preview voice"}), 400 + + try: + return synthesize_preview( + text=text, + voice_spec=voice_spec, + language=language, + speed=speed, + use_gpu=use_gpu, + tts_provider=resolved_provider, + supertonic_total_steps=supertonic_total_steps, + max_seconds=max_seconds, + ) + except Exception as exc: + return jsonify({"error": str(exc)}), 500 + +@api_bp.post("/speaker-preview") +def api_speaker_preview() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=True) or {} + pending_id = str(payload.get("pending_id") or "").strip() + text = payload.get("text", "Hello world") + voice = payload.get("voice", "af_heart") + language = payload.get("language", "a") + speed_value = payload.get("speed") + speed = coerce_float(speed_value, 1.0) + tts_provider = str(payload.get("tts_provider") or "").strip().lower() + supertonic_total_steps = int(payload.get("supertonic_total_steps") or 5) + + settings = load_settings() + use_gpu = settings.get("use_gpu", False) + + base_spec, speaker_name = split_profile_spec(voice) + resolved_provider = tts_provider if tts_provider in {"kokoro", "supertonic"} else "" + + if speaker_name: + entry = normalize_profile_entry(load_profiles().get(speaker_name)) + if entry: + resolved_provider = str(entry.get("provider") or resolved_provider or "") + if resolved_provider == "supertonic": + voice = str(entry.get("voice") or "M1") + supertonic_total_steps = int(entry.get("total_steps") or supertonic_total_steps) + if speed_value is None: + speed = coerce_float(entry.get("speed"), speed) + elif resolved_provider == "kokoro": + voice = formula_from_profile(entry) or (base_spec or voice) + + if not resolved_provider: + resolved_provider = "supertonic" if str(base_spec or "").strip() in {"M1","M2","M3","M4","M5","F1","F2","F3","F4","F5"} else "kokoro" + + pronunciation_overrides = None + manual_overrides = None + speakers = None + if pending_id: + try: + pending = get_service().get_pending_job(pending_id) + except Exception: + pending = None + if pending is not None: + manual_overrides = getattr(pending, "manual_overrides", None) + pronunciation_overrides = getattr(pending, "pronunciation_overrides", None) + speakers = getattr(pending, "speakers", None) + + try: + return synthesize_preview( + text=text, + voice_spec=voice, + language=language, + speed=speed, + use_gpu=use_gpu + , + tts_provider=resolved_provider, + supertonic_total_steps=supertonic_total_steps or int(settings.get("supertonic_total_steps") or 5), + pronunciation_overrides=pronunciation_overrides, + manual_overrides=manual_overrides, + speakers=speakers, + ) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +# --- Integration Routes --- + + +def _opds_metadata_overrides(metadata_payload: Mapping[str, Any]) -> Dict[str, Any]: + metadata_overrides: Dict[str, Any] = {} + + def _stringify_metadata_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (list, tuple, set)): + parts = [str(item).strip() for item in value if item is not None] + parts = [part for part in parts if part] + return ", ".join(parts) + return str(value).strip() + + raw_series = metadata_payload.get("series") or metadata_payload.get("series_name") + series_name = str(raw_series or "").strip() + if series_name: + metadata_overrides["series"] = series_name + metadata_overrides.setdefault("series_name", series_name) + + series_index_value = ( + metadata_payload.get("series_index") + or metadata_payload.get("series_position") + or metadata_payload.get("series_sequence") + or metadata_payload.get("book_number") + ) + if series_index_value is not None: + series_index_text = str(series_index_value).strip() + if series_index_text: + metadata_overrides.setdefault("series_index", series_index_text) + metadata_overrides.setdefault("series_position", series_index_text) + metadata_overrides.setdefault("series_sequence", series_index_text) + metadata_overrides.setdefault("book_number", series_index_text) + + tags_value = metadata_payload.get("tags") or metadata_payload.get("keywords") + if tags_value: + tags_text = _stringify_metadata_value(tags_value) + if tags_text: + metadata_overrides.setdefault("tags", tags_text) + metadata_overrides.setdefault("keywords", tags_text) + metadata_overrides.setdefault("genre", tags_text) + + description_value = metadata_payload.get("description") or metadata_payload.get("summary") + if description_value: + description_text = _stringify_metadata_value(description_value) + if description_text: + metadata_overrides.setdefault("description", description_text) + metadata_overrides.setdefault("summary", description_text) + + subtitle_value = ( + metadata_payload.get("subtitle") + or metadata_payload.get("sub_title") + or metadata_payload.get("calibre_subtitle") + ) + if subtitle_value: + subtitle_text = _stringify_metadata_value(subtitle_value) + if subtitle_text: + metadata_overrides.setdefault("subtitle", subtitle_text) + + publisher_value = metadata_payload.get("publisher") + if publisher_value: + publisher_text = _stringify_metadata_value(publisher_value) + if publisher_text: + metadata_overrides.setdefault("publisher", publisher_text) + + # Author mapping: Abogen templates look for either 'authors' or 'author'. + authors_value = ( + metadata_payload.get("authors") + or metadata_payload.get("author") + or metadata_payload.get("creator") + or metadata_payload.get("dc_creator") + ) + if authors_value: + authors_text = _stringify_metadata_value(authors_value) + if authors_text: + metadata_overrides.setdefault("authors", authors_text) + metadata_overrides.setdefault("author", authors_text) + + return metadata_overrides + +@api_bp.get("/integrations/calibre-opds/feed") +def api_calibre_opds_feed() -> ResponseReturnValue: + integrations = load_integration_settings() + calibre_settings = integrations.get("calibre_opds", {}) + + payload = { + "base_url": calibre_settings.get("base_url"), + "username": calibre_settings.get("username"), + "password": calibre_settings.get("password"), + "verify_ssl": calibre_settings.get("verify_ssl", True), + } + + if not payload.get("base_url"): + return jsonify({"error": "Calibre OPDS base URL is not configured."}), 400 + + try: + client = CalibreOPDSClient( + base_url=payload.get("base_url") or "", + username=payload.get("username"), + password=payload.get("password"), + verify=bool(payload.get("verify_ssl", True)), + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + href = request.args.get("href", type=str) + query = request.args.get("q", type=str) + letter = request.args.get("letter", type=str) + + try: + if letter: + feed = client.browse_letter(letter, start_href=href) + elif query: + feed = client.search(query, start_href=href) + else: + feed = client.fetch_feed(href) + except CalibreOPDSError as exc: + return jsonify({"error": str(exc)}), 502 + except Exception as exc: + return jsonify({"error": f"Unexpected error: {str(exc)}"}), 500 + + return jsonify({ + "feed": feed.to_dict(), + "href": href or "", + "query": query or "", + }) + +@api_bp.post("/integrations/audiobookshelf/folders") +def api_abs_folders() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=True) or {} + # Use the helper to resolve saved tokens when use_saved_token is set + settings = audiobookshelf_settings_from_payload(payload) + host = settings.get("base_url") + token = settings.get("api_token") + library_id = settings.get("library_id") + + if not host or not token: + return jsonify({"error": "Base URL and API token are required"}), 400 + + if not library_id: + return jsonify({"error": "Library ID is required to list folders"}), 400 + + try: + config = AudiobookshelfConfig(base_url=host, api_token=token, library_id=library_id) + client = AudiobookshelfClient(config) + folders = client.list_folders() + return jsonify({"folders": folders}) + except Exception as e: + return jsonify({"error": str(e)}), 400 + +@api_bp.post("/integrations/audiobookshelf/test") +def api_abs_test() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=True) or {} + # Use the helper to resolve saved tokens when use_saved_token is set + settings = audiobookshelf_settings_from_payload(payload) + host = settings.get("base_url") + token = settings.get("api_token") + + if not host or not token: + return jsonify({"error": "Base URL and API token are required"}), 400 + + try: + config = AudiobookshelfConfig(base_url=host, api_token=token) + client = AudiobookshelfClient(config) + # Just getting libraries is a good enough test + client.get_libraries() + return jsonify({"success": True, "message": "Connection successful."}) + except Exception as e: + return jsonify({"error": str(e)}), 400 + +@api_bp.post("/integrations/calibre-opds/test") +def api_calibre_opds_test() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=True) or {} + # Use the helper to resolve saved passwords when use_saved_password is set + settings = calibre_settings_from_payload(payload) + base_url = settings.get("base_url") + username = settings.get("username") + password = settings.get("password") + verify_ssl = settings.get("verify_ssl", False) + + if not base_url: + return jsonify({"error": "Base URL is required"}), 400 + + try: + client = CalibreOPDSClient( + base_url=base_url, + username=username, + password=password, + verify=verify_ssl, + timeout=10.0 + ) + client.fetch_feed() + return jsonify({"success": True, "message": "Connection successful."}) + except Exception as e: + return jsonify({"error": str(e)}), 400 + +@api_bp.post("/integrations/calibre-opds/import") +def api_calibre_opds_import() -> ResponseReturnValue: + if not request.is_json: + return jsonify({"error": "Expected JSON payload."}), 400 + + data = request.get_json(force=True, silent=True) or {} + href = str(data.get("href") or "").strip() + + if not href: + return jsonify({"error": "Download URL (href) is required."}), 400 + + metadata_payload = data.get("metadata") if isinstance(data, Mapping) else None + metadata_overrides: Dict[str, Any] = {} + if isinstance(metadata_payload, Mapping): + metadata_overrides = _opds_metadata_overrides(metadata_payload) + + settings = load_settings() + integrations = load_integration_settings() + calibre_settings = integrations.get("calibre_opds", {}) + + try: + client = CalibreOPDSClient( + base_url=calibre_settings.get("base_url") or "", + username=calibre_settings.get("username"), + password=calibre_settings.get("password"), + verify=bool(calibre_settings.get("verify_ssl", True)), + ) + + temp_dir = Path(current_app.config.get("UPLOAD_FOLDER", "uploads")) + temp_dir.mkdir(exist_ok=True) + + resource = client.download(href) + filename = resource.filename + content = resource.content + + if not filename: + filename = f"{uuid.uuid4().hex}.epub" + + file_path = temp_dir / f"{uuid.uuid4().hex}_{filename}" + file_path.write_bytes(content) + + extraction = extract_from_path(file_path) + + if metadata_overrides: + extraction.metadata.update(metadata_overrides) + + result = build_pending_job_from_extraction( + stored_path=file_path, + original_name=filename, + extraction=extraction, + form={}, + settings=settings, + profiles=serialize_profiles(), + metadata_overrides=metadata_overrides, + ) + + get_service().store_pending_job(result.pending) + + return jsonify({ + "success": True, + "status": "imported", + "pending_id": result.pending.id, + "redirect_url": url_for("main.wizard_step", step="book", pending_id=result.pending.id) + }) + + except Exception as e: + return jsonify({"error": str(e)}), 500 + +# --- LLM Routes --- + +@api_bp.post("/llm/models") +def api_llm_models() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=False) or {} + current_settings = load_settings() + + base_url = str(payload.get("base_url") or payload.get("llm_base_url") or current_settings.get("llm_base_url") or "").strip() + if not base_url: + return jsonify({"error": "LLM base URL is required."}), 400 + + api_key = str(payload.get("api_key") or payload.get("llm_api_key") or current_settings.get("llm_api_key") or "") + timeout = coerce_float(payload.get("timeout"), current_settings.get("llm_timeout", 30.0)) + + overrides = { + "llm_base_url": base_url, + "llm_api_key": api_key, + "llm_timeout": timeout, + } + + merged = apply_overrides(current_settings, overrides) + configuration = build_llm_configuration(merged) + try: + models = list_models(configuration) + except LLMClientError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify({"models": models}) + +@api_bp.post("/llm/preview") +def api_llm_preview() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=False) or {} + sample_text = str(payload.get("text") or "").strip() + if not sample_text: + return jsonify({"error": "Text is required."}), 400 + + base_settings = load_settings() + overrides: Dict[str, Any] = { + "llm_base_url": str( + payload.get("base_url") + or payload.get("llm_base_url") + or base_settings.get("llm_base_url") + or "" + ).strip(), + "llm_api_key": str( + payload.get("api_key") + or payload.get("llm_api_key") + or base_settings.get("llm_api_key") + or "" + ), + "llm_model": str( + payload.get("model") + or payload.get("llm_model") + or base_settings.get("llm_model") + or "" + ), + "llm_prompt": payload.get("prompt") or payload.get("llm_prompt") or base_settings.get("llm_prompt"), + "llm_context_mode": payload.get("context_mode") or base_settings.get("llm_context_mode"), + "llm_timeout": coerce_float(payload.get("timeout"), base_settings.get("llm_timeout", 30.0)), + "normalization_apostrophe_mode": "llm", + } + + merged = apply_overrides(base_settings, overrides) + if not merged.get("llm_base_url"): + return jsonify({"error": "LLM base URL is required."}), 400 + if not merged.get("llm_model"): + return jsonify({"error": "Select an LLM model before previewing."}), 400 + + apostrophe_config = build_apostrophe_config(settings=merged) + try: + normalized_text = normalize_for_pipeline(sample_text, config=apostrophe_config, settings=merged) + except LLMClientError as exc: + return jsonify({"error": str(exc)}), 400 + + context = { + "text": sample_text, + "normalized_text": normalized_text, + } + return jsonify(context) + +# --- Normalization Routes --- + +@api_bp.post("/normalization/preview") +def api_normalization_preview() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=False) or {} + sample_text = str(payload.get("text") or "").strip() + if not sample_text: + return jsonify({"error": "Sample text is required."}), 400 + + base_settings = load_settings() + # We might want to apply overrides from payload if any normalization settings are passed + # For now, just use base settings as in original code (presumably) + + apostrophe_config = build_apostrophe_config(settings=base_settings) + try: + normalized_text = normalize_for_pipeline(sample_text, config=apostrophe_config, settings=base_settings) + except Exception as exc: + return jsonify({"error": str(exc)}), 400 + + return jsonify({ + "text": sample_text, + "normalized_text": normalized_text, + }) + +@api_bp.post("/entity-pronunciation/preview") +def api_entity_pronunciation_preview() -> ResponseReturnValue: + payload = request.get_json(force=True, silent=True) or {} + token = payload.get("token", "").strip() + pronunciation = payload.get("pronunciation", "").strip() + voice = payload.get("voice", "").strip() + language = payload.get("language", "a").strip() + + if not token and not pronunciation: + return jsonify({"error": "Token or pronunciation required"}), 400 + + text_to_speak = pronunciation if pronunciation else token + + if not voice: + settings = load_settings() + voice = settings.get("default_voice", "af_heart") + + try: + # Check GPU setting + settings = load_settings() + use_gpu = coerce_bool(settings.get("use_gpu"), False) + + audio_bytes = generate_preview_audio( + text=text_to_speak, + voice_spec=voice, + language=language, + speed=1.0, + use_gpu=use_gpu, + ) + audio_base64 = base64.b64encode(audio_bytes).decode("utf-8") + return jsonify({"audio_base64": audio_base64}) + except Exception as e: + return jsonify({"error": str(e)}), 400 diff --git a/abogen/webui/routes/books.py b/abogen/webui/routes/books.py new file mode 100644 index 0000000..e0bfa51 --- /dev/null +++ b/abogen/webui/routes/books.py @@ -0,0 +1,34 @@ +from typing import Any, Dict + +from flask import Blueprint, render_template +from flask.typing import ResponseReturnValue + +from abogen.webui.routes.utils.settings import ( + load_settings, + load_integration_settings, +) +from abogen.webui.routes.utils.voice import template_options + +books_bp = Blueprint("books", __name__) + +def _calibre_integration_enabled(integrations: Dict[str, Any]) -> bool: + calibre = integrations.get("calibre_opds", {}) + return bool(calibre.get("enabled") and calibre.get("base_url")) + +@books_bp.get("/") +def find_books_page() -> ResponseReturnValue: + settings = load_settings() + integrations = load_integration_settings() + return render_template( + "find_books.html", + integrations=integrations, + opds_available=_calibre_integration_enabled(integrations), + options=template_options(), + settings=settings, + ) + +@books_bp.get("/search") +def search_books() -> ResponseReturnValue: + return find_books_page() + + diff --git a/abogen/webui/routes/entities.py b/abogen/webui/routes/entities.py new file mode 100644 index 0000000..44bd420 --- /dev/null +++ b/abogen/webui/routes/entities.py @@ -0,0 +1,175 @@ +from typing import Mapping +from flask import Blueprint, request, jsonify, abort, render_template, redirect, url_for +from flask.typing import ResponseReturnValue + +from abogen.webui.routes.utils.service import require_pending_job, get_service +from abogen.webui.routes.utils.entity import ( + refresh_entity_summary, + pending_entities_payload, + upsert_manual_override, + delete_manual_override, + search_manual_override_candidates, +) +from abogen.webui.routes.utils.settings import coerce_int, load_settings +from abogen.webui.routes.utils.voice import template_options +from abogen.pronunciation_store import ( + delete_override as delete_pronunciation_override, + save_override as save_pronunciation_override, + get_override_stats, + all_overrides, +) + +entities_bp = Blueprint("entities", __name__) + +@entities_bp.post("/analyze") +def analyze_entities() -> ResponseReturnValue: + # This might be triggered via wizard update, but if there's a specific route: + # In original routes.py, it was likely part of wizard logic or API. + # I'll assume this is for the API endpoint /api/pending//entities/refresh + pending_id = request.form.get("pending_id") or request.args.get("pending_id") + if not pending_id: + abort(400, "Pending ID required") + + pending = require_pending_job(pending_id) + refresh_entity_summary(pending, pending.chapters) + get_service().store_pending_job(pending) + return jsonify(pending_entities_payload(pending)) + +@entities_bp.get("/pending/") +def get_entities(pending_id: str) -> ResponseReturnValue: + pending = require_pending_job(pending_id) + refresh_flag = (request.args.get("refresh") or "").strip().lower() + expected_cache = (request.args.get("cache_key") or "").strip() + refresh_requested = refresh_flag in {"1", "true", "yes", "force"} + + if expected_cache and expected_cache != (pending.entity_cache_key or ""): + refresh_requested = True + + if refresh_requested or not pending.entity_summary: + refresh_entity_summary(pending, pending.chapters) + get_service().store_pending_job(pending) + + return jsonify(pending_entities_payload(pending)) + +@entities_bp.post("/pending//refresh") +def refresh_entities(pending_id: str) -> ResponseReturnValue: + pending = require_pending_job(pending_id) + refresh_entity_summary(pending, pending.chapters) + get_service().store_pending_job(pending) + return jsonify(pending_entities_payload(pending)) + +@entities_bp.get("/pending//overrides") +def list_manual_overrides(pending_id: str) -> ResponseReturnValue: + pending = require_pending_job(pending_id) + return jsonify({ + "overrides": pending.manual_overrides or [], + "pronunciation_overrides": pending.pronunciation_overrides or [], + "heteronym_overrides": getattr(pending, "heteronym_overrides", None) or [], + "language": pending.language or "en", + }) + +@entities_bp.post("/pending//overrides") +def upsert_override(pending_id: str) -> ResponseReturnValue: + pending = require_pending_job(pending_id) + payload = request.get_json(silent=True) or {} + if not isinstance(payload, Mapping): + abort(400, "Invalid override payload") + + try: + override = upsert_manual_override(pending, payload) + except ValueError as exc: + abort(400, str(exc)) + + get_service().store_pending_job(pending) + return jsonify({"override": override, **pending_entities_payload(pending)}) + +@entities_bp.delete("/pending//overrides/") +def delete_override(pending_id: str, override_id: str) -> ResponseReturnValue: + pending = require_pending_job(pending_id) + deleted = delete_manual_override(pending, override_id) + if not deleted: + abort(404) + + get_service().store_pending_job(pending) + return jsonify({"deleted": True, **pending_entities_payload(pending)}) + +@entities_bp.get("/pending//overrides/search") +def search_candidates(pending_id: str) -> ResponseReturnValue: + pending = require_pending_job(pending_id) + query = (request.args.get("q") or request.args.get("query") or "").strip() + limit_param = request.args.get("limit") + limit_value = coerce_int(limit_param, 15, minimum=1, maximum=50) if limit_param is not None else 15 + + results = search_manual_override_candidates(pending, query, limit=limit_value) + return jsonify({"query": query, "limit": limit_value, "results": results}) + +@entities_bp.post("/overrides") +def upsert_global_override() -> ResponseReturnValue: + payload = request.form + action = payload.get("action", "save") + lang = payload.get("lang", "en") + token = payload.get("token", "").strip() + + if action == "delete": + if token: + delete_pronunciation_override(token=token, language=lang) + else: + pronunciation = payload.get("pronunciation", "").strip() + voice = payload.get("voice", "").strip() + if token: + save_pronunciation_override( + token=token, + pronunciation=pronunciation, + voice=voice or None, + language=lang + ) + + return redirect(url_for("entities.entities_page", lang=lang)) + +@entities_bp.get("/") +def entities_page() -> str: + settings = load_settings() + lang = request.args.get("lang") or settings.get("language", "en") + voice_filter = request.args.get("voice", "") + pronunciation_filter = request.args.get("pronunciation", "") + + options = template_options() + stats = get_override_stats(lang) + + overrides = all_overrides(lang) + + if voice_filter == "assigned": + overrides = [o for o in overrides if o.get("voice")] + elif voice_filter == "unassigned": + overrides = [o for o in overrides if not o.get("voice")] + + if pronunciation_filter == "defined": + overrides = [o for o in overrides if o.get("pronunciation")] + elif pronunciation_filter == "undefined": + overrides = [o for o in overrides if not o.get("pronunciation")] + + voice_filter_options = [ + {"value": "", "label": "All voices"}, + {"value": "assigned", "label": "Assigned"}, + {"value": "unassigned", "label": "Unassigned"}, + ] + pronunciation_filter_options = [ + {"value": "", "label": "All pronunciations"}, + {"value": "defined", "label": "Defined"}, + {"value": "undefined", "label": "Undefined"}, + ] + + language_label = options["languages"].get(lang, lang) + return render_template( + "entities.html", + language=lang, + language_label=language_label, + options=options, + languages=options["languages"].items(), + stats=stats, + overrides=overrides, + voice_filter=voice_filter, + pronunciation_filter=pronunciation_filter, + voice_filter_options=voice_filter_options, + pronunciation_filter_options=pronunciation_filter_options, + ) diff --git a/abogen/webui/routes/jobs.py b/abogen/webui/routes/jobs.py new file mode 100644 index 0000000..545db76 --- /dev/null +++ b/abogen/webui/routes/jobs.py @@ -0,0 +1,305 @@ +import json +import logging +from pathlib import Path +from typing import Any, Dict, Optional + +from flask import Blueprint, Response, abort, redirect, render_template, request, url_for, send_file +from flask.typing import ResponseReturnValue + +from abogen.webui.service import ( + JobStatus, + load_audiobookshelf_chapters, + build_audiobookshelf_metadata, +) +from abogen.webui.routes.utils.service import get_service +from abogen.webui.routes.utils.form import render_jobs_panel +from abogen.webui.routes.utils.voice import template_options +from abogen.webui.routes.utils.epub import ( + job_download_flags, + locate_job_epub, + locate_job_audio, +) +from abogen.webui.routes.utils.settings import ( + stored_integration_config, + build_audiobookshelf_config, + coerce_bool, +) +from abogen.webui.routes.utils.common import existing_paths +from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfUploadError + +logger = logging.getLogger(__name__) + +jobs_bp = Blueprint("jobs", __name__) + +@jobs_bp.get("/") +def job_detail(job_id: str) -> ResponseReturnValue: + job = get_service().get_job(job_id) + if not job: + # Return a friendly page instead of 404 to avoid confusion from stale browser tabs + return render_template("job_not_found.html"), 200 + return render_template( + "job_detail.html", + job=job, + options=template_options(), + JobStatus=JobStatus, + downloads=job_download_flags(job), + ) + +@jobs_bp.post("//pause") +def pause_job(job_id: str) -> ResponseReturnValue: + get_service().pause(job_id) + if request.headers.get("HX-Request"): + return render_jobs_panel() + return redirect(url_for("jobs.job_detail", job_id=job_id)) + +@jobs_bp.post("//resume") +def resume_job(job_id: str) -> ResponseReturnValue: + get_service().resume(job_id) + if request.headers.get("HX-Request"): + return render_jobs_panel() + return redirect(url_for("jobs.job_detail", job_id=job_id)) + +@jobs_bp.post("//cancel") +def cancel_job(job_id: str) -> ResponseReturnValue: + get_service().cancel(job_id) + if request.headers.get("HX-Request"): + return render_jobs_panel() + return redirect(url_for("jobs.job_detail", job_id=job_id)) + +@jobs_bp.post("//delete") +def delete_job(job_id: str) -> ResponseReturnValue: + get_service().delete(job_id) + if request.headers.get("HX-Request"): + return render_jobs_panel() + return redirect(url_for("main.index")) + +@jobs_bp.post("//retry") +def retry_job(job_id: str) -> ResponseReturnValue: + new_job = get_service().retry(job_id) + if request.headers.get("HX-Request"): + return render_jobs_panel() + if new_job: + return redirect(url_for("jobs.job_detail", job_id=new_job.id)) + return redirect(url_for("jobs.job_detail", job_id=job_id)) + +@jobs_bp.post("//audiobookshelf") +def send_job_to_audiobookshelf(job_id: str) -> ResponseReturnValue: + service = get_service() + job = service.get_job(job_id) + if job is None: + abort(404) + + def _panel_response() -> ResponseReturnValue: + if request.headers.get("HX-Request"): + return render_jobs_panel() + return redirect(url_for("jobs.job_detail", job_id=job.id)) + + if job.status != JobStatus.COMPLETED: + return _panel_response() + + settings = stored_integration_config("audiobookshelf") + if not settings or not coerce_bool(settings.get("enabled"), False): + job.add_log("Audiobookshelf upload skipped: integration is disabled.", level="warning") + service._persist_state() + return _panel_response() + + config = build_audiobookshelf_config(settings) + if config is None: + job.add_log( + "Audiobookshelf upload skipped: configure base URL, API token, and library ID first.", + level="warning", + ) + service._persist_state() + return _panel_response() + if not config.folder_id: + job.add_log( + "Audiobookshelf upload skipped: enter the folder name or ID in the Audiobookshelf settings.", + level="warning", + ) + service._persist_state() + return _panel_response() + + audio_path = locate_job_audio(job) + if not audio_path or not audio_path.exists(): + job.add_log("Audiobookshelf upload skipped: audio output not found.", level="warning") + service._persist_state() + return _panel_response() + + cover_path = None + if config.send_cover and job.cover_image_path: + cover_candidate = job.cover_image_path + if not isinstance(cover_candidate, Path): + cover_candidate = Path(str(cover_candidate)) + if cover_candidate.exists(): + cover_path = cover_candidate + + subtitles = existing_paths(job.result.subtitle_paths) if config.send_subtitles else None + chapters = load_audiobookshelf_chapters(job) if config.send_chapters else None + metadata = build_audiobookshelf_metadata(job) + display_title = metadata.get("title") or audio_path.stem + overwrite_requested = request.form.get("overwrite") == "true" or request.args.get("overwrite") == "true" + + try: + client = AudiobookshelfClient(config) + except ValueError as exc: + job.add_log(f"Audiobookshelf configuration error: {exc}", level="error") + service._persist_state() + return _panel_response() + + try: + existing_items = client.find_existing_items(display_title, folder_id=config.folder_id) + except AudiobookshelfUploadError as exc: + job.add_log(f"Audiobookshelf lookup failed: {exc}", level="error") + service._persist_state() + return _panel_response() + + if existing_items and not overwrite_requested: + job.add_log( + f"Audiobookshelf already contains '{display_title}'. Awaiting overwrite confirmation.", + level="warning", + ) + service._persist_state() + if request.headers.get("HX-Request"): + detail = { + "jobId": job.id, + "title": display_title, + "url": url_for("jobs.send_job_to_audiobookshelf", job_id=job.id), + "target": request.headers.get("HX-Target") or "#jobs-panel", + "message": f'Audiobookshelf already contains "{display_title}". Overwrite?', + } + headers = {"HX-Trigger": json.dumps({"audiobookshelf-overwrite-prompt": detail})} + return Response("", status=204, headers=headers) + return _panel_response() + + if existing_items and overwrite_requested: + try: + client.delete_items(existing_items) + except AudiobookshelfUploadError as exc: + job.add_log(f"Audiobookshelf overwrite aborted: {exc}", level="error") + service._persist_state() + return _panel_response() + else: + job.add_log( + f"Removed {len(existing_items)} existing Audiobookshelf item(s) prior to overwrite.", + level="info", + ) + + job.add_log("Audiobookshelf upload triggered manually.", level="info") + try: + client.upload_audiobook( + audio_path, + metadata=metadata, + cover_path=cover_path, + chapters=chapters, + subtitles=subtitles, + ) + except AudiobookshelfUploadError as exc: + job.add_log(f"Audiobookshelf upload failed: {exc}", level="error") + except Exception as exc: + job.add_log(f"Audiobookshelf integration error: {exc}", level="error") + else: + job.add_log("Audiobookshelf upload queued.", level="success") + finally: + service._persist_state() + + return _panel_response() + +@jobs_bp.post("/clear-finished") +def clear_finished_jobs() -> ResponseReturnValue: + get_service().clear_finished() + if request.headers.get("HX-Request"): + return render_jobs_panel() + return redirect(url_for("main.index", _anchor="queue")) + +@jobs_bp.get("//epub") +def job_epub(job_id: str) -> ResponseReturnValue: + job = get_service().get_job(job_id) + if job is None or job.status != JobStatus.COMPLETED: + abort(404) + epub_path = locate_job_epub(job) + if not epub_path: + abort(404) + return send_file( + epub_path, + as_attachment=True, + download_name=epub_path.name, + mimetype="application/epub+zip", + ) + +@jobs_bp.get("//download/") +def download_file(job_id: str, file_type: str) -> ResponseReturnValue: + job = get_service().get_job(job_id) + if not job or job.status != JobStatus.COMPLETED: + abort(404) + + if file_type == "audio": + path = locate_job_audio(job) + if not path or not path.exists(): + abort(404) + return send_file( + path, + as_attachment=True, + download_name=path.name, + ) + + # Handle other file types if needed (subtitles, etc.) + # For now, just audio and epub are explicitly handled + abort(404) + +@jobs_bp.get("//logs") +def job_logs(job_id: str) -> ResponseReturnValue: + job = get_service().get_job(job_id) + if not job: + # Return a simple page instead of 404 to avoid log spam from stale browser tabs + return render_template("job_logs_missing.html"), 200 + return render_template("job_logs_static.html", job=job) + + +@jobs_bp.get("//logs/partial") +def job_logs_partial(job_id: str) -> ResponseReturnValue: + job = get_service().get_job(job_id) + if not job: + # Return a non-polling section so HTMX stops retrying. + return render_template("partials/logs_section_missing.html"), 200 + return render_template("partials/logs_section.html", job=job) + +@jobs_bp.get("//logs/stream") +def stream_logs(job_id: str) -> ResponseReturnValue: + job = get_service().get_job(job_id) + if not job: + abort(404) + + def generate(): + last_index = 0 + while True: + current_logs = job.logs + if len(current_logs) > last_index: + for log in current_logs[last_index:]: + yield f"data: {json.dumps({'timestamp': log.timestamp, 'level': log.level, 'message': log.message})}\n\n" + last_index = len(current_logs) + + if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}: + break + + import time + time.sleep(0.5) + + return Response(generate(), mimetype="text/event-stream") + +@jobs_bp.get("//reader") +def job_reader(job_id: str) -> ResponseReturnValue: + job = get_service().get_job(job_id) + if not job: + abort(404) + return render_template("reader_embed.html", job=job) + +@jobs_bp.get("/queue") +def queue_page() -> str: + return render_template( + "queue.html", + jobs_panel=render_jobs_panel(), + ) + +@jobs_bp.get("/partial") +def jobs_partial() -> str: + return render_jobs_panel() diff --git a/abogen/webui/routes/main.py b/abogen/webui/routes/main.py new file mode 100644 index 0000000..f01b752 --- /dev/null +++ b/abogen/webui/routes/main.py @@ -0,0 +1,388 @@ +import logging +import time +import uuid +from pathlib import Path +from typing import Any, Dict, Optional, cast + +from flask import Blueprint, redirect, render_template, request, url_for, jsonify, current_app +from werkzeug.utils import secure_filename + +from abogen.webui.service import PendingJob, JobStatus +from abogen.webui.routes.utils.service import get_service, remove_pending_job, submit_job +from abogen.webui.routes.utils.settings import load_settings +from abogen.webui.routes.utils.voice import template_options +from abogen.webui.routes.utils.form import ( + normalize_wizard_step, + wants_wizard_json, + render_wizard_partial, + wizard_json_response, + build_pending_job_from_extraction, + apply_book_step_form, + apply_prepare_form, + render_jobs_panel, +) +from abogen.text_extractor import extract_from_path +from abogen.voice_profiles import serialize_profiles + +logger = logging.getLogger(__name__) + +main_bp = Blueprint("main", __name__) + +@main_bp.app_template_filter("datetimeformat") +def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str: + if not value: + return "—" + from datetime import datetime + return datetime.fromtimestamp(value).strftime(fmt) + +@main_bp.app_template_filter("durationformat") +def durationformat(value: Optional[float]) -> str: + if value is None: + return "" + seconds = int(value) + if seconds < 60: + return f"{seconds}s" + minutes = seconds // 60 + seconds = seconds % 60 + if minutes < 60: + return f"{minutes}m {seconds}s" + hours = minutes // 60 + minutes = minutes % 60 + return f"{hours}h {minutes}m" + +@main_bp.route("/") +def index(): + pending_id = request.args.get("pending_id") + pending = get_service().get_pending_job(pending_id) if pending_id else None + + # If we have a pending job, redirect to the wizard + if pending: + step_index = getattr(pending, "wizard_max_step_index", 0) + # Map index to step name roughly + steps = ["book", "chapters", "entities"] + step_name = steps[min(step_index, len(steps)-1)] + return redirect(url_for("main.wizard_step", step=step_name, pending_id=pending.id)) + + jobs = get_service().list_jobs() + stats = { + "total": len(jobs), + "completed": sum(1 for j in jobs if j.status == JobStatus.COMPLETED), + "running": sum(1 for j in jobs if j.status == JobStatus.RUNNING), + "pending": sum(1 for j in jobs if j.status == JobStatus.PENDING), + "failed": sum(1 for j in jobs if j.status == JobStatus.FAILED), + } + + return render_template( + "index.html", + options=template_options(), + settings=load_settings(), + jobs_panel=render_jobs_panel(), + stats=stats, + ) + +@main_bp.route("/wizard") +def wizard_start(): + pending_id = request.args.get("pending_id") + step = request.args.get("step", "book") + if pending_id: + return redirect(url_for("main.wizard_step", step=step, pending_id=pending_id)) + return redirect(url_for("main.wizard_step", step=step)) + +@main_bp.route("/wizard/") +def wizard_step(step: str): + pending_id = request.args.get("pending_id") + pending = get_service().get_pending_job(pending_id) if pending_id else None + + normalized_step = normalize_wizard_step(step, pending) + if normalized_step != step: + return redirect(url_for("main.wizard_step", step=normalized_step, pending_id=pending_id)) + + if wants_wizard_json(): + return wizard_json_response(pending, normalized_step) + + return render_template( + "index.html", + options=template_options(), + settings=load_settings(), + jobs_panel=render_jobs_panel(), + wizard_mode=True, + wizard_step=normalized_step, + wizard_partial=render_wizard_partial(pending, normalized_step), + ) + +@main_bp.route("/wizard/upload", methods=["POST"]) +def wizard_upload(): + pending_id = request.form.get("pending_id") + pending = get_service().get_pending_job(pending_id) if pending_id else None + + file = request.files.get("file") or request.files.get("source_file") + + settings = load_settings() + profiles = serialize_profiles() + + # Case 1: Updating existing job without new file + if pending and (not file or not file.filename): + try: + apply_book_step_form(pending, request.form, settings=settings, profiles=profiles) + get_service().store_pending_job(pending) + + if wants_wizard_json(): + return wizard_json_response(pending, "chapters") + return redirect(url_for("main.wizard_step", step="chapters", pending_id=pending.id)) + except Exception as e: + logger.exception("Error updating job settings") + error_msg = f"Failed to update settings: {str(e)}" + if wants_wizard_json(): + return wizard_json_response(pending, "book", error=error_msg, status=500) + return render_template( + "index.html", + options=template_options(), + settings=settings, + jobs_panel=render_jobs_panel(), + wizard_mode=True, + wizard_step="book", + wizard_partial=render_wizard_partial(pending, "book", error=error_msg), + ) + + # Case 2: New file upload (or replacing file on existing job) + if not file or not file.filename: + if wants_wizard_json(): + return wizard_json_response(None, "book", error="No file selected", status=400) + return redirect(url_for("main.wizard_step", step="book")) + + filename = secure_filename(file.filename) + temp_dir = Path(current_app.config.get("UPLOAD_FOLDER", "uploads")) + temp_dir.mkdir(exist_ok=True) + file_path = temp_dir / f"{uuid.uuid4().hex}_{filename}" + file.save(file_path) + + try: + extraction = extract_from_path(file_path) + + result = build_pending_job_from_extraction( + stored_path=file_path, + original_name=filename, + extraction=extraction, + form=request.form, + settings=settings, + profiles=profiles, + ) + + # If we had a pending job, we might want to preserve its ID or other properties, + # but for a new file it's safer to start fresh with the new extraction. + # The frontend will handle the ID change via the redirect. + + get_service().store_pending_job(result.pending) + + if wants_wizard_json(): + return wizard_json_response(result.pending, "chapters") + + return redirect(url_for("main.wizard_step", step="chapters", pending_id=result.pending.id)) + + except Exception as e: + logger.exception("Error processing upload") + if file_path.exists(): + try: + file_path.unlink() + except OSError: + pass + + error_msg = f"Failed to process file: {str(e)}" + if wants_wizard_json(): + return wizard_json_response(None, "book", error=error_msg, status=500) + + return render_template( + "index.html", + options=template_options(), + settings=settings, + jobs_panel=render_jobs_panel(), + wizard_mode=True, + wizard_step="book", + wizard_partial=render_wizard_partial(None, "book", error=error_msg), + ) + +@main_bp.route("/wizard/text", methods=["POST"]) +def wizard_text(): + text = request.form.get("text", "").strip() + title = request.form.get("title", "").strip() or "Pasted Text" + + if not text: + if wants_wizard_json(): + return wizard_json_response(None, "book", error="No text provided", status=400) + return redirect(url_for("main.wizard_step", step="book")) + + temp_dir = Path(current_app.config.get("UPLOAD_FOLDER", "uploads")) + temp_dir.mkdir(exist_ok=True) + file_path = temp_dir / f"{uuid.uuid4().hex}.txt" + file_path.write_text(text, encoding="utf-8") + + settings = load_settings() + profiles = serialize_profiles() + + try: + extraction = extract_from_path(file_path) + # Override title since text extraction might not find one + extraction.metadata["title"] = title + + result = build_pending_job_from_extraction( + stored_path=file_path, + original_name=f"{title}.txt", + extraction=extraction, + form=request.form, + settings=settings, + profiles=profiles, + ) + + get_service().store_pending_job(result.pending) + + if wants_wizard_json(): + return wizard_json_response(result.pending, "chapters") + + return redirect(url_for("main.wizard_step", step="chapters", pending_id=result.pending.id)) + + except Exception as e: + logger.exception("Error processing text") + if file_path.exists(): + try: + file_path.unlink() + except OSError: + pass + + error_msg = f"Failed to process text: {str(e)}" + if wants_wizard_json(): + return wizard_json_response(None, "book", error=error_msg, status=500) + + return render_template( + "index.html", + options=template_options(), + settings=settings, + jobs_panel=render_jobs_panel(), + wizard_mode=True, + wizard_step="book", + wizard_partial=render_wizard_partial(None, "book", error=error_msg), + ) + +@main_bp.route("/wizard/update", methods=["POST"]) +def wizard_update(): + pending_id = request.values.get("pending_id") + if not pending_id: + if wants_wizard_json(): + return wizard_json_response(None, "book", error="Missing job ID", status=400) + return redirect(url_for("main.wizard_step", step="book")) + + pending = get_service().get_pending_job(pending_id) + if not pending: + if wants_wizard_json(): + return wizard_json_response(None, "book", error="Job expired or not found", status=404) + return redirect(url_for("main.wizard_step", step="book")) + + current_step = request.form.get("step", "book") + next_step = request.form.get("next_step") + + settings = load_settings() + profiles = serialize_profiles() + + try: + if current_step == "book": + apply_book_step_form(pending, request.form, settings=settings, profiles=profiles) + target_step = next_step or "chapters" + + elif current_step == "chapters": + # This step involves re-analyzing chunks if needed + ( + chunk_level, + overrides, + enabled_overrides, + errors, + selected_total, + selected_config, + apply_config_requested, + persist_config_requested, + ) = apply_prepare_form(pending, request.form) + + if errors: + if wants_wizard_json(): + return wizard_json_response(pending, current_step, error="\n".join(errors), status=400) + return render_template( + "index.html", + options=template_options(), + settings=settings, + jobs_panel=render_jobs_panel(), + wizard_mode=True, + wizard_step=current_step, + wizard_partial=render_wizard_partial(pending, current_step, error="\n".join(errors)), + ) + + target_step = next_step or "entities" + + elif current_step == "entities": + # Just saving entity overrides + apply_prepare_form(pending, request.form) + target_step = next_step or "entities" # Stay or finish + + else: + target_step = "book" + + get_service().store_pending_job(pending) + + if wants_wizard_json(): + return wizard_json_response(pending, target_step) + + return redirect(url_for("main.wizard_step", step=target_step, pending_id=pending.id)) + + except Exception as e: + logger.exception(f"Error updating wizard step {current_step}") + error_msg = f"Update failed: {str(e)}" + if wants_wizard_json(): + return wizard_json_response(pending, current_step, error=error_msg, status=500) + + return render_template( + "index.html", + options=template_options(), + settings=settings, + jobs_panel=render_jobs_panel(), + wizard_mode=True, + wizard_step=current_step, + wizard_partial=render_wizard_partial(pending, current_step, error=error_msg), + ) + +@main_bp.route("/wizard/cancel", methods=["POST"]) +def wizard_cancel(): + pending_id = request.values.get("pending_id") + if pending_id: + remove_pending_job(pending_id) + + if wants_wizard_json(): + return jsonify({"status": "cancelled", "redirect_url": url_for("main.index")}) + + return redirect(url_for("main.index")) + +@main_bp.route("/wizard/finish", methods=["POST"]) +def wizard_finish(): + pending_id = request.values.get("pending_id") + if not pending_id: + if wants_wizard_json(): + return jsonify({"error": "Missing job ID"}), 400 + return redirect(url_for("main.index")) + + pending = get_service().get_pending_job(pending_id) + if not pending: + if wants_wizard_json(): + return jsonify({"error": "Job not found"}), 404 + return redirect(url_for("main.index")) + + # Final update from form + apply_prepare_form(pending, request.form) + + # Submit job + job_id = submit_job(pending) + + if wants_wizard_json(): + return jsonify({ + "status": "submitted", + "job_id": job_id, + "redirect_url": url_for("main.index"), + "jobs_panel": render_jobs_panel() + }) + + return redirect(url_for("main.index")) diff --git a/abogen/webui/routes/settings.py b/abogen/webui/routes/settings.py new file mode 100644 index 0000000..d0bb991 --- /dev/null +++ b/abogen/webui/routes/settings.py @@ -0,0 +1,296 @@ +from pathlib import Path + +from collections.abc import Mapping +from typing import Any + +from flask import Blueprint, current_app, render_template, request, redirect, url_for, flash, send_file, abort +from flask.typing import ResponseReturnValue + +from abogen.webui.routes.utils.settings import ( + load_settings, + load_integration_settings, + save_settings, + stored_integration_config, + coerce_bool, + coerce_int, + SAVE_MODE_LABELS, + llm_ready, + _NORMALIZATION_BOOLEAN_KEYS, + _NORMALIZATION_STRING_KEYS, + _DEFAULT_ANALYSIS_THRESHOLD, +) +from abogen.webui.routes.utils.voice import template_options +from abogen.webui.debug_tts_runner import run_debug_tts_wavs +from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES +from abogen.utils import get_user_output_path, load_config + +settings_bp = Blueprint("settings", __name__) + +_NORMALIZATION_SAMPLES = { + "apostrophes": "It's a beautiful day, isn't it? 'Yes,' she said, 'it is.'", + "currency": "The price is $10.50, but it was £8.00 yesterday.", + "dates": "On 2023-01-01, we celebrated the new year.", + "numbers": "There are 123 apples and 456 oranges.", + "abbreviations": "Dr. Smith lives on Elm St. near the U.S. border.", +} + +@settings_bp.post("/update") +def update_settings() -> ResponseReturnValue: + current = load_settings() + form = request.form + + # General settings + current["language"] = (form.get("language") or "en").strip() + current["default_speaker"] = (form.get("default_speaker") or "").strip() + current["default_voice"] = (form.get("default_voice") or "").strip() + try: + current["supertonic_total_steps"] = max(2, min(15, int(form.get("supertonic_total_steps", current.get("supertonic_total_steps", 5))))) + except (TypeError, ValueError): + pass + try: + current["supertonic_speed"] = max(0.7, min(2.0, float(form.get("supertonic_speed", current.get("supertonic_speed", 1.0))))) + except (TypeError, ValueError): + pass + current["output_format"] = (form.get("output_format") or "mp3").strip() + current["subtitle_mode"] = (form.get("subtitle_mode") or "Disabled").strip() + current["subtitle_format"] = (form.get("subtitle_format") or "srt").strip() + current["save_mode"] = (form.get("save_mode") or "save_next_to_input").strip() + + current["replace_single_newlines"] = coerce_bool(form.get("replace_single_newlines"), False) + current["use_gpu"] = coerce_bool(form.get("use_gpu"), False) + current["save_chapters_separately"] = coerce_bool(form.get("save_chapters_separately"), False) + current["merge_chapters_at_end"] = coerce_bool(form.get("merge_chapters_at_end"), True) + current["save_as_project"] = coerce_bool(form.get("save_as_project"), False) + current["separate_chapters_format"] = (form.get("separate_chapters_format") or "wav").strip() + + try: + current["silence_between_chapters"] = max(0.0, float(form.get("silence_between_chapters", 2.0))) + except ValueError: + pass + + try: + current["chapter_intro_delay"] = max(0.0, float(form.get("chapter_intro_delay", 0.5))) + except ValueError: + pass + + current["read_title_intro"] = coerce_bool(form.get("read_title_intro"), False) + current["read_closing_outro"] = coerce_bool(form.get("read_closing_outro"), True) + current["normalize_chapter_opening_caps"] = coerce_bool(form.get("normalize_chapter_opening_caps"), True) + current["auto_prefix_chapter_titles"] = coerce_bool(form.get("auto_prefix_chapter_titles"), True) + + try: + current["max_subtitle_words"] = max(1, int(form.get("max_subtitle_words", 50))) + except ValueError: + pass + + current["chunk_level"] = (form.get("chunk_level") or "paragraph").strip() + current["generate_epub3"] = coerce_bool(form.get("generate_epub3"), False) + + current["speaker_analysis_threshold"] = coerce_int( + form.get("speaker_analysis_threshold"), + _DEFAULT_ANALYSIS_THRESHOLD, + minimum=1, + maximum=25, + ) + + def _extract_checkbox(name: str, default: bool) -> bool: + values = form.getlist(name) if hasattr(form, "getlist") else [] + if values: + return coerce_bool(values[-1], default) + if hasattr(form, "__contains__") and name in form: + return False + return default + + # Normalization settings + for key in _NORMALIZATION_BOOLEAN_KEYS: + current[key] = _extract_checkbox(key, bool(current.get(key, True))) + for key in _NORMALIZATION_STRING_KEYS: + if hasattr(form, "__contains__") and key in form: + current[key] = (form.get(key) or "").strip() + + # Integrations + # `load_settings()` returns only the general settings subset and intentionally + # does not include stored integrations. Seed them from the stored config so + # saving unrelated settings cannot wipe credentials/tokens. + current_integrations: dict[str, dict[str, Any]] = {} + cfg = load_config() or {} + stored_integrations = cfg.get("integrations") + if isinstance(stored_integrations, Mapping): + for name, payload in stored_integrations.items(): + if isinstance(name, str) and isinstance(payload, Mapping): + current_integrations[name] = dict(payload) + # Ensure known integrations are loaded even if the config is still in legacy format. + for name in ("audiobookshelf", "calibre_opds"): + stored = stored_integration_config(name) + if stored and name not in current_integrations: + current_integrations[name] = dict(stored) + current["integrations"] = current_integrations + + # Audiobookshelf + abs_enabled = coerce_bool(form.get("audiobookshelf_enabled"), False) + abs_url = (form.get("audiobookshelf_base_url") or "").strip() + abs_token = (form.get("audiobookshelf_api_token") or "").strip() + abs_library = (form.get("audiobookshelf_library_id") or "").strip() + abs_folder = (form.get("audiobookshelf_folder_id") or "").strip() + abs_verify = coerce_bool(form.get("audiobookshelf_verify_ssl"), True) + abs_auto_send = coerce_bool(form.get("audiobookshelf_auto_send"), False) + abs_cover = coerce_bool(form.get("audiobookshelf_send_cover"), True) + abs_chapters = coerce_bool(form.get("audiobookshelf_send_chapters"), True) + abs_subtitles = coerce_bool(form.get("audiobookshelf_send_subtitles"), False) + + try: + abs_timeout = max(1.0, float(form.get("audiobookshelf_timeout", 30.0))) + except ValueError: + abs_timeout = 30.0 + + # Preserve existing token if not provided and not cleared + if not abs_token and not coerce_bool(form.get("audiobookshelf_api_token_clear"), False): + existing_abs = current["integrations"].get("audiobookshelf", {}) + abs_token = existing_abs.get("api_token", "") + + current["integrations"]["audiobookshelf"] = { + "enabled": abs_enabled, + "base_url": abs_url, + "api_token": abs_token, + "library_id": abs_library, + "folder_id": abs_folder, + "verify_ssl": abs_verify, + "auto_send": abs_auto_send, + "send_cover": abs_cover, + "send_chapters": abs_chapters, + "send_subtitles": abs_subtitles, + "timeout": abs_timeout, + } + + # Calibre OPDS + calibre_enabled = coerce_bool(form.get("calibre_opds_enabled"), False) + calibre_url = (form.get("calibre_opds_base_url") or "").strip() + calibre_user = (form.get("calibre_opds_username") or "").strip() + calibre_pass = (form.get("calibre_opds_password") or "").strip() + calibre_verify = coerce_bool(form.get("calibre_opds_verify_ssl"), True) + + # Preserve existing password if not provided and not cleared + if not calibre_pass and not coerce_bool(form.get("calibre_opds_password_clear"), False): + existing_calibre = current["integrations"].get("calibre_opds", {}) + calibre_pass = existing_calibre.get("password", "") + + current["integrations"]["calibre_opds"] = { + "enabled": calibre_enabled, + "base_url": calibre_url, + "username": calibre_user, + "password": calibre_pass, + "verify_ssl": calibre_verify, + } + + save_settings(current) + flash("Settings updated successfully.", "success") + return redirect(url_for("settings.settings_page")) + +@settings_bp.route("/", methods=["GET", "POST"]) +def settings_page() -> str | ResponseReturnValue: + if request.method == "POST": + return update_settings() + + debug_run_id = (request.args.get("debug_run_id") or "").strip() + debug_manifest = None + if debug_run_id: + run_dir = Path(current_app.config.get("OUTPUT_FOLDER") or get_user_output_path("web")) / "debug" / debug_run_id + manifest_path = run_dir / "manifest.json" + if manifest_path.exists(): + try: + import json + + debug_manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception: + debug_manifest = None + + save_locations = [{"value": key, "label": label} for key, label in SAVE_MODE_LABELS.items()] + default_output_dir = str(Path(get_user_output_path()).resolve()) + + return render_template( + "settings.html", + settings=load_settings(), + integrations=load_integration_settings(), + options=template_options(), + normalization_samples=_NORMALIZATION_SAMPLES, + save_locations=save_locations, + default_output_dir=default_output_dir, + llm_ready=llm_ready(load_settings()), + debug_samples=DEBUG_TTS_SAMPLES, + debug_manifest=debug_manifest, + ) + + +@settings_bp.post("/debug/run") +def run_debug_wavs() -> ResponseReturnValue: + settings = load_settings() + output_root = Path(current_app.config.get("OUTPUT_FOLDER") or get_user_output_path("web")) + try: + manifest = run_debug_tts_wavs(output_root=output_root, settings=settings) + except Exception as exc: + flash(f"Debug WAV generation failed: {exc}", "error") + return redirect(url_for("settings.settings_page", _anchor="debug")) + + flash("Debug WAV generation completed.", "success") + return redirect(url_for("settings.debug_wavs_page", run_id=str(manifest.get("run_id") or ""))) + + +@settings_bp.get("/debug/") +def debug_wavs_page(run_id: str) -> ResponseReturnValue: + safe_run = (run_id or "").strip() + if not safe_run: + abort(404) + + root = Path(current_app.config.get("OUTPUT_FOLDER") or get_user_output_path("web")) + run_dir = (root / "debug" / safe_run).resolve() + manifest_path = run_dir / "manifest.json" + if not manifest_path.exists(): + abort(404) + + try: + import json + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception: + abort(404) + + artifacts = manifest.get("artifacts") or [] + # Precompute download URLs for each artifact. + for item in artifacts: + filename = str(item.get("filename") or "") + item["url"] = url_for("settings.download_debug_wav", run_id=safe_run, filename=filename) + + return render_template( + "debug_wavs.html", + run_id=safe_run, + artifacts=artifacts, + ) + + +@settings_bp.get("/debug//") +def download_debug_wav(run_id: str, filename: str) -> ResponseReturnValue: + safe_run = (run_id or "").strip() + safe_name = (filename or "").strip() + if not safe_run or not safe_name or "/" in safe_name or "\\" in safe_name: + abort(404) + is_wav = safe_name.lower().endswith(".wav") + if not is_wav and safe_name != "manifest.json": + abort(404) + + root = Path(current_app.config.get("OUTPUT_FOLDER") or get_user_output_path("web")) + path = (root / "debug" / safe_run / safe_name).resolve() + if not path.exists() or not path.is_file(): + abort(404) + # Ensure path is within root/debug/run_id + expected_dir = (root / "debug" / safe_run).resolve() + if expected_dir not in path.parents: + abort(404) + wants_download = str(request.args.get("download") or "").strip().lower() in {"1", "true", "yes"} + mimetype = "audio/wav" if is_wav else "application/json" + # Inline playback should work for WAVs; allow explicit downloads via ?download=1. + return send_file( + path, + mimetype=mimetype, + as_attachment=wants_download, + download_name=path.name, + ) diff --git a/abogen/webui/routes/utils/common.py b/abogen/webui/routes/utils/common.py new file mode 100644 index 0000000..d280bdf --- /dev/null +++ b/abogen/webui/routes/utils/common.py @@ -0,0 +1,24 @@ +from typing import Any, Optional, Tuple, Iterable, List +from pathlib import Path + +def split_profile_spec(value: Any) -> Tuple[str, Optional[str]]: + text = str(value or "").strip() + if not text: + return "", None + lowered = text.lower() + if lowered.startswith("profile:") or lowered.startswith("speaker:"): + _, _, remainder = text.partition(":") + name = remainder.strip() + return "", name or None + return text, None + + +def split_speaker_spec(value: Any) -> Tuple[str, Optional[str]]: + """Preferred alias for split_profile_spec (supports 'speaker:' and legacy 'profile:').""" + + return split_profile_spec(value) + +def existing_paths(paths: Optional[Iterable[Path]]) -> List[Path]: + if not paths: + return [] + return [p for p in paths if p.exists()] diff --git a/abogen/webui/routes/utils/entity.py b/abogen/webui/routes/utils/entity.py new file mode 100644 index 0000000..f1acfea --- /dev/null +++ b/abogen/webui/routes/utils/entity.py @@ -0,0 +1,363 @@ +import time +import uuid +from typing import Any, Dict, Iterable, List, Mapping, Optional + +from abogen.webui.service import PendingJob +from abogen.entity_analysis import ( + extract_entities, + merge_override, + normalize_token as normalize_entity_token, + normalize_manual_override_token, + search_tokens as search_entity_tokens, +) +from abogen.pronunciation_store import ( + delete_override as delete_pronunciation_override, + load_overrides as load_pronunciation_overrides, + save_override as save_pronunciation_override, + search_overrides as search_pronunciation_overrides, +) +from abogen.webui.routes.utils.settings import load_settings +from abogen.heteronym_overrides import extract_heteronym_overrides + +def collect_pronunciation_overrides(pending: PendingJob) -> List[Dict[str, Any]]: + language = pending.language or "en" + collected: Dict[str, Dict[str, Any]] = {} + + summary = pending.entity_summary or {} + for group in ("people", "entities"): + entries = summary.get(group) + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, Mapping): + continue + override_payload = entry.get("override") + if not isinstance(override_payload, Mapping): + continue + token_value = str(entry.get("label") or override_payload.get("token") or "").strip() + pronunciation_value = str(override_payload.get("pronunciation") or "").strip() + if not token_value or not pronunciation_value: + continue + normalized = normalize_entity_token(entry.get("normalized") or token_value) + if not normalized: + continue + collected[normalized] = { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": str(override_payload.get("voice") or "").strip() or None, + "notes": str(override_payload.get("notes") or "").strip() or None, + "context": str(override_payload.get("context") or "").strip() or None, + "source": f"{group}-override", + "language": language, + } + + if isinstance(pending.speakers, Mapping): + for speaker_payload in pending.speakers.values(): + if not isinstance(speaker_payload, Mapping): + continue + token_value = str(speaker_payload.get("label") or "").strip() + pronunciation_value = str(speaker_payload.get("pronunciation") or "").strip() + if not token_value or not pronunciation_value: + continue + normalized = normalize_entity_token(token_value) + if not normalized: + continue + collected[normalized] = { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": str( + speaker_payload.get("resolved_voice") + or speaker_payload.get("voice") + or pending.voice + ).strip() + or None, + "notes": None, + "context": None, + "source": "speaker", + "language": language, + } + + for manual_entry in pending.manual_overrides or []: + if not isinstance(manual_entry, Mapping): + continue + token_value = str(manual_entry.get("token") or "").strip() + pronunciation_value = str(manual_entry.get("pronunciation") or "").strip() + if not token_value or not pronunciation_value: + continue + normalized = manual_entry.get("normalized") or normalize_manual_override_token(token_value) + if not normalized: + continue + collected[normalized] = { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": str(manual_entry.get("voice") or "").strip() or None, + "notes": str(manual_entry.get("notes") or "").strip() or None, + "context": str(manual_entry.get("context") or "").strip() or None, + "source": str(manual_entry.get("source") or "manual"), + "language": language, + } + + return list(collected.values()) + + +def sync_pronunciation_overrides(pending: PendingJob) -> None: + pending.pronunciation_overrides = collect_pronunciation_overrides(pending) + + if not pending.pronunciation_overrides: + return + + summary = pending.entity_summary or {} + manual_map: Dict[str, Mapping[str, Any]] = {} + for override in pending.manual_overrides or []: + if not isinstance(override, Mapping): + continue + normalized = override.get("normalized") or normalize_entity_token(override.get("token") or "") + pronunciation_value = str(override.get("pronunciation") or "").strip() + if not normalized or not pronunciation_value: + continue + manual_map[normalized] = override + for group in ("people", "entities"): + entries = summary.get(group) + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, dict): + continue + normalized = normalize_entity_token(entry.get("normalized") or entry.get("label") or "") + manual_override = manual_map.get(normalized) + if manual_override: + entry["override"] = { + "token": manual_override.get("token"), + "pronunciation": manual_override.get("pronunciation"), + "voice": manual_override.get("voice"), + "notes": manual_override.get("notes"), + "context": manual_override.get("context"), + "source": manual_override.get("source"), + } + + +def refresh_entity_summary(pending: PendingJob, chapters: Iterable[Mapping[str, Any]]) -> None: + settings = load_settings() + language = pending.language or "en" + chapter_list: List[Mapping[str, Any]] = [chapter for chapter in chapters if isinstance(chapter, Mapping)] + if not chapter_list: + pending.entity_summary = {} + pending.entity_cache_key = "" + pending.pronunciation_overrides = pending.pronunciation_overrides or [] + pending.heteronym_overrides = pending.heteronym_overrides or [] + return + + enabled_only = [chapter for chapter in chapter_list if chapter.get("enabled")] + target_chapters = enabled_only or chapter_list + + # Always compute heteronym overrides (English only). Preserve any prior selections. + try: + pending.heteronym_overrides = extract_heteronym_overrides( + target_chapters, + language=language, + existing=getattr(pending, "heteronym_overrides", None), + ) + except Exception: + pending.heteronym_overrides = getattr(pending, "heteronym_overrides", []) or [] + + if not bool(settings.get("enable_entity_recognition", True)): + pending.entity_summary = {} + pending.entity_cache_key = "" + pending.pronunciation_overrides = pending.pronunciation_overrides or [] + return + + result = extract_entities(target_chapters, language=language) + summary = dict(result.summary) + tokens: List[str] = [] + for group in ("people", "entities"): + entries = summary.get(group) + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, Mapping): + continue + token_value = str(entry.get("normalized") or entry.get("label") or "").strip() + if token_value: + tokens.append(token_value) + + overrides_from_store = load_pronunciation_overrides(language=language, tokens=tokens) + merged_summary = merge_override(summary, overrides_from_store) + if result.errors: + merged_summary["errors"] = list(result.errors) + merged_summary["cache_key"] = result.cache_key + pending.entity_summary = merged_summary + pending.entity_cache_key = result.cache_key + sync_pronunciation_overrides(pending) + + +def find_manual_override(pending: PendingJob, identifier: str) -> Optional[Dict[str, Any]]: + for entry in pending.manual_overrides or []: + if not isinstance(entry, dict): + continue + if entry.get("id") == identifier or entry.get("normalized") == identifier: + return entry + return None + + +def upsert_manual_override(pending: PendingJob, payload: Mapping[str, Any]) -> Dict[str, Any]: + token_value = str(payload.get("token") or "").strip() + if not token_value: + raise ValueError("Token is required") + pronunciation_value = str(payload.get("pronunciation") or "").strip() + voice_value = str(payload.get("voice") or "").strip() + notes_value = str(payload.get("notes") or "").strip() + context_value = str(payload.get("context") or "").strip() + normalized = payload.get("normalized") or normalize_manual_override_token(token_value) + if not normalized: + raise ValueError("Token is required") + + existing = find_manual_override(pending, payload.get("id", "")) or find_manual_override(pending, normalized) + timestamp = time.time() + language = pending.language or "en" + + if existing: + existing.update( + { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": voice_value, + "notes": notes_value, + "context": context_value, + "updated_at": timestamp, + } + ) + manual_entry = existing + else: + manual_entry = { + "id": payload.get("id") or uuid.uuid4().hex, + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": voice_value, + "notes": notes_value, + "context": context_value, + "language": language, + "source": payload.get("source") or "manual", + "created_at": timestamp, + "updated_at": timestamp, + } + if isinstance(pending.manual_overrides, list): + pending.manual_overrides.append(manual_entry) + else: + pending.manual_overrides = [manual_entry] + + save_pronunciation_override( + language=language, + token=token_value, + pronunciation=pronunciation_value or None, + voice=voice_value or None, + notes=notes_value or None, + context=context_value or None, + ) + + sync_pronunciation_overrides(pending) + return dict(manual_entry) + + +def delete_manual_override(pending: PendingJob, override_id: str) -> bool: + if not override_id: + return False + entries = pending.manual_overrides or [] + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + continue + if entry.get("id") == override_id: + token_value = entry.get("token") or "" + language = pending.language or "en" + delete_pronunciation_override(language=language, token=token_value) + entries.pop(index) + pending.manual_overrides = entries + sync_pronunciation_overrides(pending) + return True + return False + + +def search_manual_override_candidates(pending: PendingJob, query: str, *, limit: int = 15) -> List[Dict[str, Any]]: + normalized_query = (query or "").strip() + summary_index = (pending.entity_summary or {}).get("index", {}) + matches = search_entity_tokens(summary_index, normalized_query, limit=limit) + registry: Dict[str, Dict[str, Any]] = {} + + for entry in matches: + normalized = normalize_entity_token(entry.get("normalized") or entry.get("token") or "") + if not normalized: + continue + registry.setdefault( + normalized, + { + "token": entry.get("token"), + "normalized": normalized, + "category": entry.get("category") or "entity", + "count": entry.get("count", 0), + "samples": entry.get("samples", []), + "source": "entity", + }, + ) + + language = pending.language or "en" + store_matches = search_pronunciation_overrides(language=language, query=normalized_query, limit=limit) + for entry in store_matches: + normalized = entry.get("normalized") + if not normalized: + continue + registry.setdefault( + normalized, + { + "token": entry.get("token"), + "normalized": normalized, + "category": "history", + "count": entry.get("usage_count", 0), + "samples": [entry.get("context")] if entry.get("context") else [], + "source": "history", + "pronunciation": entry.get("pronunciation"), + "voice": entry.get("voice"), + }, + ) + + for entry in pending.manual_overrides or []: + if not isinstance(entry, Mapping): + continue + normalized = entry.get("normalized") + if not normalized: + continue + registry.setdefault( + normalized, + { + "token": entry.get("token"), + "normalized": normalized, + "category": "manual", + "count": 0, + "samples": [entry.get("context")] if entry.get("context") else [], + "source": "manual", + "pronunciation": entry.get("pronunciation"), + "voice": entry.get("voice"), + }, + ) + + ordered = sorted(registry.values(), key=lambda item: (-int(item.get("count") or 0), item.get("token") or "")) + if limit: + return ordered[:limit] + return ordered + + +def pending_entities_payload(pending: PendingJob) -> Dict[str, Any]: + settings = load_settings() + recognition_enabled = bool(settings.get("enable_entity_recognition", True)) + return { + "summary": pending.entity_summary or {}, + "manual_overrides": pending.manual_overrides or [], + "pronunciation_overrides": pending.pronunciation_overrides or [], + "heteronym_overrides": getattr(pending, "heteronym_overrides", None) or [], + "cache_key": pending.entity_cache_key, + "language": pending.language or "en", + "recognition_enabled": recognition_enabled, + } diff --git a/abogen/webui/routes/utils/epub.py b/abogen/webui/routes/utils/epub.py new file mode 100644 index 0000000..00a00c6 --- /dev/null +++ b/abogen/webui/routes/utils/epub.py @@ -0,0 +1,434 @@ +import json +import math +import posixpath +import zipfile +from html.parser import HTMLParser +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple +from xml.etree import ElementTree as ET + +from abogen.webui.service import Job, JobStatus + +def _coerce_path(value: Any) -> Optional[Path]: + if isinstance(value, Path): + return value + if isinstance(value, str): + candidate = Path(value) + return candidate + return None + + +def normalize_epub_path(base_dir: str, href: str) -> str: + if not href: + return "" + sanitized = href.split("#", 1)[0].split("?", 1)[0].strip() + sanitized = sanitized.replace("\\", "/") + if not sanitized: + return "" + if sanitized.startswith("/"): + sanitized = sanitized[1:] + base_dir = "" + normalized_base = base_dir.strip("/") + sanitized_lower = sanitized.lower() + if normalized_base: + base_lower = normalized_base.lower() + prefix = base_lower + "/" + if sanitized_lower.startswith(prefix): + remainder = sanitized[len(prefix):] + if remainder.lower().startswith(prefix): + sanitized = remainder + sanitized_lower = sanitized.lower() + base_dir = "" + elif sanitized_lower == base_lower: + base_dir = "" + base = base_dir.strip("/") + combined = posixpath.join(base, sanitized) if base else sanitized + normalized = posixpath.normpath(combined) + if normalized in {"", "."}: + return "" + normalized = normalized.replace("\\", "/") + segments = [segment for segment in normalized.split("/") if segment and segment != "."] + if not segments: + return "" + deduped: List[str] = [] + last_lower: Optional[str] = None + for segment in segments: + segment_lower = segment.lower() + if last_lower == segment_lower: + continue + deduped.append(segment) + last_lower = segment_lower + normalized = "/".join(deduped) + if normalized.startswith("../") or normalized == "..": + return "" + return normalized + + +def decode_text(payload: bytes) -> str: + for encoding in ("utf-8", "utf-16", "windows-1252"): + try: + return payload.decode(encoding) + except UnicodeDecodeError: + continue + return payload.decode("utf-8", "ignore") + + +def coerce_positive_time(value: Any) -> Optional[float]: + try: + numeric = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(numeric) or numeric < 0: + return None + return numeric + + +def load_job_metadata(job: Job) -> Dict[str, Any]: + result = getattr(job, "result", None) + artifacts = getattr(result, "artifacts", None) + if not isinstance(artifacts, Mapping): + return {} + metadata_ref = artifacts.get("metadata") + if isinstance(metadata_ref, Path): + metadata_path = metadata_ref + elif isinstance(metadata_ref, str): + metadata_path = Path(metadata_ref) + else: + return {} + if not metadata_path.exists(): + return {} + try: + return json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return {} + + +def resolve_book_title(job: Job, *metadata_sources: Mapping[str, Any]) -> str: + for source in metadata_sources: + if not isinstance(source, Mapping): + continue + for key in ("title", "book_title", "name", "album", "album_title"): + value = source.get(key) + if isinstance(value, str): + candidate = value.strip() + if candidate: + return candidate + filename = job.original_filename or "" + stem = Path(filename).stem if filename else "" + return stem or filename + + +class _NavMapParser(HTMLParser): + def __init__(self, base_dir: str) -> None: + super().__init__() + self._base_dir = base_dir + self._in_nav = False + self._nav_depth = 0 + self._current_href: Optional[str] = None + self._buffer: List[str] = [] + self.links: Dict[str, str] = {} + + def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None: + tag_lower = tag.lower() + if tag_lower == "nav": + attributes = dict(attrs) + nav_type = (attributes.get("epub:type") or attributes.get("type") or "").strip().lower() + nav_role = (attributes.get("role") or "").strip().lower() + type_tokens = {token.strip() for token in nav_type.split() if token} + role_tokens = {token.strip() for token in nav_role.split() if token} + if "toc" in type_tokens or "doc-toc" in role_tokens: + self._in_nav = True + self._nav_depth = 1 + return + if self._in_nav: + self._nav_depth += 1 + return + if not self._in_nav: + return + if tag_lower == "a": + attributes = dict(attrs) + href = attributes.get("href") or "" + normalized = normalize_epub_path(self._base_dir, href) + if normalized: + self._current_href = normalized + self._buffer = [] + + def handle_endtag(self, tag: str) -> None: + tag_lower = tag.lower() + if tag_lower == "nav" and self._in_nav: + self._nav_depth -= 1 + if self._nav_depth <= 0: + self._in_nav = False + return + if not self._in_nav: + return + if tag_lower == "a" and self._current_href: + text = "".join(self._buffer).strip() + if text: + self.links.setdefault(self._current_href, text) + self._current_href = None + self._buffer = [] + + def handle_data(self, data: str) -> None: + if self._in_nav and self._current_href and data: + self._buffer.append(data) + + +def parse_nav_document(payload: bytes, base_dir: str) -> Dict[str, str]: + parser = _NavMapParser(base_dir) + parser.feed(decode_text(payload)) + parser.close() + return parser.links + + +def parse_ncx_document(payload: bytes, base_dir: str) -> Dict[str, str]: + try: + root = ET.fromstring(payload) + except ET.ParseError: + return {} + nav_map: Dict[str, str] = {} + for nav_point in root.findall(".//{*}navPoint"): + content = nav_point.find(".//{*}content") + if content is None: + continue + src = content.attrib.get("src", "") + normalized = normalize_epub_path(base_dir, src) + if not normalized: + continue + label_el = nav_point.find(".//{*}text") + label = (label_el.text or "").strip() if label_el is not None and label_el.text else "" + if not label: + label = posixpath.basename(normalized) or f"Section {len(nav_map) + 1}" + nav_map.setdefault(normalized, label) + return nav_map + + +def extract_epub_chapters(epub_path: Path) -> List[Dict[str, str]]: + chapters: List[Dict[str, str]] = [] + if not epub_path or not epub_path.exists(): + return chapters + try: + with zipfile.ZipFile(epub_path, "r") as archive: + container_bytes = archive.read("META-INF/container.xml") + container_root = ET.fromstring(container_bytes) + rootfile = container_root.find(".//{*}rootfile") + if rootfile is None: + return chapters + opf_path = (rootfile.attrib.get("full-path") or "").strip() + if not opf_path: + return chapters + opf_dir = posixpath.dirname(opf_path) + opf_bytes = archive.read(opf_path) + opf_root = ET.fromstring(opf_bytes) + + manifest: Dict[str, Dict[str, str]] = {} + for item in opf_root.findall(".//{*}manifest/{*}item"): + item_id = item.attrib.get("id") + href = item.attrib.get("href") + if not item_id or not href: + continue + manifest[item_id] = { + "href": normalize_epub_path(opf_dir, href), + "properties": item.attrib.get("properties", ""), + "media_type": item.attrib.get("media-type", ""), + } + + spine_hrefs: List[str] = [] + nav_id: Optional[str] = None + spine = opf_root.find(".//{*}spine") + if spine is not None: + nav_id = spine.attrib.get("toc") + for itemref in spine.findall(".//{*}itemref"): + idref = itemref.attrib.get("idref") + if not idref: + continue + entry = manifest.get(idref) + if not entry: + continue + href = entry["href"] + if href and href not in spine_hrefs: + spine_hrefs.append(href) + + nav_href: Optional[str] = None + for entry in manifest.values(): + properties = entry.get("properties") or "" + if "nav" in {token.strip() for token in properties.split() if token}: + nav_href = entry["href"] + break + if not nav_href and nav_id: + toc_entry = manifest.get(nav_id) + if toc_entry: + nav_href = toc_entry["href"] + + nav_titles: Dict[str, str] = {} + if nav_href: + nav_base = posixpath.dirname(nav_href) + try: + nav_bytes = archive.read(nav_href) + except KeyError: + nav_bytes = None + if nav_bytes is not None: + if nav_href.lower().endswith(".ncx"): + nav_titles = parse_ncx_document(nav_bytes, nav_base) + else: + nav_titles = parse_nav_document(nav_bytes, nav_base) + + if not nav_titles and nav_id and nav_id in manifest: + toc_entry = manifest[nav_id] + nav_base = posixpath.dirname(toc_entry["href"]) + try: + nav_bytes = archive.read(toc_entry["href"]) + except KeyError: + nav_bytes = None + if nav_bytes is not None: + nav_titles = parse_ncx_document(nav_bytes, nav_base) + + for index, href in enumerate(spine_hrefs, start=1): + normalized = href + if not normalized: + continue + title = ( + nav_titles.get(normalized) + or nav_titles.get(normalized.split("#", 1)[0]) + or posixpath.basename(normalized) + or f"Chapter {index}" + ) + chapters.append({"href": normalized, "title": title}) + + if not chapters and nav_titles: + for index, (href, title) in enumerate(nav_titles.items(), start=1): + normalized = href + if not normalized: + continue + label = title or posixpath.basename(normalized) or f"Chapter {index}" + chapters.append({"href": normalized, "title": label}) + + return chapters + except (FileNotFoundError, zipfile.BadZipFile, KeyError, ET.ParseError, UnicodeDecodeError): + return [] + return chapters + + +def read_epub_bytes(epub_path: Path, raw_href: str) -> bytes: + normalized = normalize_epub_path("", raw_href) + if not normalized: + raise ValueError("Invalid resource path") + with zipfile.ZipFile(epub_path, "r") as archive: + return archive.read(normalized) + + +def iter_job_result_paths(job: Job) -> List[Path]: + result = getattr(job, "result", None) + if result is None: + return [] + resolved_seen: Set[Path] = set() + collected: List[Path] = [] + + def _remember(candidate: Optional[Path]) -> None: + if not candidate: + return + try: + resolved = candidate.resolve() + except OSError: + return + if resolved in resolved_seen: + return + resolved_seen.add(resolved) + collected.append(candidate) + + artifacts = getattr(result, "artifacts", None) + if isinstance(artifacts, Mapping): + for value in artifacts.values(): + candidate = _coerce_path(value) + if candidate and candidate.exists() and candidate.is_file(): + _remember(candidate) + + for attr in ("audio_path", "epub_path"): + candidate = _coerce_path(getattr(result, attr, None)) + if candidate and candidate.exists() and candidate.is_file(): + _remember(candidate) + + return collected + + +def iter_job_artifact_dirs(job: Job) -> List[Path]: + result = getattr(job, "result", None) + if result is None: + return [] + artifacts = getattr(result, "artifacts", None) + directories: List[Path] = [] + if isinstance(artifacts, Mapping): + for value in artifacts.values(): + candidate = _coerce_path(value) + if candidate and candidate.exists() and candidate.is_dir(): + directories.append(candidate) + return directories + + +def normalize_suffixes(suffixes: Iterable[str]) -> List[str]: + normalized: List[str] = [] + for suffix in suffixes: + if not suffix: + continue + cleaned = suffix.lower().strip() + if not cleaned: + continue + if not cleaned.startswith("."): + cleaned = f".{cleaned.lstrip('.')}" + normalized.append(cleaned) + return normalized + + +def find_job_file(job: Job, suffixes: Iterable[str]) -> Optional[Path]: + ordered_suffixes = normalize_suffixes(suffixes) + if not ordered_suffixes: + return None + files = iter_job_result_paths(job) + for suffix in ordered_suffixes: + for candidate in files: + if candidate.suffix.lower() == suffix: + return candidate + directories = iter_job_artifact_dirs(job) + for suffix in ordered_suffixes: + pattern = f"*{suffix}" + for directory in directories: + try: + match = next((path for path in directory.rglob(pattern) if path.is_file()), None) + except OSError: + match = None + if match: + return match + return None + + +def locate_job_epub(job: Job) -> Optional[Path]: + path = find_job_file(job, [".epub"]) + if path: + return path + return None + + +def locate_job_m4b(job: Job) -> Optional[Path]: + return find_job_file(job, [".m4b"]) + + +def locate_job_audio(job: Job, preferred_suffixes: Optional[Iterable[str]] = None) -> Optional[Path]: + suffix_order: List[str] = [] + if preferred_suffixes: + suffix_order.extend(preferred_suffixes) + suffix_order.extend([".m4b", ".mp3", ".flac", ".opus", ".ogg", ".m4a", ".wav"]) + path = find_job_file(job, suffix_order) + if path: + return path + files = iter_job_result_paths(job) + return files[0] if files else None + + +def job_download_flags(job: Job) -> Dict[str, bool]: + if job.status != JobStatus.COMPLETED: + return {"audio": False, "m4b": False, "epub3": False} + return { + "audio": locate_job_audio(job) is not None, + "m4b": locate_job_m4b(job) is not None, + "epub3": locate_job_epub(job) is not None, + } diff --git a/abogen/webui/routes/utils/form.py b/abogen/webui/routes/utils/form.py new file mode 100644 index 0000000..41d605e --- /dev/null +++ b/abogen/webui/routes/utils/form.py @@ -0,0 +1,1097 @@ +import re +import time +import uuid +from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast +from flask import request, render_template, jsonify +from flask.typing import ResponseReturnValue + +from abogen.webui.service import PendingJob, JobStatus +from abogen.webui.routes.utils.service import get_service +from abogen.webui.routes.utils.settings import ( + load_settings, + coerce_bool, + coerce_int, + _CHUNK_LEVEL_VALUES, + _DEFAULT_ANALYSIS_THRESHOLD, + _NORMALIZATION_BOOLEAN_KEYS, + _NORMALIZATION_STRING_KEYS, + SAVE_MODE_LABELS, + audiobookshelf_manual_available, +) +from abogen.webui.routes.utils.voice import ( + parse_voice_formula, + formula_from_profile, + resolve_voice_setting, + resolve_voice_choice, + prepare_speaker_metadata, + template_options, +) +from abogen.webui.routes.utils.entity import sync_pronunciation_overrides +from abogen.webui.routes.utils.epub import job_download_flags +from abogen.webui.routes.utils.common import split_profile_spec +from abogen.utils import calculate_text_length +from abogen.voice_profiles import serialize_profiles, normalize_profile_entry +from abogen.chunking import ChunkLevel, build_chunks_for_chapters +from abogen.constants import VOICES_INTERNAL +from abogen.speaker_configs import get_config +from abogen.kokoro_text_normalization import normalize_roman_numeral_titles +from dataclasses import dataclass +from pathlib import Path +import mimetypes + +@dataclass +class PendingBuildResult: + pending: PendingJob + selected_speaker_config: Optional[str] + config_languages: List[str] + speaker_config_payload: Optional[Dict[str, Any]] + +_WIZARD_STEP_ORDER = ["book", "chapters", "entities"] +_WIZARD_STEP_META = { + "book": { + "index": 1, + "title": "Book parameters", + "hint": "Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.", + }, + "chapters": { + "index": 2, + "title": "Select chapters", + "hint": "Choose which chapters to convert. We'll analyse entities automatically when you continue.", + }, + "entities": { + "index": 3, + "title": "Review entities", + "hint": "Assign pronunciations, voices, and manual overrides before queueing the conversion.", + }, +} + +_SUPPLEMENT_TITLE_PATTERNS: List[tuple[re.Pattern[str], float]] = [ + (re.compile(r"\btitle\s+page\b"), 3.0), + (re.compile(r"\bcopyright\b"), 2.4), + (re.compile(r"\btable\s+of\s+contents\b"), 2.8), + (re.compile(r"\bcontents\b"), 2.0), + (re.compile(r"\backnowledg(e)?ments?\b"), 2.0), + (re.compile(r"\bdedication\b"), 2.0), + (re.compile(r"\babout\s+the\s+author(s)?\b"), 2.4), + (re.compile(r"\balso\s+by\b"), 2.0), + (re.compile(r"\bpraise\s+for\b"), 2.0), + (re.compile(r"\bcolophon\b"), 2.2), + (re.compile(r"\bpublication\s+data\b"), 2.2), + (re.compile(r"\btranscriber'?s?\s+note\b"), 2.2), + (re.compile(r"\bglossary\b"), 2.0), + (re.compile(r"\bindex\b"), 2.0), + (re.compile(r"\bbibliograph(y|ies)\b"), 2.0), + (re.compile(r"\breferences\b"), 1.8), + (re.compile(r"\bappendix\b"), 1.9), +] + +_CONTENT_TITLE_PATTERNS: List[re.Pattern[str]] = [ + re.compile(r"\bchapter\b"), + re.compile(r"\bbook\b"), + re.compile(r"\bpart\b"), + re.compile(r"\bsection\b"), + re.compile(r"\bscene\b"), + re.compile(r"\bprologue\b"), + re.compile(r"\bepilogue\b"), + re.compile(r"\bintroduction\b"), + re.compile(r"\bstory\b"), +] + +_SUPPLEMENT_TEXT_KEYWORDS: List[tuple[str, float]] = [ + ("copyright", 1.2), + ("all rights reserved", 1.1), + ("isbn", 0.9), + ("library of congress", 1.0), + ("table of contents", 1.0), + ("dedicated to", 0.8), + ("acknowledg", 0.8), + ("printed in", 0.6), + ("permission", 0.6), + ("publisher", 0.5), + ("praise for", 0.9), + ("also by", 0.9), + ("glossary", 0.8), + ("index", 0.8), + ("newsletter", 3.2), + ("mailing list", 2.6), + ("sign-up", 2.2), +] + +def supplement_score(title: str, text: str, index: int) -> float: + normalized_title = (title or "").lower() + score = 0.0 + + for pattern, weight in _SUPPLEMENT_TITLE_PATTERNS: + if pattern.search(normalized_title): + score += weight + + for pattern in _CONTENT_TITLE_PATTERNS: + if pattern.search(normalized_title): + score -= 2.0 + + stripped_text = (text or "").strip() + length = len(stripped_text) + if length <= 150: + score += 0.9 + elif length <= 400: + score += 0.6 + elif length <= 800: + score += 0.35 + + lowercase_text = stripped_text.lower() + for keyword, weight in _SUPPLEMENT_TEXT_KEYWORDS: + if keyword in lowercase_text: + score += weight + + if index == 0 and score > 0: + score += 0.25 + + return score + + +def should_preselect_chapter( + title: str, + text: str, + index: int, + total_count: int, +) -> bool: + if total_count <= 1: + return True + score = supplement_score(title, text, index) + return score < 1.9 + + +def ensure_at_least_one_chapter_enabled(chapters: List[Dict[str, Any]]) -> None: + if not chapters: + return + if any(chapter.get("enabled") for chapter in chapters): + return + best_index = max(range(len(chapters)), key=lambda idx: chapters[idx].get("characters", 0)) + chapters[best_index]["enabled"] = True + +def apply_prepare_form( + pending: PendingJob, form: Mapping[str, Any] +) -> tuple[ + ChunkLevel, + List[Dict[str, Any]], + List[Dict[str, Any]], + List[str], + int, + str, + bool, + bool, +]: + raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower() + if raw_chunk_level not in _CHUNK_LEVEL_VALUES: + raw_chunk_level = pending.chunk_level if pending.chunk_level in _CHUNK_LEVEL_VALUES else "paragraph" + pending.chunk_level = raw_chunk_level + chunk_level_literal = cast(ChunkLevel, pending.chunk_level) + + pending.speaker_mode = "single" + + pending.generate_epub3 = coerce_bool(form.get("generate_epub3"), False) + + threshold_default = getattr(pending, "speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD) + raw_threshold = form.get("speaker_analysis_threshold") + if raw_threshold is not None: + pending.speaker_analysis_threshold = coerce_int( + raw_threshold, + threshold_default, + minimum=1, + maximum=25, + ) + else: + pending.speaker_analysis_threshold = threshold_default + + if not pending.speakers: + narrator: Dict[str, Any] = { + "id": "narrator", + "label": "Narrator", + "voice": pending.voice, + } + if pending.voice_profile: + narrator["voice_profile"] = pending.voice_profile + pending.speakers = {"narrator": narrator} + else: + existing_narrator = pending.speakers.get("narrator") + if isinstance(existing_narrator, dict): + existing_narrator.setdefault("id", "narrator") + existing_narrator["label"] = existing_narrator.get("label", "Narrator") + existing_narrator["voice"] = pending.voice + if pending.voice_profile: + existing_narrator["voice_profile"] = pending.voice_profile + pending.speakers["narrator"] = existing_narrator + + selected_config = (form.get("applied_speaker_config") or "").strip() + apply_config_requested = str(form.get("apply_speaker_config", "")).strip() in {"1", "true", "on"} + persist_config_requested = str(form.get("save_speaker_config", "")).strip() in {"1", "true", "on"} + + pending.applied_speaker_config = selected_config or None + + errors: List[str] = [] + + if isinstance(pending.speakers, dict): + for speaker_id, payload in list(pending.speakers.items()): + if not isinstance(payload, dict): + continue + field_key = f"speaker-{speaker_id}-pronunciation" + raw_value = form.get(field_key, "") + pronunciation = raw_value.strip() + if pronunciation: + payload["pronunciation"] = pronunciation + else: + payload.pop("pronunciation", None) + + voice_value = (form.get(f"speaker-{speaker_id}-voice") or "").strip() + formula_key = f"speaker-{speaker_id}-formula" + formula_value = (form.get(formula_key) or "").strip() + has_formula = False + if formula_value: + try: + parse_voice_formula(formula_value) + except ValueError as exc: + label = payload.get("label") or speaker_id.replace("_", " ").title() + errors.append(f"Invalid custom mix for {label}: {exc}") + else: + payload["voice_formula"] = formula_value + payload["resolved_voice"] = formula_value + payload.pop("voice_profile", None) + has_formula = True + else: + payload.pop("voice_formula", None) + + if voice_value == "__custom_mix": + voice_value = "" + + if voice_value: + payload["voice"] = voice_value + if not has_formula: + payload["resolved_voice"] = voice_value + else: + payload.pop("voice", None) + if not has_formula: + payload.pop("resolved_voice", None) + + lang_key = f"speaker-{speaker_id}-languages" + languages: List[str] = [] + getter = getattr(form, "getlist", None) + if callable(getter): + values = cast(Iterable[str], getter(lang_key)) + languages = [code.strip() for code in values if code] + else: + raw_langs = form.get(lang_key) + if isinstance(raw_langs, str): + languages = [item.strip() for item in raw_langs.split(",") if item.strip()] + payload["config_languages"] = languages + + profiles = serialize_profiles() + raw_delay = form.get("chapter_intro_delay") + if raw_delay is not None: + raw_normalized = raw_delay.strip() + if raw_normalized: + try: + pending.chapter_intro_delay = max(0.0, float(raw_normalized)) + except ValueError: + errors.append("Enter a valid number for the chapter intro delay.") + else: + pending.chapter_intro_delay = 0.0 + + intro_values: List[str] = [] + getter = getattr(form, "getlist", None) + if callable(getter): + raw_intro_values = getter("read_title_intro") + if raw_intro_values: + intro_values = list(cast(Iterable[str], raw_intro_values)) + else: + raw_intro = form.get("read_title_intro") + if raw_intro is not None: + intro_values = [raw_intro] + if intro_values: + pending.read_title_intro = coerce_bool(intro_values[-1], pending.read_title_intro) + elif hasattr(form, "__contains__") and "read_title_intro" in form: + pending.read_title_intro = False + + outro_values: List[str] = [] + if callable(getter): + raw_outro_values = getter("read_closing_outro") + if raw_outro_values: + outro_values = list(cast(Iterable[str], raw_outro_values)) + else: + raw_outro = form.get("read_closing_outro") + if raw_outro is not None: + outro_values = [raw_outro] + if outro_values: + pending.read_closing_outro = coerce_bool( + outro_values[-1], getattr(pending, "read_closing_outro", True) + ) + elif hasattr(form, "__contains__") and "read_closing_outro" in form: + pending.read_closing_outro = False + + caps_values: List[str] = [] + if callable(getter): + raw_caps_values = getter("normalize_chapter_opening_caps") + if raw_caps_values: + caps_values = list(cast(Iterable[str], raw_caps_values)) + else: + raw_caps = form.get("normalize_chapter_opening_caps") + if raw_caps is not None: + caps_values = [raw_caps] + if caps_values: + pending.normalize_chapter_opening_caps = coerce_bool( + caps_values[-1], getattr(pending, "normalize_chapter_opening_caps", True) + ) + elif hasattr(form, "__contains__") and "normalize_chapter_opening_caps" in form: + pending.normalize_chapter_opening_caps = False + + overrides: List[Dict[str, Any]] = [] + selected_total = 0 + + for index, chapter in enumerate(pending.chapters): + enabled = form.get(f"chapter-{index}-enabled") == "on" + title_input = (form.get(f"chapter-{index}-title") or "").strip() + title = title_input or chapter.get("title") or f"Chapter {index + 1}" + voice_selection = form.get(f"chapter-{index}-voice", "__default") + formula_input = (form.get(f"chapter-{index}-formula") or "").strip() + + entry: Dict[str, Any] = { + "id": chapter.get("id") or f"{index:04d}", + "index": index, + "order": index, + "source_title": chapter.get("title") or title, + "title": title, + "text": chapter.get("text", ""), + "enabled": enabled, + } + entry["characters"] = calculate_text_length(entry["text"]) + + if enabled: + if voice_selection.startswith("voice:"): + entry["voice"] = voice_selection.split(":", 1)[1] + entry["resolved_voice"] = entry["voice"] + elif voice_selection.startswith("profile:"): + profile_name = voice_selection.split(":", 1)[1] + entry["voice_profile"] = profile_name + profile_entry = profiles.get(profile_name) or {} + formula_value = formula_from_profile(profile_entry) + if formula_value: + entry["voice_formula"] = formula_value + entry["resolved_voice"] = formula_value + else: + errors.append(f"Profile '{profile_name}' has no configured voices.") + elif voice_selection == "formula": + if not formula_input: + errors.append(f"Provide a custom formula for chapter {index + 1}.") + else: + try: + parse_voice_formula(formula_input) + except ValueError as exc: + errors.append(str(exc)) + else: + entry["voice_formula"] = formula_input + entry["resolved_voice"] = formula_input + selected_total += entry["characters"] + + overrides.append(entry) + pending.chapters[index] = dict(entry) + + enabled_overrides = [entry for entry in overrides if entry.get("enabled")] + + heteronym_entries = getattr(pending, "heteronym_overrides", None) + if isinstance(heteronym_entries, list) and heteronym_entries: + for entry in heteronym_entries: + if not isinstance(entry, dict): + continue + entry_id = str(entry.get("entry_id") or entry.get("id") or "").strip() + if not entry_id: + continue + raw_choice = form.get(f"heteronym-{entry_id}-choice") + if raw_choice is None: + continue + choice = str(raw_choice).strip() + if not choice: + continue + options = entry.get("options") + if isinstance(options, list) and options: + allowed = { + str(opt.get("key")).strip() + for opt in options + if isinstance(opt, dict) and str(opt.get("key") or "").strip() + } + if allowed and choice not in allowed: + continue + entry["choice"] = choice + + sync_pronunciation_overrides(pending) + + return ( + chunk_level_literal, + overrides, + enabled_overrides, + errors, + selected_total, + selected_config, + apply_config_requested, + persist_config_requested, + ) + +def apply_book_step_form( + pending: PendingJob, + form: Mapping[str, Any], + *, + settings: Mapping[str, Any], + profiles: Mapping[str, Any], +) -> None: + language_fallback = pending.language or settings.get("language", "en") + raw_language = (form.get("language") or language_fallback or "en").strip() + if raw_language: + pending.language = raw_language + + subtitle_mode = (form.get("subtitle_mode") or pending.subtitle_mode or "Disabled").strip() + if subtitle_mode: + pending.subtitle_mode = subtitle_mode + + pending.generate_epub3 = coerce_bool(form.get("generate_epub3"), bool(pending.generate_epub3)) + + chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower() + raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or chunk_level_default).strip().lower() + if raw_chunk_level not in _CHUNK_LEVEL_VALUES: + raw_chunk_level = chunk_level_default if chunk_level_default in _CHUNK_LEVEL_VALUES else (pending.chunk_level or "paragraph") + pending.chunk_level = raw_chunk_level + + threshold_default = pending.speaker_analysis_threshold or settings.get("speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD) + raw_threshold = form.get("speaker_analysis_threshold") + if raw_threshold is not None: + pending.speaker_analysis_threshold = coerce_int( + raw_threshold, + threshold_default, + minimum=1, + maximum=25, + ) + + raw_delay = form.get("chapter_intro_delay") + if raw_delay is not None: + try: + pending.chapter_intro_delay = max(0.0, float(str(raw_delay).strip() or 0.0)) + except ValueError: + pass + + intro_default = pending.read_title_intro if isinstance(pending.read_title_intro, bool) else bool(settings.get("read_title_intro", False)) + intro_values: List[str] = [] + getter = getattr(form, "getlist", None) + if callable(getter): + raw_intro_values = getter("read_title_intro") + if raw_intro_values: + intro_values = list(cast(Iterable[str], raw_intro_values)) + else: + raw_intro_flag = form.get("read_title_intro") + if raw_intro_flag is not None: + intro_values = [raw_intro_flag] + if intro_values: + pending.read_title_intro = coerce_bool(intro_values[-1], intro_default) + elif hasattr(form, "__contains__") and "read_title_intro" in form: + pending.read_title_intro = False + else: + pending.read_title_intro = intro_default + + outro_default = ( + pending.read_closing_outro + if isinstance(getattr(pending, "read_closing_outro", None), bool) + else bool(settings.get("read_closing_outro", True)) + ) + outro_values: List[str] = [] + if callable(getter): + raw_outro_values = getter("read_closing_outro") + if raw_outro_values: + outro_values = list(cast(Iterable[str], raw_outro_values)) + else: + raw_outro_flag = form.get("read_closing_outro") + if raw_outro_flag is not None: + outro_values = [raw_outro_flag] + if outro_values: + pending.read_closing_outro = coerce_bool(outro_values[-1], outro_default) + elif hasattr(form, "__contains__") and "read_closing_outro" in form: + pending.read_closing_outro = False + else: + pending.read_closing_outro = outro_default + + caps_default = ( + pending.normalize_chapter_opening_caps + if isinstance(getattr(pending, "normalize_chapter_opening_caps", None), bool) + else bool(settings.get("normalize_chapter_opening_caps", True)) + ) + caps_values: List[str] = [] + getter = getattr(form, "getlist", None) + if callable(getter): + raw_caps_values = getter("normalize_chapter_opening_caps") + if raw_caps_values: + caps_values = list(cast(Iterable[str], raw_caps_values)) + else: + raw_caps_flag = form.get("normalize_chapter_opening_caps") + if raw_caps_flag is not None: + caps_values = [raw_caps_flag] + if caps_values: + pending.normalize_chapter_opening_caps = coerce_bool(caps_values[-1], caps_default) + elif hasattr(form, "__contains__") and "normalize_chapter_opening_caps" in form: + pending.normalize_chapter_opening_caps = False + else: + pending.normalize_chapter_opening_caps = caps_default + + def _extract_checkbox(name: str, default: bool) -> bool: + values: List[str] = [] + getter = getattr(form, "getlist", None) + if callable(getter): + raw_values = getter(name) + if raw_values: + values = list(cast(Iterable[str], raw_values)) + else: + raw_flag = form.get(name) + if raw_flag is not None: + values = [raw_flag] + if values: + return coerce_bool(values[-1], default) + if hasattr(form, "__contains__") and name in form: + return False + return default + + overrides_existing = getattr(pending, "normalization_overrides", None) + overrides: Dict[str, Any] = dict(overrides_existing or {}) + for key in _NORMALIZATION_BOOLEAN_KEYS: + default_toggle = overrides.get(key, bool(settings.get(key, True))) + overrides[key] = _extract_checkbox(key, default_toggle) + for key in _NORMALIZATION_STRING_KEYS: + default_val = overrides.get(key, str(settings.get(key, ""))) + val = form.get(key) + if val is not None: + overrides[key] = str(val) + else: + overrides[key] = default_val + pending.normalization_overrides = overrides + + speed_value = form.get("speed") + if speed_value is not None: + try: + pending.speed = float(speed_value) + except ValueError: + pass + + # NOTE: Do not auto-set a global TTS provider at the book level based on the + # narrator defaults. Provider is resolved per-speaker/per-chunk from the voice + # spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula). + # This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro). + provider_value = str(form.get("tts_provider") or "").strip().lower() + if provider_value in {"kokoro", "supertonic"}: + pending.tts_provider = provider_value + + # Determine the base speaker selection (saved speaker ref or raw voice). + narrator_voice_raw = ( + form.get("voice") + or pending.voice + or settings.get("default_speaker") + or settings.get("default_voice") + or "" + ).strip() + + profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {}) + base_spec, _selected_speaker_name = split_profile_spec(narrator_voice_raw) + + profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip() + custom_formula_raw = (form.get("voice_formula") or "").strip() + narrator_voice_raw = (base_spec or narrator_voice_raw or settings.get("default_voice") or "").strip() + resolved_default_voice, inferred_profile, _ = resolve_voice_setting( + narrator_voice_raw, + profiles=profiles_map, + ) + + if profile_selection in {"__standard", "", None} and inferred_profile: + profile_selection = inferred_profile + + if profile_selection == "__formula": + profile_name = "" + custom_formula = custom_formula_raw + elif profile_selection in {"__standard", "", None}: + profile_name = "" + custom_formula = "" + else: + profile_name = profile_selection + custom_formula = "" + + base_voice_spec = resolved_default_voice or narrator_voice_raw + if not base_voice_spec and VOICES_INTERNAL: + base_voice_spec = VOICES_INTERNAL[0] + + voice_choice, resolved_language, selected_profile = resolve_voice_choice( + pending.language, + base_voice_spec, + profile_name, + custom_formula, + profiles_map, + ) + + if resolved_language: + pending.language = resolved_language + + if profile_selection == "__formula" and custom_formula_raw: + pending.voice = custom_formula_raw + pending.voice_profile = None + elif profile_selection not in {"__standard", "", None, "__formula"}: + pending.voice_profile = selected_profile or profile_selection + pending.voice = voice_choice + else: + pending.voice_profile = None + fallback_voice = base_voice_spec or narrator_voice_raw + pending.voice = voice_choice or fallback_voice + + pending.applied_speaker_config = (form.get("speaker_config") or "").strip() or None + + # Metadata updates + if "meta_title" in form: + pending.metadata_tags["title"] = str(form.get("meta_title", "")).strip() + + if "meta_subtitle" in form: + pending.metadata_tags["subtitle"] = str(form.get("meta_subtitle", "")).strip() + + if "meta_author" in form: + authors = str(form.get("meta_author", "")).strip() + pending.metadata_tags["authors"] = authors + pending.metadata_tags["author"] = authors + + if "meta_series" in form: + series = str(form.get("meta_series", "")).strip() + pending.metadata_tags["series"] = series + pending.metadata_tags["series_name"] = series + pending.metadata_tags["seriesname"] = series + pending.metadata_tags["series_title"] = series + pending.metadata_tags["seriestitle"] = series + # If user manually edits series, update opds_series too so it persists + if "opds_series" in pending.metadata_tags: + pending.metadata_tags["opds_series"] = series + + if "meta_series_index" in form: + idx = str(form.get("meta_series_index", "")).strip() + pending.metadata_tags["series_index"] = idx + pending.metadata_tags["series_sequence"] = idx + + if "meta_publisher" in form: + pending.metadata_tags["publisher"] = str(form.get("meta_publisher", "")).strip() + + if "meta_description" in form: + desc = str(form.get("meta_description", "")).strip() + pending.metadata_tags["description"] = desc + pending.metadata_tags["summary"] = desc + + if coerce_bool(form.get("remove_cover"), False): + pending.cover_image_path = None + pending.cover_image_mime = None + +def persist_cover_image(extraction_result: Any, stored_path: Path) -> tuple[Optional[Path], Optional[str]]: + cover_bytes = getattr(extraction_result, "cover_image", None) + if not cover_bytes: + return None, None + + mime = getattr(extraction_result, "cover_mime", None) + extension = mimetypes.guess_extension(mime or "") or ".png" + base_stem = Path(stored_path).stem or "cover" + candidate = stored_path.parent / f"{base_stem}_cover{extension}" + counter = 1 + while candidate.exists(): + candidate = stored_path.parent / f"{base_stem}_cover_{counter}{extension}" + counter += 1 + + try: + candidate.write_bytes(cover_bytes) + except OSError: + return None, None + + return candidate, mime + +def build_pending_job_from_extraction( + *, + stored_path: Path, + original_name: str, + extraction: Any, + form: Mapping[str, Any], + settings: Mapping[str, Any], + profiles: Mapping[str, Any], + metadata_overrides: Optional[Mapping[str, Any]] = None, +) -> PendingBuildResult: + profiles_map = dict(profiles) + cover_path, cover_mime = persist_cover_image(extraction, stored_path) + + if getattr(extraction, "chapters", None): + original_titles = [chapter.title for chapter in extraction.chapters] + normalized_titles = normalize_roman_numeral_titles(original_titles) + if normalized_titles != original_titles: + for chapter, new_title in zip(extraction.chapters, normalized_titles): + chapter.title = new_title + + metadata_tags = dict(getattr(extraction, "metadata", {}) or {}) + if metadata_overrides: + normalized_keys = {str(existing_key).casefold(): str(existing_key) for existing_key in metadata_tags.keys()} + for key, value in metadata_overrides.items(): + if value is None: + continue + key_text = str(key or "").strip() + if not key_text: + continue + value_text = str(value).strip() + if not value_text: + continue + lookup = key_text.casefold() + existing_key = normalized_keys.get(lookup) + if existing_key: + existing_value = str(metadata_tags.get(existing_key) or "").strip() + if existing_value: + continue + target_key = existing_key + else: + target_key = key_text + normalized_keys[lookup] = target_key + metadata_tags[target_key] = value_text + + total_chars = getattr(extraction, "total_characters", None) or calculate_text_length( + getattr(extraction, "combined_text", "") + ) + chapters_source = getattr(extraction, "chapters", []) or [] + total_chapter_count = len(chapters_source) + chapters_payload: List[Dict[str, Any]] = [] + for index, chapter in enumerate(chapters_source): + enabled = should_preselect_chapter(chapter.title, chapter.text, index, total_chapter_count) + chapters_payload.append( + { + "id": f"{index:04d}", + "index": index, + "title": chapter.title, + "text": chapter.text, + "characters": calculate_text_length(chapter.text), + "enabled": enabled, + } + ) + + if not chapters_payload: + chapters_payload.append( + { + "id": "0000", + "index": 0, + "title": original_name, + "text": "", + "characters": 0, + "enabled": True, + } + ) + + ensure_at_least_one_chapter_enabled(chapters_payload) + + language = str(form.get("language") or "a").strip() or "a" + profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {}) + default_voice_setting = settings.get("default_voice") or "" + resolved_default_voice, inferred_profile, inferred_language = resolve_voice_setting( + default_voice_setting, + profiles=profiles_map, + ) + base_voice_input = str(form.get("voice") or "").strip() + profile_selection = (form.get("voice_profile") or "__standard").strip() + custom_formula_raw = str(form.get("voice_formula") or "").strip() + + if profile_selection in {"__standard", ""} and inferred_profile: + profile_selection = inferred_profile + + base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip() + if not base_voice and VOICES_INTERNAL: + base_voice = VOICES_INTERNAL[0] + selected_speaker_config = (form.get("speaker_config") or "").strip() + speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None + + if profile_selection == "__formula": + profile_name = "" + custom_formula = custom_formula_raw + elif profile_selection in {"__standard", ""}: + profile_name = "" + custom_formula = "" + else: + profile_name = profile_selection + custom_formula = "" + + voice, language, selected_profile = resolve_voice_choice( + language, + base_voice, + profile_name, + custom_formula, + profiles_map, + ) + + try: + speed = float(form.get("speed", 1.0)) + except (TypeError, ValueError): + speed = 1.0 + + subtitle_mode = str(form.get("subtitle_mode") or "Disabled") + output_format = settings["output_format"] + subtitle_format = settings["subtitle_format"] + save_mode_key = settings["save_mode"] + save_mode = SAVE_MODE_LABELS.get(save_mode_key, SAVE_MODE_LABELS["save_next_to_input"]) + replace_single_newlines = settings["replace_single_newlines"] + use_gpu = settings["use_gpu"] + save_chapters_separately = settings["save_chapters_separately"] + merge_chapters_at_end = settings["merge_chapters_at_end"] or not save_chapters_separately + save_as_project = settings["save_as_project"] + separate_chapters_format = settings["separate_chapters_format"] + silence_between_chapters = settings["silence_between_chapters"] + chapter_intro_delay = settings["chapter_intro_delay"] + read_title_intro = settings["read_title_intro"] + read_closing_outro = settings.get("read_closing_outro", True) + normalize_chapter_opening_caps = settings["normalize_chapter_opening_caps"] + max_subtitle_words = settings["max_subtitle_words"] + auto_prefix_chapter_titles = settings["auto_prefix_chapter_titles"] + + chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower() + raw_chunk_level = str(form.get("chunk_level") or chunk_level_default).strip().lower() + if raw_chunk_level not in _CHUNK_LEVEL_VALUES: + raw_chunk_level = chunk_level_default if chunk_level_default in _CHUNK_LEVEL_VALUES else "paragraph" + chunk_level_value = raw_chunk_level + chunk_level_literal = cast(ChunkLevel, chunk_level_value) + + speaker_mode_value = "single" + + generate_epub3_default = bool(settings.get("generate_epub3", False)) + generate_epub3 = coerce_bool(form.get("generate_epub3"), generate_epub3_default) + + selected_chapter_sources = [entry for entry in chapters_payload if entry.get("enabled")] + raw_chunks = build_chunks_for_chapters(selected_chapter_sources, level=chunk_level_literal) + analysis_chunks = build_chunks_for_chapters(selected_chapter_sources, level="sentence") + + analysis_threshold = coerce_int( + settings.get("speaker_analysis_threshold"), + _DEFAULT_ANALYSIS_THRESHOLD, + minimum=1, + maximum=25, + ) + + initial_analysis = False + ( + processed_chunks, + speakers, + analysis_payload, + config_languages, + _, + ) = prepare_speaker_metadata( + chapters=selected_chapter_sources, + chunks=raw_chunks, + analysis_chunks=analysis_chunks, + voice=voice, + voice_profile=selected_profile or None, + threshold=analysis_threshold, + run_analysis=initial_analysis, + speaker_config=speaker_config_payload, + apply_config=bool(speaker_config_payload), + ) + + def _extract_checkbox(name: str, default: bool) -> bool: + values: List[str] = [] + getter = getattr(form, "getlist", None) + if callable(getter): + raw_values = getter(name) + if raw_values: + values = list(cast(Iterable[str], raw_values)) + else: + raw_flag = form.get(name) + if raw_flag is not None: + values = [raw_flag] + if values: + return coerce_bool(values[-1], default) + return default + + normalization_overrides = {} + for key in _NORMALIZATION_BOOLEAN_KEYS: + default_val = bool(settings.get(key, True)) + normalization_overrides[key] = _extract_checkbox(key, default_val) + + for key in _NORMALIZATION_STRING_KEYS: + default_val = str(settings.get(key, "")) + val = form.get(key) + if val is not None: + normalization_overrides[key] = str(val) + else: + normalization_overrides[key] = default_val + + pending = PendingJob( + id=uuid.uuid4().hex, + original_filename=original_name, + stored_path=stored_path, + language=language, + voice=voice, + speed=speed, + use_gpu=use_gpu, + subtitle_mode=subtitle_mode, + output_format=output_format, + save_mode=save_mode, + output_folder=None, + replace_single_newlines=replace_single_newlines, + subtitle_format=subtitle_format, + total_characters=total_chars, + save_chapters_separately=save_chapters_separately, + merge_chapters_at_end=merge_chapters_at_end, + separate_chapters_format=separate_chapters_format, + silence_between_chapters=silence_between_chapters, + save_as_project=save_as_project, + voice_profile=selected_profile or None, + max_subtitle_words=max_subtitle_words, + metadata_tags=metadata_tags, + chapters=chapters_payload, + normalization_overrides=normalization_overrides, + created_at=time.time(), + cover_image_path=cover_path, + cover_image_mime=cover_mime, + chapter_intro_delay=chapter_intro_delay, + read_title_intro=bool(read_title_intro), + read_closing_outro=bool(read_closing_outro), + normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps), + auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles), + chunk_level=chunk_level_value, + speaker_mode=speaker_mode_value, + generate_epub3=generate_epub3, + chunks=processed_chunks, + speakers=speakers, + speaker_analysis=analysis_payload, + speaker_analysis_threshold=analysis_threshold, + analysis_requested=initial_analysis, + ) + + return PendingBuildResult( + pending=pending, + selected_speaker_config=selected_speaker_config or None, + config_languages=list(config_languages or []), + speaker_config_payload=speaker_config_payload, + ) + +def render_jobs_panel() -> str: + jobs = get_service().list_jobs() + active_statuses = {JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED} + active_jobs = [job for job in jobs if job.status in active_statuses] + active_jobs.sort(key=lambda job: ((job.queue_position or 10_000), -job.created_at)) + finished_jobs = [job for job in jobs if job.status not in active_statuses] + download_flags = {job.id: job_download_flags(job) for job in jobs} + return render_template( + "partials/jobs.html", + active_jobs=active_jobs, + finished_jobs=finished_jobs[:5], + total_finished=len(finished_jobs), + JobStatus=JobStatus, + download_flags=download_flags, + audiobookshelf_manual_available=audiobookshelf_manual_available(), + ) + + +def normalize_wizard_step(step: Optional[str], pending: Optional[PendingJob] = None) -> str: + if pending is None: + default_step = "book" + else: + default_step = "chapters" + if not step: + chosen = default_step + else: + normalized = step.strip().lower() + if normalized in {"", "upload", "settings"}: + chosen = default_step + elif normalized == "speakers": + chosen = "entities" + elif normalized in _WIZARD_STEP_ORDER: + chosen = normalized + else: + chosen = default_step + return chosen + + +def wants_wizard_json() -> bool: + format_hint = request.args.get("format", "").strip().lower() + if format_hint == "json": + return True + accept_header = (request.headers.get("Accept") or "").lower() + if "application/json" in accept_header: + return True + requested_with = (request.headers.get("X-Requested-With") or "").lower() + if requested_with in {"xmlhttprequest", "fetch"}: + return True + wizard_header = (request.headers.get("X-Abogen-Wizard") or "").lower() + return wizard_header == "json" + + +def render_wizard_partial( + pending: Optional[PendingJob], + step: str, + *, + error: Optional[str] = None, + notice: Optional[str] = None, +) -> str: + templates = { + "book": "partials/new_job_step_book.html", + "chapters": "partials/new_job_step_chapters.html", + "entities": "partials/new_job_step_entities.html", + } + template_name = templates[step] + context: Dict[str, Any] = { + "pending": pending, + "readonly": False, + "options": template_options(), + "settings": load_settings(), + "error": error, + "notice": notice, + } + return render_template(template_name, **context) + + +def wizard_step_payload( + pending: Optional[PendingJob], + step: str, + html: str, + *, + error: Optional[str] = None, + notice: Optional[str] = None, +) -> Dict[str, Any]: + meta = _WIZARD_STEP_META.get(step, {}) + try: + active_index = _WIZARD_STEP_ORDER.index(step) + except ValueError: + active_index = 0 + max_recorded_index = active_index + if pending is not None: + stored_index = int(getattr(pending, "wizard_max_step_index", -1)) + if stored_index < 0: + stored_index = -1 + max_recorded_index = max(active_index, stored_index) + max_allowed = len(_WIZARD_STEP_ORDER) - 1 + if max_recorded_index > max_allowed: + max_recorded_index = max_allowed + if stored_index != max_recorded_index: + pending.wizard_max_step_index = max_recorded_index + get_service().store_pending_job(pending) + else: + max_allowed = len(_WIZARD_STEP_ORDER) - 1 + if max_recorded_index > max_allowed: + max_recorded_index = max_allowed + completed = [slug for idx, slug in enumerate(_WIZARD_STEP_ORDER) if idx <= max_recorded_index] + return { + "step": step, + "step_index": int(meta.get("index", active_index + 1)), + "total_steps": len(_WIZARD_STEP_ORDER), + "title": meta.get("title", ""), + "hint": meta.get("hint", ""), + "html": html, + "completed_steps": completed, + "pending_id": pending.id if pending else "", + "filename": pending.original_filename if pending and pending.original_filename else "", + "error": error or "", + "notice": notice or "", + } + + +def wizard_json_response( + pending: Optional[PendingJob], + step: str, + *, + error: Optional[str] = None, + notice: Optional[str] = None, + status: int = 200, +) -> ResponseReturnValue: + html = render_wizard_partial(pending, step, error=error, notice=notice) + payload = wizard_step_payload(pending, step, html, error=error, notice=notice) + return jsonify(payload), status diff --git a/abogen/webui/routes/utils/preview.py b/abogen/webui/routes/utils/preview.py new file mode 100644 index 0000000..95c3040 --- /dev/null +++ b/abogen/webui/routes/utils/preview.py @@ -0,0 +1,210 @@ +import io +import threading +from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple +import numpy as np +import soundfile as sf +from flask import current_app, send_file +from flask.typing import ResponseReturnValue + + +SPLIT_PATTERN = r"\n+" +SAMPLE_RATE = 24000 + +_preview_pipelines: Dict[Tuple[str, str], Any] = {} +_preview_pipeline_lock = threading.Lock() + + +def _select_device() -> str: + import platform + + system = platform.system() + if system == "Darwin" and platform.processor() == "arm": + return "mps" + return "cuda" + + +def _to_float32(audio_segment) -> np.ndarray: + if audio_segment is None: + return np.zeros(0, dtype="float32") + + tensor = audio_segment + if hasattr(tensor, "detach"): + tensor = tensor.detach() + if hasattr(tensor, "cpu"): + try: + tensor = tensor.cpu() + except Exception: + pass + if hasattr(tensor, "numpy"): + return np.asarray(tensor.numpy(), dtype="float32").reshape(-1) + return np.asarray(tensor, dtype="float32").reshape(-1) + +def get_preview_pipeline(language: str, device: str) -> Any: + key = (language, device) + with _preview_pipeline_lock: + pipeline = _preview_pipelines.get(key) + if pipeline is not None: + return pipeline + from abogen.utils import load_numpy_kpipeline + + _, KPipeline = load_numpy_kpipeline() + pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device) + _preview_pipelines[key] = pipeline + return pipeline + +def generate_preview_audio( + text: str, + voice_spec: str, + language: str, + speed: float, + use_gpu: bool, + tts_provider: str = "kokoro", + supertonic_total_steps: int = 5, + max_seconds: float = 8.0, + pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None, + manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None, + speakers: Optional[Mapping[str, Any]] = None, +) -> bytes: + if not text.strip(): + raise ValueError("Preview text is required") + + provider = (tts_provider or "kokoro").strip().lower() + + # Apply pronunciation/manual overrides first so tokens like `Unfu*k` still match + # before any downstream normalization potentially strips punctuation. + source_text = text + if pronunciation_overrides or manual_overrides or speakers: + try: + from abogen.webui import conversion_runner as runner + + class _PreviewJob: + def __init__(self): + self.language = language + self.voice = voice_spec + self.speakers = speakers + self.manual_overrides = list(manual_overrides or []) + self.pronunciation_overrides = list(pronunciation_overrides or []) + + job = _PreviewJob() + merged = runner._merge_pronunciation_overrides(job) + rules = runner._compile_pronunciation_rules(merged) + source_text = runner._apply_pronunciation_rules(source_text, rules) + except Exception: + current_app.logger.exception("Preview override application failed; using raw text") + source_text = text + + normalized_text = source_text + if provider != "supertonic": + try: + from abogen.kokoro_text_normalization import normalize_for_pipeline + + normalized_text = normalize_for_pipeline(source_text) + except Exception: + current_app.logger.exception("Preview normalization failed; using raw text") + normalized_text = source_text + + if provider == "supertonic": + from abogen.tts_supertonic import SupertonicPipeline + + pipeline = SupertonicPipeline(sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps) + segments = pipeline( + normalized_text, + voice=voice_spec, + speed=speed, + split_pattern=SPLIT_PATTERN, + total_steps=supertonic_total_steps, + ) + else: + device = "cpu" + if use_gpu: + try: + device = _select_device() + except Exception: + device = "cpu" + use_gpu = False + + pipeline = get_preview_pipeline(language, device) + if pipeline is None: + raise RuntimeError("Preview pipeline is unavailable") + + voice_choice: Any = voice_spec + if voice_spec and "*" in voice_spec: + from abogen.voice_formulas import get_new_voice + + voice_choice = get_new_voice(pipeline, voice_spec, use_gpu) + + segments = pipeline( + normalized_text, + voice=voice_choice, + speed=speed, + split_pattern=SPLIT_PATTERN, + ) + + audio_chunks: List[np.ndarray] = [] + accumulated = 0 + max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE) + + for segment in segments: + graphemes = getattr(segment, "graphemes", "").strip() + if not graphemes: + continue + audio = _to_float32(getattr(segment, "audio", None)) + if audio.size == 0: + continue + remaining = max_samples - accumulated + if remaining <= 0: + break + if audio.shape[0] > remaining: + audio = audio[:remaining] + audio_chunks.append(audio) + accumulated += audio.shape[0] + if accumulated >= max_samples: + break + + if not audio_chunks: + raise RuntimeError("Preview could not be generated") + + audio_data = np.concatenate(audio_chunks) + buffer = io.BytesIO() + sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV") + return buffer.getvalue() + +def synthesize_preview( + text: str, + voice_spec: str, + language: str, + speed: float, + use_gpu: bool, + tts_provider: str = "kokoro", + supertonic_total_steps: int = 5, + max_seconds: float = 8.0, + pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None, + manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None, + speakers: Optional[Mapping[str, Any]] = None, +) -> ResponseReturnValue: + try: + audio_bytes = generate_preview_audio( + text=text, + voice_spec=voice_spec, + language=language, + speed=speed, + use_gpu=use_gpu, + tts_provider=tts_provider, + supertonic_total_steps=supertonic_total_steps, + max_seconds=max_seconds, + pronunciation_overrides=pronunciation_overrides, + manual_overrides=manual_overrides, + speakers=speakers, + ) + except Exception as e: + raise e + + buffer = io.BytesIO(audio_bytes) + response = send_file( + buffer, + mimetype="audio/wav", + as_attachment=False, + download_name="speaker_preview.wav", + ) + response.headers["Cache-Control"] = "no-store" + return response diff --git a/abogen/webui/routes/utils/service.py b/abogen/webui/routes/utils/service.py new file mode 100644 index 0000000..b48f94a --- /dev/null +++ b/abogen/webui/routes/utils/service.py @@ -0,0 +1,67 @@ +from typing import cast +from flask import current_app, abort +from abogen.webui.service import ConversionService, PendingJob + +def get_service() -> ConversionService: + return current_app.extensions["conversion_service"] + +def require_pending_job(pending_id: str) -> PendingJob: + pending = get_service().get_pending_job(pending_id) + if not pending: + abort(404) + return cast(PendingJob, pending) + +def remove_pending_job(pending_id: str) -> None: + get_service().pop_pending_job(pending_id) + +def submit_job(pending: PendingJob) -> str: + service = get_service() + service.pop_pending_job(pending.id) + + job = service.enqueue( + original_filename=pending.original_filename, + stored_path=pending.stored_path, + language=pending.language, + tts_provider=getattr(pending, "tts_provider", "kokoro"), + voice=pending.voice, + speed=pending.speed, + supertonic_total_steps=getattr(pending, "supertonic_total_steps", 5), + use_gpu=pending.use_gpu, + subtitle_mode=pending.subtitle_mode, + output_format=pending.output_format, + save_mode=pending.save_mode, + output_folder=pending.output_folder, + replace_single_newlines=pending.replace_single_newlines, + subtitle_format=pending.subtitle_format, + total_characters=pending.total_characters, + chapters=pending.chapters, + save_chapters_separately=pending.save_chapters_separately, + merge_chapters_at_end=pending.merge_chapters_at_end, + separate_chapters_format=pending.separate_chapters_format, + silence_between_chapters=pending.silence_between_chapters, + save_as_project=pending.save_as_project, + voice_profile=pending.voice_profile, + max_subtitle_words=pending.max_subtitle_words, + metadata_tags=pending.metadata_tags, + cover_image_path=pending.cover_image_path, + cover_image_mime=pending.cover_image_mime, + chapter_intro_delay=pending.chapter_intro_delay, + read_title_intro=pending.read_title_intro, + read_closing_outro=pending.read_closing_outro, + auto_prefix_chapter_titles=pending.auto_prefix_chapter_titles, + normalize_chapter_opening_caps=pending.normalize_chapter_opening_caps, + chunk_level=pending.chunk_level, + chunks=pending.chunks, + speakers=pending.speakers, + speaker_mode=pending.speaker_mode, + generate_epub3=pending.generate_epub3, + speaker_analysis=pending.speaker_analysis, + speaker_analysis_threshold=pending.speaker_analysis_threshold, + analysis_requested=pending.analysis_requested, + entity_summary=getattr(pending, "entity_summary", None), + manual_overrides=getattr(pending, "manual_overrides", None), + pronunciation_overrides=getattr(pending, "pronunciation_overrides", None), + heteronym_overrides=getattr(pending, "heteronym_overrides", None), + normalization_overrides=pending.normalization_overrides, + ) + return job.id diff --git a/abogen/webui/routes/utils/settings.py b/abogen/webui/routes/utils/settings.py new file mode 100644 index 0000000..c96a66c --- /dev/null +++ b/abogen/webui/routes/utils/settings.py @@ -0,0 +1,752 @@ +import os +import re +from typing import Any, Dict, Mapping, Optional + +from abogen.constants import ( + LANGUAGE_DESCRIPTIONS, + SUBTITLE_FORMATS, + SUPPORTED_SOUND_FORMATS, + VOICES_INTERNAL, +) +from abogen.normalization_settings import ( + DEFAULT_LLM_PROMPT, + environment_llm_defaults, +) +from abogen.utils import load_config, save_config +from abogen.integrations.calibre_opds import CalibreOPDSClient +from abogen.integrations.audiobookshelf import AudiobookshelfConfig +from abogen.webui.routes.utils.common import split_profile_spec + +SAVE_MODE_LABELS = { + "save_next_to_input": "Save next to input file", + "save_to_desktop": "Save to Desktop", + "choose_output_folder": "Choose output folder", + "default_output": "Use default save location", +} + +LEGACY_SAVE_MODE_MAP = {label: key for key, label in SAVE_MODE_LABELS.items()} + +_CHUNK_LEVEL_OPTIONS = [ + {"value": "paragraph", "label": "Paragraphs"}, + {"value": "sentence", "label": "Sentences"}, +] + +_CHUNK_LEVEL_VALUES = {option["value"] for option in _CHUNK_LEVEL_OPTIONS} + +_DEFAULT_ANALYSIS_THRESHOLD = 3 + +_APOSTROPHE_MODE_OPTIONS = [ + {"value": "off", "label": "Off"}, + {"value": "spacy", "label": "spaCy (built-in)"}, + {"value": "llm", "label": "LLM assisted"}, +] + +_NORMALIZATION_BOOLEAN_KEYS = { + "normalization_numbers", + "normalization_titles", + "normalization_terminal", + "normalization_phoneme_hints", + "normalization_caps_quotes", + "normalization_currency", + "normalization_footnotes", + "normalization_internet_slang", + "normalization_apostrophes_contractions", + "normalization_apostrophes_plural_possessives", + "normalization_apostrophes_sibilant_possessives", + "normalization_apostrophes_decades", + "normalization_apostrophes_leading_elisions", + "normalization_contraction_aux_be", + "normalization_contraction_aux_have", + "normalization_contraction_modal_will", + "normalization_contraction_modal_would", + "normalization_contraction_negation_not", + "normalization_contraction_let_us", +} + +_NORMALIZATION_STRING_KEYS = { + "normalization_numbers_year_style", + "normalization_apostrophe_mode", +} + +BOOLEAN_SETTINGS = { + "replace_single_newlines", + "use_gpu", + "save_chapters_separately", + "merge_chapters_at_end", + "save_as_project", + "generate_epub3", + "enable_entity_recognition", + "read_title_intro", + "read_closing_outro", + "auto_prefix_chapter_titles", + "normalize_chapter_opening_caps", + "normalization_numbers", + "normalization_titles", + "normalization_terminal", + "normalization_phoneme_hints", + "normalization_caps_quotes", + "normalization_currency", + "normalization_footnotes", + "normalization_internet_slang", + "normalization_apostrophes_contractions", + "normalization_apostrophes_plural_possessives", + "normalization_apostrophes_sibilant_possessives", + "normalization_apostrophes_decades", + "normalization_apostrophes_leading_elisions", + "normalization_contraction_aux_be", + "normalization_contraction_aux_have", + "normalization_contraction_modal_will", + "normalization_contraction_modal_would", + "normalization_contraction_negation_not", + "normalization_contraction_let_us", +} + +FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay", "llm_timeout"} +INT_SETTINGS = {"max_subtitle_words", "speaker_analysis_threshold"} + +_NORMALIZATION_GROUPS = [ + { + "label": "General Rules", + "options": [ + {"key": "normalization_numbers", "label": "Convert grouped numbers to words"}, + {"key": "normalization_currency", "label": "Convert currency symbols ($10 → ten dollars)"}, + {"key": "normalization_titles", "label": "Expand titles and suffixes (Dr., St., Jr., …)"}, + {"key": "normalization_internet_slang", "label": "Expand internet slang (pls → please)"}, + {"key": "normalization_footnotes", "label": "Remove footnote indicators ([1], [2])"}, + {"key": "normalization_terminal", "label": "Ensure sentences end with terminal punctuation"}, + {"key": "normalization_caps_quotes", "label": "Convert ALL CAPS dialogue inside quotes"}, + ] + }, + { + "label": "Apostrophes & Contractions", + "options": [ + {"key": "normalization_apostrophes_contractions", "label": "Expand contractions (it's → it is)"}, + {"key": "normalization_apostrophes_plural_possessives", "label": "Collapse plural possessives (dogs' → dogs)"}, + {"key": "normalization_apostrophes_sibilant_possessives", "label": "Mark sibilant possessives (boss's → boss + IZ marker)"}, + {"key": "normalization_apostrophes_decades", "label": "Expand decades ('90s → 1990s)"}, + {"key": "normalization_apostrophes_leading_elisions", "label": "Expand leading elisions ('tis → it is)"}, + {"key": "normalization_phoneme_hints", "label": "Add phoneme hints for possessives"}, + {"key": "normalization_contraction_aux_be", "label": "Expand auxiliary 'be' (I'm → I am)"}, + {"key": "normalization_contraction_aux_have", "label": "Expand auxiliary 'have' (I've → I have)"}, + {"key": "normalization_contraction_modal_will", "label": "Expand modal 'will' (I'll → I will)"}, + {"key": "normalization_contraction_modal_would", "label": "Expand modal 'would' (I'd → I would)"}, + {"key": "normalization_contraction_negation_not", "label": "Expand negation 'not' (don't → do not)"}, + {"key": "normalization_contraction_let_us", "label": "Expand 'let's' → let us"}, + ] + } +] + + +def integration_defaults() -> Dict[str, Dict[str, Any]]: + return { + "calibre_opds": { + "enabled": False, + "base_url": "", + "username": "", + "password": "", + "verify_ssl": True, + }, + "audiobookshelf": { + "enabled": False, + "base_url": "", + "api_token": "", + "library_id": "", + "collection_id": "", + "folder_id": "", + "verify_ssl": True, + "send_cover": True, + "send_chapters": True, + "send_subtitles": False, + "auto_send": False, + "timeout": 30.0, + }, + } + + +def has_output_override() -> bool: + return bool(os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get("ABOGEN_OUTPUT_ROOT")) + + +def settings_defaults() -> Dict[str, Any]: + llm_env_defaults = environment_llm_defaults() + return { + "output_format": "wav", + "subtitle_format": "srt", + "save_mode": "default_output" if has_output_override() else "save_next_to_input", + "default_speaker": "", + "default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "", + "supertonic_total_steps": 5, + "supertonic_speed": 1.0, + "replace_single_newlines": False, + "use_gpu": True, + "save_chapters_separately": False, + "merge_chapters_at_end": True, + "save_as_project": False, + "separate_chapters_format": "wav", + "silence_between_chapters": 2.0, + "chapter_intro_delay": 0.5, + "read_title_intro": False, + "read_closing_outro": True, + "normalize_chapter_opening_caps": True, + "max_subtitle_words": 50, + "chunk_level": "paragraph", + "enable_entity_recognition": True, + "generate_epub3": False, + "auto_prefix_chapter_titles": True, + "speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD, + "speaker_pronunciation_sentence": "This is {{name}} speaking.", + "speaker_random_languages": [], + "llm_base_url": llm_env_defaults.get("llm_base_url", ""), + "llm_api_key": llm_env_defaults.get("llm_api_key", ""), + "llm_model": llm_env_defaults.get("llm_model", ""), + "llm_timeout": llm_env_defaults.get("llm_timeout", 30.0), + "llm_prompt": llm_env_defaults.get("llm_prompt", DEFAULT_LLM_PROMPT), + "llm_context_mode": llm_env_defaults.get("llm_context_mode", "sentence"), + "normalization_numbers": True, + "normalization_currency": True, + "normalization_footnotes": True, + "normalization_titles": True, + "normalization_terminal": True, + "normalization_phoneme_hints": True, + "normalization_caps_quotes": True, + "normalization_internet_slang": False, + "normalization_apostrophes_contractions": True, + "normalization_apostrophes_plural_possessives": True, + "normalization_apostrophes_sibilant_possessives": True, + "normalization_apostrophes_decades": True, + "normalization_apostrophes_leading_elisions": True, + "normalization_apostrophe_mode": "spacy", + "normalization_numbers_year_style": "american", + "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, + } + + +def llm_ready(settings: Mapping[str, Any]) -> bool: + base_url = str(settings.get("llm_base_url") or "").strip() + return bool(base_url) + + +_PROMPT_TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}") + + +def render_prompt_template(template: str, context: Mapping[str, str]) -> str: + if not template: + return "" + + def _replace(match: re.Match[str]) -> str: + key = match.group(1) + return context.get(key, "") + + return _PROMPT_TOKEN_RE.sub(_replace, template) + + +def coerce_bool(value: Any, default: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in {"true", "1", "yes", "on"} + if value is None: + return default + return bool(value) + + +def coerce_float(value: Any, default: float) -> float: + try: + return max(0.0, float(value)) + except (TypeError, ValueError): + return default + + +def coerce_int(value: Any, default: int, *, minimum: int = 1, maximum: int = 200) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return max(minimum, min(parsed, maximum)) + + +def normalize_save_mode(value: Any, default: str) -> str: + if isinstance(value, str): + if value in SAVE_MODE_LABELS: + return value + if value in LEGACY_SAVE_MODE_MAP: + return LEGACY_SAVE_MODE_MAP[value] + return default + + +def normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> Any: + if key in BOOLEAN_SETTINGS: + return coerce_bool(value, defaults[key]) + if key in FLOAT_SETTINGS: + return coerce_float(value, defaults[key]) + if key in INT_SETTINGS: + return coerce_int(value, defaults[key]) + if key == "save_mode": + return normalize_save_mode(value, defaults[key]) + if key == "output_format": + return value if value in SUPPORTED_SOUND_FORMATS else defaults[key] + if key == "subtitle_format": + valid = {item[0] for item in SUBTITLE_FORMATS} + return value if value in valid else defaults[key] + if key == "separate_chapters_format": + if isinstance(value, str): + normalized = value.lower() + if normalized in {"wav", "flac", "mp3", "opus"}: + return normalized + return defaults[key] + if key == "default_voice": + if isinstance(value, str): + text = value.strip() + if not text: + return defaults[key] + spec, profile_name = split_profile_spec(text) + if profile_name: + return f"speaker:{profile_name}" + return spec + return defaults[key] + if key == "default_speaker": + if isinstance(value, str): + text = value.strip() + if not text: + return "" + spec, profile_name = split_profile_spec(text) + if profile_name: + return f"speaker:{profile_name}" + return spec + return "" + if key == "chunk_level": + if isinstance(value, str) and value in _CHUNK_LEVEL_VALUES: + return value + return defaults[key] + if key == "normalization_apostrophe_mode": + if isinstance(value, str): + normalized_mode = value.strip().lower() + if normalized_mode in {"off", "spacy", "llm"}: + return normalized_mode + return defaults[key] + if key == "normalization_numbers_year_style": + if isinstance(value, str): + normalized_style = value.strip().lower() + if normalized_style in {"american", "off"}: + return normalized_style + return defaults[key] + if key == "llm_context_mode": + if isinstance(value, str): + normalized_scope = value.strip().lower() + if normalized_scope == "sentence": + return normalized_scope + return defaults[key] + if key == "llm_prompt": + candidate = str(value or "").strip() + return candidate if candidate else defaults[key] + if key in {"llm_base_url", "llm_api_key", "llm_model"}: + return str(value or "").strip() + if key == "speaker_random_languages": + if isinstance(value, (list, tuple, set)): + return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS] + if isinstance(value, str): + parts = [item.strip().lower() for item in value.split(",") if item.strip()] + return [code for code in parts if code in LANGUAGE_DESCRIPTIONS] + return defaults.get(key, []) + if key == "supertonic_total_steps": + try: + steps = int(value) + except (TypeError, ValueError): + return defaults.get(key, 5) + return max(2, min(15, steps)) + if key == "supertonic_speed": + try: + speed = float(value) + except (TypeError, ValueError): + return defaults.get(key, 1.0) + return max(0.7, min(2.0, speed)) + return value if value is not None else defaults.get(key) + + +def load_settings() -> Dict[str, Any]: + defaults = settings_defaults() + cfg = load_config() or {} + settings: Dict[str, Any] = {} + for key, default in defaults.items(): + raw_value = cfg.get(key, default) + settings[key] = normalize_setting_value(key, raw_value, defaults) + return settings + + +def load_integration_settings() -> Dict[str, Dict[str, Any]]: + defaults = integration_defaults() + cfg = load_config() or {} + # Integrations are stored under the "integrations" key in the config + stored_integrations = cfg.get("integrations", {}) + if not isinstance(stored_integrations, Mapping): + stored_integrations = {} + + integrations: Dict[str, Dict[str, Any]] = {} + for key, default in defaults.items(): + stored = stored_integrations.get(key) + merged: Dict[str, Any] = dict(default) + if isinstance(stored, Mapping): + for field, default_value in default.items(): + value = stored.get(field, default_value) + if isinstance(default_value, bool): + merged[field] = coerce_bool(value, default_value) + elif isinstance(default_value, float): + try: + merged[field] = float(value) + except (TypeError, ValueError): + merged[field] = default_value + elif isinstance(default_value, int): + try: + merged[field] = int(value) + except (TypeError, ValueError): + merged[field] = default_value + else: + merged[field] = str(value or "") + if key == "calibre_opds": + merged["has_password"] = bool(isinstance(stored, Mapping) and stored.get("password")) + # Do not clear the password here, let the template decide whether to show it or not + # merged["password"] = "" + elif key == "audiobookshelf": + merged["has_api_token"] = bool(isinstance(stored, Mapping) and stored.get("api_token")) + # Do not clear the token here + # merged["api_token"] = "" + integrations[key] = merged + + # Environment variable fallbacks for Calibre OPDS + calibre = integrations["calibre_opds"] + if not calibre.get("base_url"): + calibre["base_url"] = os.environ.get("CALIBRE_SERVER_HOST", "") + if not calibre.get("username"): + calibre["username"] = os.environ.get("OPDS_USERNAME", "") + if not calibre.get("password"): + calibre["password"] = os.environ.get("OPDS_PASSWORD", "") + + # If we have a password (from storage or env), mark it as present for the UI + if calibre.get("password"): + calibre["has_password"] = True + + # Auto-enable if configured via env but not explicitly disabled in config + stored_calibre = stored_integrations.get("calibre_opds") + if stored_calibre is None and calibre.get("base_url"): + calibre["enabled"] = True + + return integrations + + +def stored_integration_config(name: str) -> Dict[str, Any]: + cfg = load_config() or {} + # Check under "integrations" first (new structure) + integrations = cfg.get("integrations") + if isinstance(integrations, Mapping): + entry = integrations.get(name) + if isinstance(entry, Mapping): + return dict(entry) + + # Fallback to top-level (legacy structure) + entry = cfg.get(name) + if isinstance(entry, Mapping): + return dict(entry) + return {} + + +def calibre_settings_from_payload(payload: Mapping[str, Any]) -> Dict[str, Any]: + defaults = integration_defaults()["calibre_opds"] + stored = stored_integration_config("calibre_opds") + + base_url = str( + payload.get("base_url") + or payload.get("calibre_opds_base_url") + or stored.get("base_url") + or "" + ).strip() + username = str( + payload.get("username") + or payload.get("calibre_opds_username") + or stored.get("username") + or "" + ).strip() + password_input = str( + payload.get("password") + or payload.get("calibre_opds_password") + or "" + ).strip() + use_saved_password = coerce_bool( + payload.get("use_saved_password") + or payload.get("calibre_opds_use_saved_password"), + False, + ) + clear_saved_password = coerce_bool( + payload.get("clear_saved_password") + or payload.get("calibre_opds_password_clear"), + False, + ) + password = "" + if password_input: + password = password_input + elif use_saved_password and not clear_saved_password: + password = str(stored.get("password") or "") + + verify_ssl = coerce_bool( + payload.get("verify_ssl") + or payload.get("calibre_opds_verify_ssl"), + defaults["verify_ssl"], + ) + enabled = coerce_bool( + payload.get("enabled") + or payload.get("calibre_opds_enabled"), + coerce_bool(stored.get("enabled"), False), + ) + + return { + "enabled": enabled, + "base_url": base_url, + "username": username, + "password": password, + "verify_ssl": verify_ssl, + } + + +def audiobookshelf_settings_from_payload(payload: Mapping[str, Any]) -> Dict[str, Any]: + defaults = integration_defaults()["audiobookshelf"] + stored = stored_integration_config("audiobookshelf") + + base_url = str( + payload.get("base_url") + or payload.get("audiobookshelf_base_url") + or stored.get("base_url") + or "" + ).strip() + library_id = str( + payload.get("library_id") + or payload.get("audiobookshelf_library_id") + or stored.get("library_id") + or "" + ).strip() + collection_id = str( + payload.get("collection_id") + or payload.get("audiobookshelf_collection_id") + or stored.get("collection_id") + or "" + ).strip() + folder_id = str( + payload.get("folder_id") + or payload.get("audiobookshelf_folder_id") + or stored.get("folder_id") + or "" + ).strip() + token_input = str( + payload.get("api_token") + or payload.get("audiobookshelf_api_token") + or "" + ).strip() + use_saved_token = coerce_bool( + payload.get("use_saved_token") + or payload.get("audiobookshelf_use_saved_token"), + False, + ) + clear_saved_token = coerce_bool( + payload.get("clear_saved_token") + or payload.get("audiobookshelf_api_token_clear"), + False, + ) + if token_input: + api_token = token_input + elif use_saved_token and not clear_saved_token: + api_token = str(stored.get("api_token") or "") + else: + api_token = "" + + verify_ssl = coerce_bool( + payload.get("verify_ssl") + or payload.get("audiobookshelf_verify_ssl"), + defaults["verify_ssl"], + ) + send_cover = coerce_bool( + payload.get("send_cover") + or payload.get("audiobookshelf_send_cover"), + defaults["send_cover"], + ) + send_chapters = coerce_bool( + payload.get("send_chapters") + or payload.get("audiobookshelf_send_chapters"), + defaults["send_chapters"], + ) + send_subtitles = coerce_bool( + payload.get("send_subtitles") + or payload.get("audiobookshelf_send_subtitles"), + defaults["send_subtitles"], + ) + auto_send = coerce_bool( + payload.get("auto_send") + or payload.get("audiobookshelf_auto_send"), + defaults["auto_send"], + ) + timeout_raw = ( + payload.get("timeout") + or payload.get("audiobookshelf_timeout") + or stored.get("timeout") + or defaults["timeout"] + ) + try: + timeout = float(timeout_raw) + except (TypeError, ValueError): + timeout = defaults["timeout"] + + enabled = coerce_bool( + payload.get("enabled") + or payload.get("audiobookshelf_enabled"), + coerce_bool(stored.get("enabled"), False), + ) + + return { + "enabled": enabled, + "base_url": base_url, + "library_id": library_id, + "collection_id": collection_id, + "folder_id": folder_id, + "api_token": api_token, + "verify_ssl": verify_ssl, + "send_cover": send_cover, + "send_chapters": send_chapters, + "send_subtitles": send_subtitles, + "auto_send": auto_send, + "timeout": timeout, + } + + +def build_audiobookshelf_config(settings: Mapping[str, Any]) -> Optional[AudiobookshelfConfig]: + base_url = str(settings.get("base_url") or "").strip() + api_token = str(settings.get("api_token") or "").strip() + library_id = str(settings.get("library_id") or "").strip() + if not (base_url and api_token and library_id): + return None + try: + timeout = float(settings.get("timeout", 3600.0)) + except (TypeError, ValueError): + timeout = 3600.0 + return AudiobookshelfConfig( + base_url=base_url, + api_token=api_token, + library_id=library_id, + collection_id=(str(settings.get("collection_id") or "").strip() or None), + folder_id=(str(settings.get("folder_id") or "").strip() or None), + verify_ssl=coerce_bool(settings.get("verify_ssl"), True), + send_cover=coerce_bool(settings.get("send_cover"), True), + send_chapters=coerce_bool(settings.get("send_chapters"), True), + send_subtitles=coerce_bool(settings.get("send_subtitles"), False), + timeout=timeout, + ) + + +def calibre_integration_enabled( + integrations: Optional[Mapping[str, Any]] = None, +) -> bool: + if integrations is None: + integrations = load_integration_settings() + payload = integrations.get("calibre_opds") if isinstance(integrations, Mapping) else None + if not isinstance(payload, Mapping): + return False + base_url = str(payload.get("base_url") or "").strip() + enabled_flag = coerce_bool(payload.get("enabled"), False) + return bool(enabled_flag and base_url) + + +def audiobookshelf_manual_available() -> bool: + settings = stored_integration_config("audiobookshelf") + if not settings: + return False + return coerce_bool(settings.get("enabled"), False) + + +def build_calibre_client(settings: Mapping[str, Any]) -> CalibreOPDSClient: + base_url = str(settings.get("base_url") or "").strip() + if not base_url: + raise ValueError("Calibre OPDS base URL is required") + username = str(settings.get("username") or "").strip() or None + password = str(settings.get("password") or "").strip() or None + verify_ssl = coerce_bool(settings.get("verify_ssl"), True) + timeout_raw = settings.get("timeout", 15.0) + try: + timeout = float(timeout_raw) + except (TypeError, ValueError): + timeout = 15.0 + return CalibreOPDSClient( + base_url, + username=username, + password=password, + timeout=timeout, + verify=verify_ssl, + ) + + +def apply_integration_form(cfg: Dict[str, Any], form: Mapping[str, Any]) -> None: + defaults = integration_defaults() + + current_calibre = dict(cfg.get("calibre_opds") or {}) + calibre_enabled = coerce_bool(form.get("calibre_opds_enabled"), False) + calibre_base = str(form.get("calibre_opds_base_url") or current_calibre.get("base_url") or "").strip() + calibre_username = str(form.get("calibre_opds_username") or current_calibre.get("username") or "").strip() + calibre_password_input = str(form.get("calibre_opds_password") or "") + calibre_clear = coerce_bool(form.get("calibre_opds_password_clear"), False) + if calibre_password_input: + calibre_password = calibre_password_input + elif calibre_clear: + calibre_password = "" + else: + calibre_password = str(current_calibre.get("password") or "") + calibre_verify = coerce_bool(form.get("calibre_opds_verify_ssl"), defaults["calibre_opds"]["verify_ssl"]) + cfg["calibre_opds"] = { + "enabled": calibre_enabled, + "base_url": calibre_base, + "username": calibre_username, + "password": calibre_password, + "verify_ssl": calibre_verify, + } + + current_abs = dict(cfg.get("audiobookshelf") or {}) + abs_enabled = coerce_bool(form.get("audiobookshelf_enabled"), False) + abs_base = str(form.get("audiobookshelf_base_url") or current_abs.get("base_url") or "").strip() + abs_library = str(form.get("audiobookshelf_library_id") or current_abs.get("library_id") or "").strip() + abs_collection = str(form.get("audiobookshelf_collection_id") or current_abs.get("collection_id") or "").strip() + abs_folder = str(form.get("audiobookshelf_folder_id") or current_abs.get("folder_id") or "").strip() + abs_token_input = str(form.get("audiobookshelf_api_token") or "") + abs_token_clear = coerce_bool(form.get("audiobookshelf_api_token_clear"), False) + if abs_token_input: + abs_token = abs_token_input + elif abs_token_clear: + abs_token = "" + else: + abs_token = str(current_abs.get("api_token") or "") + abs_verify = coerce_bool(form.get("audiobookshelf_verify_ssl"), defaults["audiobookshelf"]["verify_ssl"]) + abs_send_cover = coerce_bool(form.get("audiobookshelf_send_cover"), defaults["audiobookshelf"]["send_cover"]) + abs_send_chapters = coerce_bool(form.get("audiobookshelf_send_chapters"), defaults["audiobookshelf"]["send_chapters"]) + abs_send_subtitles = coerce_bool(form.get("audiobookshelf_send_subtitles"), defaults["audiobookshelf"]["send_subtitles"]) + abs_auto_send = coerce_bool(form.get("audiobookshelf_auto_send"), defaults["audiobookshelf"]["auto_send"]) + timeout_raw = form.get("audiobookshelf_timeout", current_abs.get("timeout", defaults["audiobookshelf"]["timeout"])) + try: + abs_timeout = float(timeout_raw) + except (TypeError, ValueError): + abs_timeout = defaults["audiobookshelf"]["timeout"] + cfg["audiobookshelf"] = { + "enabled": abs_enabled, + "base_url": abs_base, + "api_token": abs_token, + "library_id": abs_library, + "collection_id": abs_collection, + "folder_id": abs_folder, + "verify_ssl": abs_verify, + "send_cover": abs_send_cover, + "send_chapters": abs_send_chapters, + "send_subtitles": abs_send_subtitles, + "auto_send": abs_auto_send, + "timeout": abs_timeout, + } + + +def save_settings(settings: Dict[str, Any]) -> None: + save_config(settings) diff --git a/abogen/webui/routes/utils/voice.py b/abogen/webui/routes/utils/voice.py new file mode 100644 index 0000000..3d9081a --- /dev/null +++ b/abogen/webui/routes/utils/voice.py @@ -0,0 +1,809 @@ +import threading +from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast +import numpy as np + +from abogen.speaker_configs import slugify_label +from abogen.speaker_analysis import analyze_speakers +from abogen.webui.routes.utils.settings import load_settings, settings_defaults, _DEFAULT_ANALYSIS_THRESHOLD, _CHUNK_LEVEL_OPTIONS, _APOSTROPHE_MODE_OPTIONS, _NORMALIZATION_GROUPS +from abogen.webui.routes.utils.common import split_profile_spec +from abogen.voice_profiles import ( + load_profiles, + serialize_profiles, +) +from abogen.voice_formulas import get_new_voice, parse_formula_terms +from abogen.constants import ( + LANGUAGE_DESCRIPTIONS, + SUBTITLE_FORMATS, + SUPPORTED_SOUND_FORMATS, + SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, + SAMPLE_VOICE_TEXTS, + VOICES_INTERNAL, +) +from abogen.speaker_configs import list_configs +from abogen.utils import load_numpy_kpipeline +from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN + +_preview_pipeline_lock = threading.RLock() +_preview_pipelines: Dict[Tuple[str, str], Any] = {} + +def build_narrator_roster( + voice: str, + voice_profile: Optional[str], + existing: Optional[Mapping[str, Any]] = None, +) -> Dict[str, Any]: + roster: Dict[str, Any] = { + "narrator": { + "id": "narrator", + "label": "Narrator", + "voice": voice, + } + } + if voice_profile: + roster["narrator"]["voice_profile"] = voice_profile + existing_entry: Optional[Mapping[str, Any]] = None + if existing is not None: + existing_entry = existing.get("narrator") if isinstance(existing, Mapping) else None + if isinstance(existing_entry, Mapping): + roster_entry = roster["narrator"] + for key in ("label", "voice", "voice_profile", "voice_formula", "pronunciation"): + value = existing_entry.get(key) + if value is not None and value != "": + roster_entry[key] = value + return roster + + +def build_speaker_roster( + analysis: Dict[str, Any], + base_voice: str, + voice_profile: Optional[str], + existing: Optional[Mapping[str, Any]] = None, + order: Optional[Iterable[str]] = None, +) -> Dict[str, Any]: + roster = build_narrator_roster(base_voice, voice_profile, existing) + existing_map: Dict[str, Any] = dict(existing) if isinstance(existing, Mapping) else {} + speakers = analysis.get("speakers", {}) if isinstance(analysis, dict) else {} + ordered_ids: Iterable[str] + if order is not None: + ordered_ids = [sid for sid in order if sid in speakers] + else: + ordered_ids = speakers.keys() + + for speaker_id in ordered_ids: + payload = speakers.get(speaker_id, {}) + if speaker_id == "narrator": + continue + if isinstance(payload, Mapping) and payload.get("suppressed"): + continue + previous = existing_map.get(speaker_id) + roster[speaker_id] = { + "id": speaker_id, + "label": payload.get("label") or speaker_id.replace("_", " ").title(), + "analysis_confidence": payload.get("confidence"), + "analysis_count": payload.get("count"), + "gender": payload.get("gender", "unknown"), + } + detected_gender = payload.get("detected_gender") + if detected_gender: + roster[speaker_id]["detected_gender"] = detected_gender + samples = payload.get("sample_quotes") + if isinstance(samples, list): + roster[speaker_id]["sample_quotes"] = samples + if isinstance(previous, Mapping): + for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"): + value = previous.get(key) + if value is not None and value != "": + roster[speaker_id][key] = value + if "sample_quotes" not in roster[speaker_id]: + prev_samples = previous.get("sample_quotes") + if isinstance(prev_samples, list): + roster[speaker_id]["sample_quotes"] = prev_samples + if "detected_gender" not in roster[speaker_id]: + prev_detected = previous.get("detected_gender") + if isinstance(prev_detected, str) and prev_detected: + roster[speaker_id]["detected_gender"] = prev_detected + return roster + + +def match_configured_speaker( + config_speakers: Mapping[str, Any], + roster_id: str, + roster_label: str, +) -> Optional[Mapping[str, Any]]: + if not config_speakers: + return None + entry = config_speakers.get(roster_id) + if entry: + return cast(Mapping[str, Any], entry) + slug = slugify_label(roster_label) + if slug != roster_id and slug in config_speakers: + return cast(Mapping[str, Any], config_speakers[slug]) + lower_label = roster_label.strip().lower() + for record in config_speakers.values(): + if not isinstance(record, Mapping): + continue + if str(record.get("label", "")).strip().lower() == lower_label: + return record + return None + + +def apply_speaker_config_to_roster( + roster: Mapping[str, Any], + config: Optional[Mapping[str, Any]], + *, + persist_changes: bool = False, + fallback_languages: Optional[Iterable[str]] = None, +) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]: + if not isinstance(roster, Mapping): + effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code] + return {}, effective_languages, None + updated_roster: Dict[str, Any] = {key: dict(value) for key, value in roster.items() if isinstance(value, Mapping)} + if not config: + effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code] + return updated_roster, effective_languages, None + + speakers_map = config.get("speakers") + if not isinstance(speakers_map, Mapping): + effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code] + return updated_roster, effective_languages, None + + config_languages = config.get("languages") + if isinstance(config_languages, list): + allowed_languages = [code for code in config_languages if isinstance(code, str) and code] + else: + allowed_languages = [] + if not allowed_languages and fallback_languages: + allowed_languages = [code for code in fallback_languages if isinstance(code, str) and code] + + default_voice = config.get("default_voice") if isinstance(config.get("default_voice"), str) else "" + used_voices = {entry.get("resolved_voice") or entry.get("voice") for entry in updated_roster.values()} - {None} + narrator_voice = "" + narrator_entry = updated_roster.get("narrator") if isinstance(updated_roster, Mapping) else None + if isinstance(narrator_entry, Mapping): + narrator_voice = str( + narrator_entry.get("resolved_voice") + or narrator_entry.get("default_voice") + or "" + ).strip() + if narrator_voice: + used_voices.add(narrator_voice) + + config_changed = False + new_config_payload: Dict[str, Any] = { + "language": config.get("language", "a"), + "languages": allowed_languages, + "default_voice": default_voice, + "speakers": dict(speakers_map), + "version": config.get("version", 1), + "notes": config.get("notes", ""), + } + + speakers_payload = new_config_payload["speakers"] + + for speaker_id, roster_entry in updated_roster.items(): + if speaker_id == "narrator": + continue + label = str(roster_entry.get("label") or speaker_id) + config_entry = match_configured_speaker(speakers_map, speaker_id, label) + if config_entry is None: + continue + voice_id = str(config_entry.get("voice") or "").strip() + voice_profile = str(config_entry.get("voice_profile") or "").strip() + voice_formula = str(config_entry.get("voice_formula") or "").strip() + resolved_voice = str(config_entry.get("resolved_voice") or "").strip() + languages = config_entry.get("languages") if isinstance(config_entry.get("languages"), list) else [] + chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice") + usable_languages = languages or allowed_languages + + if chosen_voice: + roster_entry["resolved_voice"] = chosen_voice + roster_entry["voice"] = chosen_voice if not voice_profile and not voice_formula else roster_entry.get("voice", chosen_voice) + if voice_profile: + roster_entry["voice_profile"] = voice_profile + if voice_formula: + roster_entry["voice_formula"] = voice_formula + roster_entry["resolved_voice"] = voice_formula + if not voice_formula and not voice_profile and resolved_voice: + roster_entry["resolved_voice"] = resolved_voice + roster_entry["config_languages"] = usable_languages or [] + + if chosen_voice: + used_voices.add(chosen_voice) + + # persist updates back to config payload if required + if persist_changes: + slug = config_entry.get("id") or slugify_label(label) + speakers_payload[slug] = { + "id": slug, + "label": label, + "gender": config_entry.get("gender", "unknown"), + "voice": voice_id, + "voice_profile": voice_profile, + "voice_formula": voice_formula, + "resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id), + "languages": usable_languages, + } + + new_config = new_config_payload if (persist_changes and config_changed) else None + return updated_roster, allowed_languages, new_config + + +def filter_voice_catalog( + catalog: Iterable[Mapping[str, Any]], + *, + gender: str, + allowed_languages: Optional[Iterable[str]] = None, +) -> List[str]: + allowed_set = {code.lower() for code in (allowed_languages or []) if isinstance(code, str) and code} + gender_normalized = (gender or "unknown").lower() + gender_code = "" + if gender_normalized == "male": + gender_code = "m" + elif gender_normalized == "female": + gender_code = "f" + + matches: List[str] = [] + seen: set[str] = set() + + def _consider(entry: Mapping[str, Any]) -> None: + voice_id = entry.get("id") + if not isinstance(voice_id, str) or not voice_id: + return + if voice_id in seen: + return + seen.add(voice_id) + matches.append(voice_id) + + primary: List[Mapping[str, Any]] = [] + fallback: List[Mapping[str, Any]] = [] + for entry in catalog: + if not isinstance(entry, Mapping): + continue + voice_lang = str(entry.get("language", "")).lower() + voice_gender_code = str(entry.get("gender_code", "")).lower() + if allowed_set and voice_lang not in allowed_set: + continue + if gender_code and voice_gender_code != gender_code: + fallback.append(entry) + continue + primary.append(entry) + + for entry in primary: + _consider(entry) + + if not matches: + for entry in fallback: + _consider(entry) + + if not matches: + for entry in catalog: + if isinstance(entry, Mapping): + _consider(entry) + + return matches + + +def build_voice_catalog() -> List[Dict[str, str]]: + catalog: List[Dict[str, str]] = [] + gender_map = {"f": "Female", "m": "Male"} + for voice_id in VOICES_INTERNAL: + prefix, _, rest = voice_id.partition("_") + language_code = prefix[0] if prefix else "a" + gender_code = prefix[1] if len(prefix) > 1 else "" + catalog.append( + { + "id": voice_id, + "language": language_code, + "language_label": LANGUAGE_DESCRIPTIONS.get(language_code, language_code.upper()), + "gender": gender_map.get(gender_code, "Unknown"), + "gender_code": gender_code, + "display_name": rest.replace("_", " ").title() if rest else voice_id, + } + ) + return catalog + + +def inject_recommended_voices( + roster: Mapping[str, Any], + *, + fallback_languages: Optional[Iterable[str]] = None, +) -> None: + voice_catalog = build_voice_catalog() + fallback_list = [code for code in (fallback_languages or []) if isinstance(code, str) and code] + for speaker_id, payload in roster.items(): + if not isinstance(payload, dict): + continue + languages = payload.get("config_languages") + if isinstance(languages, list) and languages: + language_list = languages + else: + language_list = fallback_list + gender = str(payload.get("gender", "unknown")) + payload["recommended_voices"] = filter_voice_catalog( + voice_catalog, + gender=gender, + allowed_languages=language_list, + ) + + +def extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str, Any], List[str]]: + getter = getattr(form, "getlist", None) + + def _get_list(name: str) -> List[str]: + if callable(getter): + values = cast(Iterable[Any], getter(name)) + return [str(value).strip() for value in values if value] + raw_value = form.get(name) + if isinstance(raw_value, str): + return [item.strip() for item in raw_value.split(",") if item.strip()] + return [] + + name = (form.get("config_name") or "").strip() + language = str(form.get("config_language") or "a").strip() or "a" + allowed_languages = [] + default_voice = (form.get("config_default_voice") or "").strip() + notes = (form.get("config_notes") or "").strip() + + try: + parsed = int(form.get("config_version") or 1) + version = max(1, min(parsed, 9999)) + except (TypeError, ValueError): + version = 1 + + speaker_rows = _get_list("speaker_rows") + speakers: Dict[str, Dict[str, Any]] = {} + for row_key in speaker_rows: + prefix = f"speaker-{row_key}-" + label = (form.get(prefix + "label") or "").strip() + if not label: + continue + raw_gender = (form.get(prefix + "gender") or "unknown").strip().lower() + gender = raw_gender if raw_gender in {"male", "female", "unknown"} else "unknown" + voice = (form.get(prefix + "voice") or "").strip() + voice_profile = (form.get(prefix + "profile") or "").strip() + voice_formula = (form.get(prefix + "formula") or "").strip() + speaker_id = (form.get(prefix + "id") or "").strip() or slugify_label(label) + speakers[speaker_id] = { + "id": speaker_id, + "label": label, + "gender": gender, + "voice": voice, + "voice_profile": voice_profile, + "voice_formula": voice_formula, + "resolved_voice": voice_formula or voice, + "languages": [], + } + + payload = { + "language": language, + "languages": allowed_languages, + "default_voice": default_voice, + "speakers": speakers, + "notes": notes, + "version": version, + } + + errors: List[str] = [] + if not name: + errors.append("Configuration name is required.") + if not speakers: + errors.append("Add at least one speaker to the configuration.") + + return name, payload, errors + + +def prepare_speaker_metadata( + *, + chapters: List[Dict[str, Any]], + chunks: List[Dict[str, Any]], + analysis_chunks: Optional[List[Dict[str, Any]]] = None, + voice: str, + voice_profile: Optional[str], + threshold: int, + existing_roster: Optional[Mapping[str, Any]] = None, + run_analysis: bool = True, + speaker_config: Optional[Mapping[str, Any]] = None, + apply_config: bool = False, + persist_config: bool = False, +) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any], List[str], Optional[Dict[str, Any]]]: + chunk_list = [dict(chunk) for chunk in chunks] + analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)] + threshold_value = max(1, int(threshold)) + analysis_enabled = run_analysis + settings_state = load_settings() + global_random_languages = [ + code + for code in settings_state.get("speaker_random_languages", []) + if isinstance(code, str) and code + ] + + if not analysis_enabled: + for chunk in chunk_list: + chunk["speaker_id"] = "narrator" + chunk["speaker_label"] = "Narrator" + analysis_payload = { + "version": "1.0", + "narrator": "narrator", + "assignments": {str(chunk.get("id")): "narrator" for chunk in chunk_list}, + "speakers": { + "narrator": { + "id": "narrator", + "label": "Narrator", + "count": len(chunk_list), + "confidence": "low", + "sample_quotes": [], + "suppressed": False, + } + }, + "suppressed": [], + "stats": { + "total_chunks": len(chunk_list), + "explicit_chunks": 0, + "active_speakers": 0, + "unique_speakers": 1, + "suppressed": 0, + }, + } + roster = build_narrator_roster(voice, voice_profile, existing_roster) + narrator_pron = roster["narrator"].get("pronunciation") + if narrator_pron: + analysis_payload["speakers"]["narrator"]["pronunciation"] = narrator_pron + return chunk_list, roster, analysis_payload, [], None + + analysis_result = analyze_speakers( + chapters, + analysis_source, + threshold=threshold_value, + max_speakers=0, + ) + analysis_payload = analysis_result.to_dict() + speakers_payload = analysis_payload.get("speakers", {}) + ordered_ids = [ + sid + for sid, meta in sorted( + ( + (sid, meta) + for sid, meta in speakers_payload.items() + if sid != "narrator" and isinstance(meta, Mapping) and not meta.get("suppressed") + ), + key=lambda item: item[1].get("count", 0), + reverse=True, + ) + ] + analysis_payload["ordered_speakers"] = ordered_ids + assignments = analysis_payload.get("assignments", {}) + suppressed_ids = analysis_payload.get("suppressed", []) + suppressed_details: List[Dict[str, Any]] = [] + speakers_payload = analysis_payload.get("speakers", {}) + if isinstance(suppressed_ids, Iterable): + for suppressed_id in suppressed_ids: + speaker_meta = speakers_payload.get(suppressed_id) if isinstance(speakers_payload, dict) else None + if isinstance(speaker_meta, dict): + suppressed_details.append( + { + "id": suppressed_id, + "label": speaker_meta.get("label") + or str(suppressed_id).replace("_", " ").title(), + "pronunciation": speaker_meta.get("pronunciation"), + } + ) + else: + suppressed_details.append( + { + "id": suppressed_id, + "label": str(suppressed_id).replace("_", " ").title(), + "pronunciation": None, + } + ) + analysis_payload["suppressed_details"] = suppressed_details + roster = build_speaker_roster( + analysis_payload, + voice, + voice_profile, + existing=existing_roster, + order=analysis_payload.get("ordered_speakers"), + ) + applied_languages: List[str] = [] + updated_config: Optional[Dict[str, Any]] = None + if apply_config and speaker_config: + roster, applied_languages, updated_config = apply_speaker_config_to_roster( + roster, + speaker_config, + persist_changes=persist_config, + fallback_languages=global_random_languages, + ) + speakers_payload = analysis_payload.get("speakers") + if isinstance(speakers_payload, dict): + for roster_id, roster_payload in roster.items(): + speaker_meta = speakers_payload.get(roster_id) + if isinstance(speaker_meta, dict): + for key in ("voice", "voice_profile", "voice_formula", "resolved_voice"): + value = roster_payload.get(key) + if value: + speaker_meta[key] = value + effective_languages: List[str] = [] + if applied_languages: + effective_languages = applied_languages + elif isinstance(analysis_payload.get("config_languages"), list): + effective_languages = [ + code for code in analysis_payload.get("config_languages", []) if isinstance(code, str) and code + ] + elif global_random_languages: + effective_languages = list(global_random_languages) + + if effective_languages: + analysis_payload["config_languages"] = effective_languages + speakers_payload = analysis_payload.get("speakers") + if isinstance(speakers_payload, dict): + for roster_id, roster_payload in roster.items(): + if roster_id in speakers_payload and isinstance(roster_payload, dict): + pronunciation_value = roster_payload.get("pronunciation") + if pronunciation_value: + speakers_payload[roster_id]["pronunciation"] = pronunciation_value + + fallback_languages = effective_languages or [] + inject_recommended_voices(roster, fallback_languages=fallback_languages) + + for chunk in chunk_list: + chunk_id = str(chunk.get("id")) + speaker_id = assignments.get(chunk_id, "narrator") + chunk["speaker_id"] = speaker_id + speaker_meta = roster.get(speaker_id) + chunk["speaker_label"] = speaker_meta.get("label") if isinstance(speaker_meta, dict) else speaker_id + + return chunk_list, roster, analysis_payload, applied_languages, updated_config + + +def formula_from_profile(entry: Dict[str, Any]) -> Optional[str]: + voices = entry.get("voices") or [] + if not voices: + return None + total = sum(weight for _, weight in voices) + if total <= 0: + return None + + def _format_weight(value: float) -> str: + normalized = value / total if total else 0.0 + return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0" + + parts = [f"{name}*{_format_weight(weight)}" for name, weight in voices if weight > 0] + return "+".join(parts) if parts else None + + +def template_options() -> Dict[str, Any]: + current_settings = load_settings() + profiles = serialize_profiles() + ordered_profiles = sorted(profiles.items()) + profile_options = [] + for name, entry in ordered_profiles: + provider = str((entry or {}).get("provider") or "kokoro").strip().lower() + profile_options.append( + { + "name": name, + "language": (entry or {}).get("language", ""), + "provider": provider, + "formula": formula_from_profile(entry or {}) or "", + "voice": (entry or {}).get("voice", ""), + "total_steps": (entry or {}).get("total_steps"), + "speed": (entry or {}).get("speed"), + } + ) + voice_catalog = build_voice_catalog() + return { + "languages": LANGUAGE_DESCRIPTIONS, + "voices": VOICES_INTERNAL, + "subtitle_formats": SUBTITLE_FORMATS, + "supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, + "output_formats": SUPPORTED_SOUND_FORMATS, + "voice_profiles": ordered_profiles, + "voice_profile_options": profile_options, + "separate_formats": ["wav", "flac", "mp3", "opus"], + "voice_catalog": voice_catalog, + "voice_catalog_map": {entry["id"]: entry for entry in voice_catalog}, + "sample_voice_texts": SAMPLE_VOICE_TEXTS, + "voice_profiles_data": profiles, + "speaker_configs": list_configs(), + "chunk_levels": _CHUNK_LEVEL_OPTIONS, + "speaker_analysis_threshold": current_settings.get( + "speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD + ), + "speaker_pronunciation_sentence": current_settings.get( + "speaker_pronunciation_sentence", settings_defaults()["speaker_pronunciation_sentence"] + ), + "apostrophe_modes": _APOSTROPHE_MODE_OPTIONS, + "normalization_groups": _NORMALIZATION_GROUPS, + } + + +def resolve_profile_voice( + profile_name: Optional[str], + *, + profiles: Optional[Mapping[str, Any]] = None, +) -> tuple[str, Optional[str]]: + if not profile_name: + return "", None + source = profiles if isinstance(profiles, Mapping) else None + if source is None: + source = load_profiles() + entry = source.get(profile_name) if isinstance(source, Mapping) else None + if not isinstance(entry, Mapping): + return "", None + formula = formula_from_profile(dict(entry)) or "" + language = entry.get("language") if isinstance(entry.get("language"), str) else None + if isinstance(language, str): + language = language.strip().lower() or None + return formula, language + + +def resolve_voice_setting( + value: Any, + *, + profiles: Optional[Mapping[str, Any]] = None, +) -> tuple[str, Optional[str], Optional[str]]: + base_spec, profile_name = split_profile_spec(value) + if profile_name: + formula, language = resolve_profile_voice(profile_name, profiles=profiles) + return formula or "", profile_name, language + return base_spec, None, None + + +def resolve_voice_choice( + language: str, + base_voice: str, + profile_name: str, + custom_formula: str, + profiles: Dict[str, Any], +) -> tuple[str, str, Optional[str]]: + resolved_voice = base_voice + resolved_language = language + selected_profile = None + + if profile_name: + from abogen.voice_profiles import normalize_profile_entry + + entry_raw = profiles.get(profile_name) + entry = normalize_profile_entry(entry_raw) + provider = str((entry or {}).get("provider") or "").strip().lower() + + # Provider-aware behavior: + # - Kokoro profiles typically represent mixes (formula strings). + # - SuperTonic profiles represent a discrete voice id + settings. + # In that case, we return a speaker reference so downstream can + # resolve provider per-speaker and allow mixed-provider casting. + if provider == "supertonic": + resolved_voice = f"speaker:{profile_name}" + selected_profile = profile_name + profile_language = (entry or {}).get("language") + if profile_language: + resolved_language = str(profile_language) + else: + formula = formula_from_profile(entry or {}) if entry else None + if formula: + resolved_voice = formula + selected_profile = profile_name + profile_language = (entry or {}).get("language") + if profile_language: + resolved_language = profile_language + + if custom_formula: + resolved_voice = custom_formula + selected_profile = None + + return resolved_voice, resolved_language, selected_profile + + +def parse_voice_formula(formula: str) -> List[tuple[str, float]]: + voices = parse_formula_terms(formula) + total = sum(weight for _, weight in voices) + if total <= 0: + raise ValueError("Voice weights must sum to a positive value") + return voices + + +def sanitize_voice_entries(entries: Iterable[Any]) -> List[Dict[str, Any]]: + sanitized: List[Dict[str, Any]] = [] + for entry in entries or []: + if isinstance(entry, dict): + voice_id = entry.get("id") or entry.get("voice") + if not voice_id: + continue + enabled = entry.get("enabled", True) + if not enabled: + continue + sanitized.append({"voice": voice_id, "weight": entry.get("weight")}) + elif isinstance(entry, (list, tuple)) and len(entry) >= 2: + sanitized.append({"voice": entry[0], "weight": entry[1]}) + return sanitized + + +def pairs_to_formula(pairs: Iterable[Tuple[str, float]]) -> Optional[str]: + voices = [(voice, float(weight)) for voice, weight in pairs if float(weight) > 0] + if not voices: + return None + total = sum(weight for _, weight in voices) + if total <= 0: + return None + + def _format_value(value: float) -> str: + normalized = value / total if total else 0.0 + return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0" + + parts = [f"{voice}*{_format_value(weight)}" for voice, weight in voices] + return "+".join(parts) + + +def profiles_payload() -> Dict[str, Any]: + return {"profiles": serialize_profiles()} + + +def get_preview_pipeline(language: str, device: str): + key = (language, device) + with _preview_pipeline_lock: + pipeline = _preview_pipelines.get(key) + if pipeline is not None: + return pipeline + _, KPipeline = load_numpy_kpipeline() + pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device) + _preview_pipelines[key] = pipeline + return pipeline + + +def synthesize_audio_from_normalized( + *, + normalized_text: str, + voice_spec: str, + language: str, + speed: float, + use_gpu: bool, + max_seconds: float, +) -> np.ndarray: + if not normalized_text.strip(): + raise ValueError("Preview text is required") + + device = "cpu" + if use_gpu: + try: + device = _select_device() + except Exception: + device = "cpu" + use_gpu = False + + pipeline = get_preview_pipeline(language, device) + if pipeline is None: + raise RuntimeError("Preview pipeline is unavailable") + + voice_choice: Any = voice_spec + if voice_spec and "*" in voice_spec: + voice_choice = get_new_voice(pipeline, voice_spec, use_gpu) + + segments = pipeline( + normalized_text, + voice=voice_choice, + speed=speed, + split_pattern=SPLIT_PATTERN, + ) + + audio_chunks: List[np.ndarray] = [] + accumulated = 0 + max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE) + + for segment in segments: + graphemes = getattr(segment, "graphemes", "").strip() + if not graphemes: + continue + audio = _to_float32(getattr(segment, "audio", None)) + if audio.size == 0: + continue + remaining = max_samples - accumulated + if remaining <= 0: + break + if audio.shape[0] > remaining: + audio = audio[:remaining] + audio_chunks.append(audio) + accumulated += audio.shape[0] + if accumulated >= max_samples: + break + + if not audio_chunks: + raise RuntimeError("Preview could not be generated") + + return np.concatenate(audio_chunks) diff --git a/abogen/webui/routes/voices.py b/abogen/webui/routes/voices.py new file mode 100644 index 0000000..a7e9ce7 --- /dev/null +++ b/abogen/webui/routes/voices.py @@ -0,0 +1,140 @@ +from typing import Any, Dict, List, Optional +from flask import Blueprint, render_template, request, jsonify, abort, flash, redirect, url_for +from flask.typing import ResponseReturnValue + +from abogen.webui.routes.utils.voice import ( + template_options, + resolve_voice_setting, + resolve_voice_choice, + parse_voice_formula, +) +from abogen.webui.routes.utils.settings import load_settings, coerce_bool +from abogen.webui.routes.utils.preview import synthesize_preview +from abogen.speaker_configs import ( + list_configs, + get_config, + load_configs, + save_configs, + delete_config, +) +from abogen.constants import VOICES_INTERNAL + +voices_bp = Blueprint("voices", __name__) + +@voices_bp.get("/") +def voice_profiles() -> ResponseReturnValue: + return render_template("voices.html", options=template_options()) + +@voices_bp.post("/test") +def test_voice() -> ResponseReturnValue: + text = (request.form.get("text") or "").strip() + voice = (request.form.get("voice") or "").strip() + speed = float(request.form.get("speed", 1.0)) + + # This seems to be the form-based preview + settings = load_settings() + use_gpu = coerce_bool(settings.get("use_gpu"), True) + + try: + return synthesize_preview( + text=text, + voice_spec=voice, + language="a", # Default language + speed=speed, + use_gpu=use_gpu, + ) + except Exception as e: + abort(400, str(e)) + +@voices_bp.get("/configs") +def speaker_configs() -> ResponseReturnValue: + return jsonify({"configs": list_configs()}) + +@voices_bp.post("/configs/save") +def save_speaker_config() -> ResponseReturnValue: + payload = request.get_json(force=True) + name = (payload.get("name") or "").strip() + config = payload.get("config") + + if not name: + abort(400, "Config name is required") + if not config: + abort(400, "Config data is required") + + configs = load_configs() + configs[name] = config + save_configs(configs) + return jsonify({"status": "saved", "configs": list_configs()}) + +@voices_bp.post("/configs/delete") +def delete_speaker_config() -> ResponseReturnValue: + payload = request.get_json(force=True) + name = (payload.get("name") or "").strip() + + if not name: + abort(400, "Config name is required") + + delete_config(name) + return jsonify({"status": "deleted", "configs": list_configs()}) + +@voices_bp.route("/presets", methods=["GET", "POST"]) +def speaker_configs_page() -> ResponseReturnValue: + configs = load_configs() + editing_name = request.args.get("config") + message = None + error = None + + if request.method == "POST": + try: + name = request.form.get("config_name", "").strip() + if not name: + raise ValueError("Preset name is required") + + language = request.form.get("config_language", "en") + + speakers = [] + row_keys = request.form.getlist("speaker_rows") + for key in row_keys: + s_id = request.form.get(f"speaker-{key}-id", key) + label = request.form.get(f"speaker-{key}-label", "") + gender = request.form.get(f"speaker-{key}-gender", "unknown") + voice = request.form.get(f"speaker-{key}-voice", "") + + if label: + speakers.append({ + "id": s_id, + "label": label, + "gender": gender, + "voice": voice or None + }) + + config = { + "name": name, + "language": language, + "speakers": speakers, + "version": 1 + } + + configs[name] = config + save_configs(configs) + message = f"Preset '{name}' saved." + editing_name = name + except Exception as e: + error = str(e) + + editing = configs.get(editing_name, {}) if editing_name else {} + + return render_template( + "speakers.html", + options=template_options(), + configs=configs.values(), + editing_name=editing_name, + editing=editing, + message=message, + error=error + ) + +@voices_bp.post("/presets//delete") +def delete_speaker_config_named(name: str) -> ResponseReturnValue: + delete_config(name) + return redirect(url_for("voices.speaker_configs_page")) diff --git a/abogen/webui/service.py b/abogen/webui/service.py new file mode 100644 index 0000000..a04e9ce --- /dev/null +++ b/abogen/webui/service.py @@ -0,0 +1,1618 @@ +from __future__ import annotations + +import json +import logging +import math +import os +import re +import shutil +import sys +import threading +import time +import uuid +import traceback +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping, Tuple + +from abogen.utils import get_internal_cache_path, get_user_settings_dir, load_config +from abogen.voice_cache import bootstrap_voice_cache +from abogen.integrations.audiobookshelf import ( + AudiobookshelfClient, + AudiobookshelfConfig, + AudiobookshelfUploadError, +) + + +def _create_set_event() -> threading.Event: + event = threading.Event() + event.set() + return event + + +STATE_VERSION = 8 + + +_JOB_LOGGER = logging.getLogger("abogen.jobs") +if not _JOB_LOGGER.handlers: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S")) + _JOB_LOGGER.addHandler(handler) + _JOB_LOGGER.propagate = False +_JOB_LOGGER.setLevel(logging.DEBUG) + +_JOB_LEVEL_MAP: Dict[str, int] = { + "critical": logging.CRITICAL, + "error": logging.ERROR, + "warning": logging.WARNING, + "info": logging.INFO, + "success": logging.INFO, + "debug": logging.DEBUG, + "trace": logging.DEBUG, +} + + +_PEOPLE_SPLIT_RE = re.compile(r"[;,/&]|\band\b", re.IGNORECASE) + + +def _emit_job_log(job_id: str, level: str, message: str) -> None: + normalized = (level or "info").lower() + log_level = _JOB_LEVEL_MAP.get(normalized, logging.INFO) + try: + _JOB_LOGGER.log(log_level, "[job %s] %s", job_id, message) + except Exception: + # Logging failures should never disrupt job processing, but we should know about them. + try: + sys.stderr.write(f"Logging failed for job {job_id}: {message}\n") + traceback.print_exc(file=sys.stderr) + except Exception: + pass + + +class JobStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + PAUSED = "paused" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +@dataclass +class JobLog: + timestamp: float + message: str + level: str = "info" + + +@dataclass +class JobResult: + audio_path: Optional[Path] = None + subtitle_paths: List[Path] = field(default_factory=list) + artifacts: Dict[str, Path] = field(default_factory=dict) + epub_path: Optional[Path] = None + + +@dataclass +class Job: + id: str + original_filename: str + stored_path: Path + language: str + voice: str + speed: float + use_gpu: bool + subtitle_mode: str + output_format: str + save_mode: str + output_folder: Optional[Path] + replace_single_newlines: bool + subtitle_format: str + created_at: float + tts_provider: str = "kokoro" + supertonic_total_steps: int = 5 + save_chapters_separately: bool = False + merge_chapters_at_end: bool = True + separate_chapters_format: str = "wav" + silence_between_chapters: float = 2.0 + save_as_project: bool = False + voice_profile: Optional[str] = None + metadata_tags: Dict[str, str] = field(default_factory=dict) + max_subtitle_words: int = 50 + chapter_intro_delay: float = 0.5 + read_title_intro: bool = False + read_closing_outro: bool = True + auto_prefix_chapter_titles: bool = True + normalize_chapter_opening_caps: bool = True + status: JobStatus = JobStatus.PENDING + started_at: Optional[float] = None + finished_at: Optional[float] = None + progress: float = 0.0 + total_characters: int = 0 + processed_characters: int = 0 + logs: List[JobLog] = field(default_factory=list) + error: Optional[str] = None + result: JobResult = field(default_factory=JobResult) + chapters: List[Dict[str, Any]] = field(default_factory=list) + queue_position: Optional[int] = None + cancel_requested: bool = False + pause_requested: bool = False + paused: bool = False + resume_token: Optional[str] = None + pause_event: threading.Event = field(default_factory=_create_set_event, repr=False, compare=False) + cover_image_path: Optional[Path] = None + cover_image_mime: Optional[str] = None + chunk_level: str = "paragraph" + chunks: List[Dict[str, Any]] = field(default_factory=list) + speakers: Dict[str, Any] = field(default_factory=dict) + speaker_mode: str = "single" + generate_epub3: bool = False + speaker_analysis: Dict[str, Any] = field(default_factory=dict) + speaker_analysis_threshold: int = 3 + analysis_requested: bool = False + entity_summary: Dict[str, Any] = field(default_factory=dict) + manual_overrides: List[Dict[str, Any]] = field(default_factory=list) + pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list) + heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list) + normalization_overrides: Dict[str, Any] = field(default_factory=dict) + speaker_voice_languages: List[str] = field(default_factory=list) + applied_speaker_config: Optional[str] = None + + @property + def estimated_time_remaining(self) -> Optional[float]: + """ + Returns the estimated seconds remaining based on current progress and elapsed time. + Returns None if the job hasn't started, is finished, or progress is 0. + """ + if self.status != JobStatus.RUNNING or not self.started_at or self.progress <= 0: + return None + + elapsed = time.time() - self.started_at + if elapsed <= 0: + return None + + # Estimate total time based on current progress + total_estimated = elapsed / self.progress + remaining = total_estimated - elapsed + return max(0.0, remaining) + + def add_log(self, message: str, level: str = "info") -> None: + entry = JobLog(timestamp=time.time(), message=message, level=level) + self.logs.append(entry) + _emit_job_log(self.id, level, message) + + def as_dict(self) -> Dict[str, object]: + return { + "id": self.id, + "original_filename": self.original_filename, + "status": self.status.value, + "use_gpu": self.use_gpu, + "created_at": self.created_at, + "started_at": self.started_at, + "finished_at": self.finished_at, + "progress": self.progress, + "total_characters": self.total_characters, + "processed_characters": self.processed_characters, + "error": self.error, + "logs": [log.__dict__ for log in self.logs], + "result": { + "audio": str(self.result.audio_path) if self.result.audio_path else None, + "subtitles": [str(path) for path in self.result.subtitle_paths], + "artifacts": {key: str(path) for key, path in self.result.artifacts.items()}, + }, + "queue_position": self.queue_position, + "options": { + "tts_provider": getattr(self, "tts_provider", "kokoro"), + "supertonic_total_steps": getattr(self, "supertonic_total_steps", 5), + "save_chapters_separately": self.save_chapters_separately, + "merge_chapters_at_end": self.merge_chapters_at_end, + "separate_chapters_format": self.separate_chapters_format, + "silence_between_chapters": self.silence_between_chapters, + "save_as_project": self.save_as_project, + "voice_profile": self.voice_profile, + "max_subtitle_words": self.max_subtitle_words, + "chapter_intro_delay": self.chapter_intro_delay, + "read_title_intro": getattr(self, "read_title_intro", False), + "read_closing_outro": getattr(self, "read_closing_outro", True), + "auto_prefix_chapter_titles": getattr(self, "auto_prefix_chapter_titles", True), + "normalize_chapter_opening_caps": getattr(self, "normalize_chapter_opening_caps", True), + }, + "metadata_tags": dict(self.metadata_tags), + "chapters": [ + { + "id": entry.get("id"), + "index": entry.get("index"), + "order": entry.get("order"), + "title": entry.get("title"), + "enabled": bool(entry.get("enabled", True)), + "voice": entry.get("voice"), + "voice_profile": entry.get("voice_profile"), + "voice_formula": entry.get("voice_formula"), + "resolved_voice": entry.get("resolved_voice"), + "characters": len(str(entry.get("text", ""))), + } + for entry in self.chapters + ], + "chunk_level": self.chunk_level, + "chunks": [dict(chunk) for chunk in self.chunks], + "speakers": dict(self.speakers), + "speaker_mode": self.speaker_mode, + "generate_epub3": self.generate_epub3, + "speaker_analysis": dict(self.speaker_analysis), + "speaker_analysis_threshold": self.speaker_analysis_threshold, + "analysis_requested": self.analysis_requested, + "speaker_voice_languages": list(self.speaker_voice_languages), + "applied_speaker_config": self.applied_speaker_config, + "entity_summary": dict(self.entity_summary), + "manual_overrides": [dict(entry) for entry in self.manual_overrides], + "pronunciation_overrides": [dict(entry) for entry in self.pronunciation_overrides], + "heteronym_overrides": [dict(entry) for entry in self.heteronym_overrides], + "normalization_overrides": dict(self.normalization_overrides), + } + + +def _normalize_metadata_casefold(values: Optional[Mapping[str, Any]]) -> Dict[str, Any]: + normalized: Dict[str, Any] = {} + if not values: + return normalized + for key, value in values.items(): + if value is None: + continue + key_text = str(key).strip().lower() + if not key_text: + continue + if isinstance(value, (list, tuple, set)): + normalized[key_text] = value + else: + text = str(value).strip() + if text: + normalized[key_text] = text + return normalized + + +def _split_people_field(raw: Any) -> List[str]: + if raw is None: + return [] + if isinstance(raw, (list, tuple, set)): + results: List[str] = [] + for item in raw: + results.extend(_split_people_field(item)) + return results + text = str(raw or "").strip() + if not text: + return [] + tokens = [_token.strip() for _token in _PEOPLE_SPLIT_RE.split(text) if _token.strip()] + seen: set[str] = set() + ordered: List[str] = [] + for token in tokens: + key = token.casefold() + if key in seen: + continue + seen.add(key) + ordered.append(token) + return ordered + + +_LIST_SPLIT_RE = re.compile(r"[;,\n]") +_SERIES_SEQUENCE_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?") + +_SERIES_SEQUENCE_TAG_KEYS: Tuple[str, ...] = ( + "series_index", + "series_position", + "series_sequence", + "series_number", + "seriesnumber", + "book_number", + "booknumber", +) + + +def _split_simple_list(raw: Any) -> List[str]: + if raw is None: + return [] + if isinstance(raw, (list, tuple, set)): + results: List[str] = [] + for item in raw: + results.extend(_split_simple_list(item)) + return results + text = str(raw or "").strip() + if not text: + return [] + tokens = [_token.strip() for _token in _LIST_SPLIT_RE.split(text) if _token.strip()] + seen: set[str] = set() + ordered: List[str] = [] + for token in tokens: + key = token.casefold() + if key in seen: + continue + seen.add(key) + ordered.append(token) + return ordered + + +def _first_nonempty(*values: Any) -> Optional[str]: + for value in values: + if value is None: + continue + if isinstance(value, (list, tuple, set)): + items = list(value) + if not items: + continue + value = items[0] + text = str(value).strip() + if text: + return text + return None + + +def _extract_year(raw: Optional[str]) -> Optional[int]: + if not raw: + return None + text = str(raw).strip() + if not text: + return None + match = re.search(r"(19|20)\d{2}", text) + if match: + try: + return int(match.group(0)) + except ValueError: + return None + try: + parsed = int(text) + except ValueError: + return None + if 0 < parsed < 3000: + return parsed + return None + + +def build_audiobookshelf_metadata(job: Job) -> Dict[str, Any]: + tags = _normalize_metadata_casefold(job.metadata_tags) + filename = Path(job.original_filename or "").stem or job.original_filename or "Audiobook" + title = _first_nonempty( + tags.get("title"), + tags.get("book_title"), + tags.get("name"), + tags.get("album"), + filename, + ) + authors = _split_people_field( + tags.get("authors") + or tags.get("author") + or tags.get("album_artist") + or tags.get("artist") + ) + narrators = _split_people_field(tags.get("narrators") or tags.get("narrator")) + description = _first_nonempty(tags.get("description"), tags.get("summary"), tags.get("comment")) + genres = _split_simple_list(tags.get("genre")) + keywords = _split_simple_list(tags.get("tags") or tags.get("keywords")) + language = _first_nonempty(tags.get("language"), tags.get("lang")) or job.language or "" + series_name = _first_nonempty( + tags.get("series"), + tags.get("series_name"), + tags.get("seriesname"), + tags.get("series_title"), + tags.get("seriestitle"), + ) + + series_sequence = None + for key in _SERIES_SEQUENCE_TAG_KEYS: + raw_value = tags.get(key) + normalized_sequence = _normalize_series_sequence(raw_value) + if normalized_sequence: + series_sequence = normalized_sequence + break + if not series_name: + series_sequence = None + data: Dict[str, Any] = { + "title": title, + "subtitle": tags.get("subtitle"), + "authors": authors, + "narrators": narrators, + "description": description, + "publisher": tags.get("publisher"), + "genres": genres, + "tags": keywords, + "language": language, + "publishedYear": _extract_year(tags.get("published") or tags.get("publication_year") or tags.get("date") or tags.get("year")), + "seriesName": series_name, + "seriesSequence": series_sequence, + "isbn": _first_nonempty(tags.get("isbn"), tags.get("asin")), + } + published_date = _first_nonempty(tags.get("published"), tags.get("publication_date"), tags.get("date")) + if published_date: + data["publishedDate"] = published_date + + rating_text = _first_nonempty(tags.get("rating"), tags.get("my_rating")) + if rating_text: + try: + data["rating"] = float(str(rating_text).strip()) + except ValueError: + pass + rating_max_text = _first_nonempty(tags.get("rating_max"), tags.get("rating_scale")) + if rating_max_text: + try: + data["ratingMax"] = float(str(rating_max_text).strip()) + except ValueError: + pass + # Remove empty values + cleaned: Dict[str, Any] = {} + for key, value in data.items(): + if value is None: + continue + if isinstance(value, str) and not value.strip(): + continue + if isinstance(value, (list, tuple)) and not value: + continue + cleaned[key] = value + return cleaned + + +def _normalize_series_sequence(raw: Any) -> Optional[str]: + if raw is None: + return None + + if isinstance(raw, (int, float)): + if isinstance(raw, float) and (math.isnan(raw) or math.isinf(raw)): + return None + text = str(raw) + else: + text = str(raw).strip() + + if not text: + return None + + candidate = text.replace(",", ".") + match = _SERIES_SEQUENCE_NUMBER_RE.search(candidate) + if not match: + return None + + normalized = match.group(0) + if "." in normalized: + normalized = normalized.rstrip("0").rstrip(".") + if not normalized: + normalized = "0" + return normalized + + try: + return str(int(normalized)) + except ValueError: + cleaned = normalized.lstrip("0") + return cleaned or "0" + + +def load_audiobookshelf_chapters(job: Job) -> Optional[List[Dict[str, Any]]]: + metadata_ref = job.result.artifacts.get("metadata") + if not metadata_ref: + return None + metadata_path = metadata_ref if isinstance(metadata_ref, Path) else Path(str(metadata_ref)) + if not metadata_path.exists(): + return None + try: + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + chapters = payload.get("chapters") + if not isinstance(chapters, list): + return None + cleaned: List[Dict[str, Any]] = [] + for entry in chapters: + if not isinstance(entry, Mapping): + continue + title = _first_nonempty(entry.get("title"), entry.get("original_title")) + start = entry.get("start") + end = entry.get("end") + if title is None or not isinstance(start, (int, float)): + continue + chapter_payload: Dict[str, Any] = { + "title": title, + "start": float(start), + } + if isinstance(end, (int, float)): + chapter_payload["end"] = float(end) + cleaned.append(chapter_payload) + return cleaned or None + + +def _existing_paths(paths: Iterable[Any]) -> List[Path]: + resolved: List[Path] = [] + for item in paths: + candidate = item if isinstance(item, Path) else Path(str(item)) + if candidate.exists(): + resolved.append(candidate) + return resolved + + +@dataclass +class PendingJob: + id: str + original_filename: str + stored_path: Path + language: str + voice: str + speed: float + use_gpu: bool + subtitle_mode: str + output_format: str + save_mode: str + output_folder: Optional[Path] + replace_single_newlines: bool + subtitle_format: str + total_characters: int + save_chapters_separately: bool + merge_chapters_at_end: bool + separate_chapters_format: str + silence_between_chapters: float + save_as_project: bool + voice_profile: Optional[str] + max_subtitle_words: int + metadata_tags: Dict[str, Any] + chapters: List[Dict[str, Any]] + normalization_overrides: Dict[str, Any] + created_at: float + tts_provider: str = "kokoro" + supertonic_total_steps: int = 5 + cover_image_path: Optional[Path] = None + cover_image_mime: Optional[str] = None + chapter_intro_delay: float = 0.5 + read_title_intro: bool = False + read_closing_outro: bool = True + auto_prefix_chapter_titles: bool = True + normalize_chapter_opening_caps: bool = True + chunk_level: str = "paragraph" + chunks: List[Dict[str, Any]] = field(default_factory=list) + speakers: Dict[str, Any] = field(default_factory=dict) + speaker_mode: str = "single" + generate_epub3: bool = False + speaker_analysis: Dict[str, Any] = field(default_factory=dict) + speaker_analysis_threshold: int = 3 + analysis_requested: bool = False + speaker_voice_languages: List[str] = field(default_factory=list) + applied_speaker_config: Optional[str] = None + entity_summary: Dict[str, Any] = field(default_factory=dict) + manual_overrides: List[Dict[str, Any]] = field(default_factory=list) + pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list) + heteronym_overrides: List[Dict[str, Any]] = field(default_factory=list) + entity_cache_key: Optional[str] = None + wizard_max_step_index: int = 0 + + +class ConversionService: + def __init__( + self, + output_root: Path, + runner: Callable[[Job], None], + *, + uploads_root: Optional[Path] = None, + poll_interval: float = 0.5, + ) -> None: + self._jobs: Dict[str, Job] = {} + self._queue: List[str] = [] + self._lock = threading.RLock() + self._worker_thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._wake_event = threading.Event() + self._output_root = output_root + self._uploads_root = uploads_root or output_root / "uploads" + self._runner = runner + self._poll_interval = poll_interval + self._pending_jobs: Dict[str, PendingJob] = {} + self._state_path = self._determine_state_path() + self._ensure_directories() + self._bootstrap_voice_cache() + self._load_state() + + # Public API --------------------------------------------------------- + def list_jobs(self) -> List[Job]: + with self._lock: + return sorted(self._jobs.values(), key=lambda job: job.created_at, reverse=True) + + def get_job(self, job_id: str) -> Optional[Job]: + with self._lock: + return self._jobs.get(job_id) + + def enqueue( + self, + *, + original_filename: str, + stored_path: Path, + language: str, + voice: str, + speed: float, + tts_provider: str = "kokoro", + supertonic_total_steps: int = 5, + use_gpu: bool, + subtitle_mode: str, + output_format: str, + save_mode: str, + output_folder: Optional[Path], + replace_single_newlines: bool, + subtitle_format: str, + total_characters: int, + chapters: Optional[Iterable[Any]] = None, + save_chapters_separately: bool = False, + merge_chapters_at_end: bool = True, + separate_chapters_format: str = "wav", + silence_between_chapters: float = 2.0, + save_as_project: bool = False, + voice_profile: Optional[str] = None, + max_subtitle_words: int = 50, + metadata_tags: Optional[Mapping[str, Any]] = None, + cover_image_path: Optional[Path] = None, + cover_image_mime: Optional[str] = None, + chapter_intro_delay: float = 0.5, + read_title_intro: bool = False, + read_closing_outro: bool = True, + auto_prefix_chapter_titles: bool = True, + normalize_chapter_opening_caps: bool = True, + chunk_level: str = "paragraph", + chunks: Optional[Iterable[Any]] = None, + speakers: Optional[Mapping[str, Any]] = None, + speaker_mode: str = "single", + generate_epub3: bool = False, + speaker_analysis: Optional[Mapping[str, Any]] = None, + speaker_analysis_threshold: int = 3, + analysis_requested: bool = False, + entity_summary: Optional[Mapping[str, Any]] = None, + manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None, + pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None, + heteronym_overrides: Optional[Iterable[Mapping[str, Any]]] = None, + normalization_overrides: Optional[Mapping[str, Any]] = None, + ) -> Job: + job_id = uuid.uuid4().hex + normalized_metadata = self._normalize_metadata_tags(metadata_tags) + normalized_chapters = self._normalize_chapters(chapters) + normalized_chunks = self._normalize_chunks(chunks) + if total_characters <= 0 and normalized_chapters: + total_characters = sum(len(str(entry.get("text", ""))) for entry in normalized_chapters) + job = Job( + id=job_id, + original_filename=original_filename, + stored_path=stored_path, + language=language, + voice=voice, + speed=speed, + tts_provider=tts_provider, + supertonic_total_steps=int(supertonic_total_steps or 5), + use_gpu=use_gpu, + subtitle_mode=subtitle_mode, + output_format=output_format, + save_mode=save_mode, + output_folder=output_folder, + replace_single_newlines=replace_single_newlines, + subtitle_format=subtitle_format, + save_chapters_separately=save_chapters_separately, + merge_chapters_at_end=merge_chapters_at_end, + separate_chapters_format=separate_chapters_format, + silence_between_chapters=silence_between_chapters, + save_as_project=save_as_project, + voice_profile=voice_profile, + max_subtitle_words=max_subtitle_words, + metadata_tags=normalized_metadata, + created_at=time.time(), + total_characters=total_characters, + chapters=normalized_chapters, + cover_image_path=cover_image_path, + cover_image_mime=cover_image_mime, + chapter_intro_delay=chapter_intro_delay, + read_title_intro=bool(read_title_intro), + read_closing_outro=bool(read_closing_outro), + auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles), + normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps), + chunk_level=chunk_level, + chunks=normalized_chunks, + speakers=dict(speakers or {}), + speaker_mode=speaker_mode, + generate_epub3=bool(generate_epub3), + speaker_analysis=dict(speaker_analysis or {}), + speaker_analysis_threshold=int(speaker_analysis_threshold or 3), + analysis_requested=bool(analysis_requested), + entity_summary=dict(entity_summary or {}), + manual_overrides=[dict(entry) for entry in manual_overrides] if manual_overrides else [], + pronunciation_overrides=[dict(entry) for entry in pronunciation_overrides] if pronunciation_overrides else [], + heteronym_overrides=[dict(entry) for entry in heteronym_overrides] if heteronym_overrides else [], + normalization_overrides=dict(normalization_overrides or {}), + ) + with self._lock: + self._jobs[job_id] = job + self._queue.append(job_id) + self._update_queue_positions_locked() + self._wake_event.set() + self._ensure_worker() + job.add_log("Job queued") + return job + + def store_pending_job(self, pending: PendingJob) -> None: + with self._lock: + self._pending_jobs[pending.id] = pending + + def get_pending_job(self, pending_id: str) -> Optional[PendingJob]: + with self._lock: + return self._pending_jobs.get(pending_id) + + def pop_pending_job(self, pending_id: str) -> Optional[PendingJob]: + with self._lock: + return self._pending_jobs.pop(pending_id, None) + + def cancel(self, job_id: str) -> bool: + with self._lock: + job = self._jobs.get(job_id) + if job is None: + return False + if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}: + return False + job.cancel_requested = True + job.pause_requested = False + job.paused = False + job.add_log("Cancellation requested", level="warning") + job.pause_event.set() + if job.status == JobStatus.PENDING: + job.status = JobStatus.CANCELLED + self._queue.remove(job_id) + job.finished_at = time.time() + self._update_queue_positions_locked() + self._persist_state() + return True + + def pause(self, job_id: str) -> bool: + with self._lock: + job = self._jobs.get(job_id) + if job is None: + return False + if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}: + return False + if job.pause_requested or job.paused: + return True + + job.pause_requested = True + job.add_log("Pause requested; finishing current chunk before stopping.", level="warning") + + if job.status == JobStatus.PENDING: + if job_id in self._queue: + self._queue.remove(job_id) + self._update_queue_positions_locked() + job.status = JobStatus.PAUSED + job.paused = True + job.pause_event.clear() + self._persist_state() + return True + + def resume(self, job_id: str) -> bool: + with self._lock: + job = self._jobs.get(job_id) + if job is None: + return False + if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}: + return False + + job.pause_requested = False + + if job.status == JobStatus.PAUSED and job.started_at is None: + job.status = JobStatus.PENDING + job.paused = False + job.pause_event.set() + if job_id not in self._queue: + self._queue.insert(0, job_id) + self._update_queue_positions_locked() + self._wake_event.set() + job.add_log("Resume requested; returning job to queue.", level="info") + else: + job.paused = False + job.pause_event.set() + if job.status == JobStatus.PAUSED: + job.status = JobStatus.RUNNING + job.add_log("Resume requested", level="info") + + self._persist_state() + return True + + def retry(self, job_id: str) -> Optional[Job]: + with self._lock: + job = self._jobs.get(job_id) + if job is None: + return None + if job.status not in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}: + job.add_log( + "Retry requested while job still active; ignoring.", + level="warning", + ) + return None + + stored_path = job.stored_path + if not isinstance(stored_path, Path): + stored_path = Path(str(stored_path)) + + if not stored_path.exists(): + job.add_log( + f"Retry requested but source file is missing: {stored_path}", + level="error", + ) + return None + + new_job = self.enqueue( + original_filename=job.original_filename, + stored_path=stored_path, + language=job.language, + voice=job.voice, + speed=job.speed, + use_gpu=job.use_gpu, + subtitle_mode=job.subtitle_mode, + output_format=job.output_format, + save_mode=job.save_mode, + output_folder=job.output_folder, + replace_single_newlines=job.replace_single_newlines, + subtitle_format=job.subtitle_format, + total_characters=job.total_characters, + chapters=job.chapters, + save_chapters_separately=job.save_chapters_separately, + merge_chapters_at_end=job.merge_chapters_at_end, + separate_chapters_format=job.separate_chapters_format, + silence_between_chapters=job.silence_between_chapters, + save_as_project=job.save_as_project, + voice_profile=job.voice_profile, + max_subtitle_words=job.max_subtitle_words, + metadata_tags=job.metadata_tags, + cover_image_path=job.cover_image_path, + cover_image_mime=job.cover_image_mime, + chapter_intro_delay=job.chapter_intro_delay, + read_title_intro=job.read_title_intro, + auto_prefix_chapter_titles=job.auto_prefix_chapter_titles, + normalize_chapter_opening_caps=job.normalize_chapter_opening_caps, + chunk_level=job.chunk_level, + chunks=job.chunks, + speakers=job.speakers, + speaker_mode=job.speaker_mode, + generate_epub3=job.generate_epub3, + speaker_analysis=job.speaker_analysis, + speaker_analysis_threshold=job.speaker_analysis_threshold, + analysis_requested=job.analysis_requested, + entity_summary=job.entity_summary, + manual_overrides=job.manual_overrides, + pronunciation_overrides=job.pronunciation_overrides, + normalization_overrides=job.normalization_overrides, + ) + + new_job.speaker_voice_languages = list(job.speaker_voice_languages) + new_job.applied_speaker_config = job.applied_speaker_config + new_job.add_log(f"Retry created from job {job.id}", level="info") + job.add_log(f"Retry scheduled as job {new_job.id}", level="info") + self._remove_job_locked(job_id) + return new_job + + def delete(self, job_id: str) -> bool: + with self._lock: + job = self._jobs.get(job_id) + if job is None: + return False + if job.status in {JobStatus.RUNNING}: + return False + self._jobs.pop(job_id) + if job_id in self._queue: + self._queue.remove(job_id) + self._update_queue_positions_locked() + self._persist_state() + return True + + def clear_finished(self, *, statuses: Optional[Iterable[JobStatus]] = None) -> int: + finished_statuses = set(statuses or {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}) + removed = 0 + with self._lock: + # Remove any queued entries first to avoid stale references + filtered_queue: List[str] = [] + for job_id in self._queue: + job = self._jobs.get(job_id) + if job and job.status in finished_statuses: + continue + filtered_queue.append(job_id) + self._queue = filtered_queue + + for job_id, job in list(self._jobs.items()): + if job.status in finished_statuses: + self._jobs.pop(job_id) + removed += 1 + + if removed: + self._update_queue_positions_locked() + self._persist_state() + return removed + + def shutdown(self) -> None: + self._stop_event.set() + self._wake_event.set() + if self._worker_thread and self._worker_thread.is_alive(): + self._worker_thread.join(timeout=5) + self._worker_thread = None + + # Internal ----------------------------------------------------------- + def _ensure_directories(self) -> None: + self._output_root.mkdir(parents=True, exist_ok=True) + self._uploads_root.mkdir(parents=True, exist_ok=True) + self._state_path.parent.mkdir(parents=True, exist_ok=True) + + def _bootstrap_voice_cache(self) -> None: + try: + downloaded, errors = bootstrap_voice_cache( + on_progress=lambda msg: _JOB_LOGGER.debug("[voice cache] %s", msg) + ) + except RuntimeError as exc: + _JOB_LOGGER.warning("Voice cache bootstrap skipped: %s", exc) + return + + if downloaded: + count = len(downloaded) + suffix = "s" if count != 1 else "" + _JOB_LOGGER.info("Voice cache ready: downloaded %d new asset%s.", count, suffix) + if errors: + for voice_id, message in errors.items(): + _JOB_LOGGER.warning("Voice cache failed for %s: %s", voice_id, message) + + def _ensure_worker(self) -> None: + with self._lock: + if self._worker_thread and self._worker_thread.is_alive(): + return + self._stop_event.clear() + self._worker_thread = threading.Thread( + target=self._worker_loop, + name="abogen-conversion-worker", + daemon=True, + ) + self._worker_thread.start() + + def _worker_loop(self) -> None: + while not self._stop_event.is_set(): + job = None + with self._lock: + self._wake_event.clear() + while self._queue and self._jobs[self._queue[0]].status in { + JobStatus.CANCELLED, + JobStatus.COMPLETED, + JobStatus.FAILED, + }: + self._queue.pop(0) + if self._queue: + job = self._jobs[self._queue.pop(0)] + else: + self._update_queue_positions_locked() + if job is None: + self._wake_event.wait(timeout=self._poll_interval) + continue + if job.cancel_requested: + job.add_log("Job cancelled before start", level="warning") + job.status = JobStatus.CANCELLED + job.finished_at = time.time() + continue + self._run_job(job) + + def _run_job(self, job: Job) -> None: + job.pause_event.set() + job.pause_requested = False + job.paused = False + job.status = JobStatus.RUNNING + job.started_at = time.time() + job.add_log("Job started", level="info") + self._persist_state() + try: + self._runner(job) + except Exception as exc: # pragma: no cover - defensive + job.error = str(exc) + job.status = JobStatus.FAILED + job.finished_at = time.time() + exc_type = exc.__class__.__name__ + job.add_log(f"Job failed ({exc_type}): {exc}", level="error") + tb_lines = traceback.format_exception(exc.__class__, exc, exc.__traceback__) + for line in tb_lines[:20]: + trimmed = line.rstrip() + if trimmed: + for snippet in trimmed.splitlines(): + job.add_log(f"TRACE: {snippet}", level="debug") + else: + if job.cancel_requested: + job.status = JobStatus.CANCELLED + job.add_log("Job cancelled", level="warning") + elif job.status != JobStatus.FAILED: + job.status = JobStatus.COMPLETED + job.add_log("Job completed", level="success") + self._post_completion_hooks(job) + job.finished_at = time.time() + finally: + job.pause_event.set() + self._persist_state() + with self._lock: + self._update_queue_positions_locked() + + def _update_queue_positions_locked(self) -> None: + for index, job_id in enumerate(self._queue, start=1): + job = self._jobs.get(job_id) + if job: + job.queue_position = index + self._persist_state() + + def _remove_job_locked(self, job_id: str) -> None: + self._jobs.pop(job_id, None) + if job_id in self._queue: + self._queue.remove(job_id) + self._update_queue_positions_locked() + + def _post_completion_hooks(self, job: Job) -> None: + try: + self._maybe_send_to_audiobookshelf(job) + except AudiobookshelfUploadError as exc: + job.add_log(f"Audiobookshelf upload failed: {exc}", level="error") + except Exception as exc: # pragma: no cover - defensive guard + job.add_log(f"Audiobookshelf integration error: {exc}", level="error") + + def _maybe_send_to_audiobookshelf(self, job: Job) -> None: + cfg = load_config() or {} + integration_cfg = cfg.get("audiobookshelf") + if not isinstance(integration_cfg, Mapping): + return + enabled = self._coerce_bool(integration_cfg.get("enabled"), False) + auto_send = self._coerce_bool(integration_cfg.get("auto_send"), False) + if not (enabled and auto_send): + return + + base_url = str(integration_cfg.get("base_url") or "").strip() + api_token = str(integration_cfg.get("api_token") or "").strip() + library_id = str(integration_cfg.get("library_id") or "").strip() + folder_id = str(integration_cfg.get("folder_id") or "").strip() + if not base_url or not api_token or not library_id: + job.add_log( + "Audiobookshelf upload skipped: configure base URL, API token, and library ID first.", + level="warning", + ) + return + if not folder_id: + job.add_log( + "Audiobookshelf upload skipped: enter the folder name or ID in the Audiobookshelf settings.", + level="warning", + ) + return + + audio_ref = job.result.audio_path + audio_path = audio_ref if isinstance(audio_ref, Path) else Path(str(audio_ref)) if audio_ref else None + if not audio_path or not audio_path.exists(): + job.add_log("Audiobookshelf upload skipped: audio output not found.", level="warning") + return + + timeout_raw = integration_cfg.get("timeout", 3600.0) + try: + timeout_value = float(timeout_raw) + except (TypeError, ValueError): + timeout_value = 3600.0 + + config = AudiobookshelfConfig( + base_url=base_url, + api_token=api_token, + library_id=library_id, + collection_id=(str(integration_cfg.get("collection_id") or "").strip() or None), + folder_id=folder_id, + verify_ssl=self._coerce_bool(integration_cfg.get("verify_ssl"), True), + send_cover=self._coerce_bool(integration_cfg.get("send_cover"), True), + send_chapters=self._coerce_bool(integration_cfg.get("send_chapters"), True), + send_subtitles=self._coerce_bool(integration_cfg.get("send_subtitles"), False), + timeout=timeout_value, + ) + + cover_ref = job.cover_image_path + cover_path = None + if config.send_cover and cover_ref: + cover_candidate = cover_ref if isinstance(cover_ref, Path) else Path(str(cover_ref)) + if cover_candidate.exists(): + cover_path = cover_candidate + + subtitles = _existing_paths(job.result.subtitle_paths) if config.send_subtitles else None + chapters = load_audiobookshelf_chapters(job) if config.send_chapters else None + metadata = build_audiobookshelf_metadata(job) + + client = AudiobookshelfClient(config) + + display_title = metadata.get("title") or audio_path.stem + try: + existing_items = client.find_existing_items(display_title, folder_id=config.folder_id) + except AudiobookshelfUploadError as exc: + job.add_log(f"Audiobookshelf lookup failed: {exc}", level="error") + return + + if existing_items: + job.add_log( + f"Removing existing Audiobookshelf item(s) for '{display_title}' before upload.", + level="info", + ) + try: + client.delete_items(existing_items) + except Exception as exc: + job.add_log(f"Failed to remove existing item(s): {exc}", level="warning") + + client.upload_audiobook( + audio_path, + metadata=metadata, + cover_path=cover_path, + chapters=chapters, + subtitles=subtitles, + ) + job.add_log("Audiobookshelf upload queued.", level="info") + + # Persistence ------------------------------------------------------ + def _serialize_job(self, job: Job) -> Dict[str, Any]: + result_audio = str(job.result.audio_path) if job.result.audio_path else None + result_subtitles = [str(path) for path in job.result.subtitle_paths] + result_artifacts = {key: str(path) for key, path in job.result.artifacts.items()} + result_epub = str(job.result.epub_path) if job.result.epub_path else None + return { + "id": job.id, + "original_filename": job.original_filename, + "stored_path": str(job.stored_path), + "language": job.language, + "tts_provider": getattr(job, "tts_provider", "kokoro"), + "voice": job.voice, + "speed": job.speed, + "supertonic_total_steps": getattr(job, "supertonic_total_steps", 5), + "use_gpu": job.use_gpu, + "subtitle_mode": job.subtitle_mode, + "output_format": job.output_format, + "save_mode": job.save_mode, + "output_folder": str(job.output_folder) if job.output_folder else None, + "replace_single_newlines": job.replace_single_newlines, + "subtitle_format": job.subtitle_format, + "created_at": job.created_at, + "save_chapters_separately": job.save_chapters_separately, + "merge_chapters_at_end": job.merge_chapters_at_end, + "separate_chapters_format": job.separate_chapters_format, + "silence_between_chapters": job.silence_between_chapters, + "save_as_project": job.save_as_project, + "voice_profile": job.voice_profile, + "metadata_tags": job.metadata_tags, + "max_subtitle_words": job.max_subtitle_words, + "status": job.status.value, + "started_at": job.started_at, + "finished_at": job.finished_at, + "progress": job.progress, + "total_characters": job.total_characters, + "processed_characters": job.processed_characters, + "error": job.error, + "logs": [log.__dict__ for log in job.logs][-500:], + "result": { + "audio_path": result_audio, + "subtitle_paths": result_subtitles, + "artifacts": result_artifacts, + "epub_path": result_epub, + }, + "chapters": [dict(entry) for entry in job.chapters], + "queue_position": job.queue_position, + "cancel_requested": job.cancel_requested, + "pause_requested": job.pause_requested, + "paused": job.paused, + "resume_token": job.resume_token, + "cover_image_path": str(job.cover_image_path) if job.cover_image_path else None, + "cover_image_mime": job.cover_image_mime, + "chapter_intro_delay": job.chapter_intro_delay, + "read_title_intro": job.read_title_intro, + "auto_prefix_chapter_titles": job.auto_prefix_chapter_titles, + "normalize_chapter_opening_caps": job.normalize_chapter_opening_caps, + "chunk_level": job.chunk_level, + "chunks": [dict(entry) for entry in job.chunks], + "speakers": dict(job.speakers), + "speaker_mode": job.speaker_mode, + "generate_epub3": job.generate_epub3, + "speaker_analysis": dict(job.speaker_analysis), + "speaker_analysis_threshold": job.speaker_analysis_threshold, + "analysis_requested": job.analysis_requested, + "entity_summary": dict(job.entity_summary), + "manual_overrides": [dict(entry) for entry in job.manual_overrides], + "pronunciation_overrides": [dict(entry) for entry in job.pronunciation_overrides], + "heteronym_overrides": [dict(entry) for entry in job.heteronym_overrides], + "normalization_overrides": dict(job.normalization_overrides), + } + + def _persist_state(self) -> None: + try: + with self._lock: + snapshot = { + "version": STATE_VERSION, + "jobs": [self._serialize_job(job) for job in self._jobs.values()], + "queue": list(self._queue), + } + tmp_path = self._state_path.with_suffix(".tmp") + with tmp_path.open("w", encoding="utf-8") as handle: + json.dump(snapshot, handle, indent=2) + os.replace(tmp_path, self._state_path) + except Exception: + # Persistence failures should not disrupt runtime; ignore. + pass + + def _determine_state_path(self) -> Path: + override_file = os.environ.get("ABOGEN_QUEUE_STATE_PATH") + if override_file: + target_path = Path(override_file).expanduser() + target_path.parent.mkdir(parents=True, exist_ok=True) + return target_path + + override_dir = os.environ.get("ABOGEN_QUEUE_STATE_DIR") + if override_dir: + base_dir = Path(override_dir).expanduser() + else: + settings_override = os.environ.get("ABOGEN_SETTINGS_DIR") + if settings_override: + base_dir = Path(settings_override).expanduser() / "queue" + else: + try: + base_dir = Path(get_user_settings_dir()) / "queue" + except ModuleNotFoundError: + base_dir = Path(get_internal_cache_path("jobs")) + base_dir.mkdir(parents=True, exist_ok=True) + target_path = base_dir / "queue_state.json" + + legacy_path = Path(get_internal_cache_path("jobs")) / "queue_state.json" + if legacy_path.exists() and not target_path.exists(): + try: + shutil.move(str(legacy_path), target_path) + except Exception: + try: + shutil.copy2(str(legacy_path), target_path) + except Exception: + pass + + return target_path + + def _deserialize_job(self, payload: Dict[str, Any]) -> Job: + stored_path = Path(payload["stored_path"]) + output_folder_raw = payload.get("output_folder") + output_folder = Path(output_folder_raw) if output_folder_raw else None + job = Job( + id=payload["id"], + original_filename=payload["original_filename"], + stored_path=stored_path, + language=payload.get("language", "a"), + tts_provider=str(payload.get("tts_provider") or "kokoro"), + voice=payload.get("voice", ""), + speed=float(payload.get("speed", 1.0)), + use_gpu=bool(payload.get("use_gpu", True)), + subtitle_mode=payload.get("subtitle_mode", "Disabled"), + output_format=payload.get("output_format", "wav"), + save_mode=payload.get("save_mode", "Save next to input file"), + output_folder=output_folder, + replace_single_newlines=bool(payload.get("replace_single_newlines", False)), + subtitle_format=payload.get("subtitle_format", "srt"), + created_at=float(payload.get("created_at", time.time())), + supertonic_total_steps=int(payload.get("supertonic_total_steps", 5)), + save_chapters_separately=bool(payload.get("save_chapters_separately", False)), + merge_chapters_at_end=bool(payload.get("merge_chapters_at_end", True)), + separate_chapters_format=payload.get("separate_chapters_format", "wav"), + silence_between_chapters=float(payload.get("silence_between_chapters", 2.0)), + save_as_project=bool(payload.get("save_as_project", False)), + voice_profile=payload.get("voice_profile"), + metadata_tags=payload.get("metadata_tags", {}), + max_subtitle_words=int(payload.get("max_subtitle_words", 50)), + chapter_intro_delay=float(payload.get("chapter_intro_delay", 0.5)), + read_title_intro=bool(payload.get("read_title_intro", False)), + auto_prefix_chapter_titles=bool(payload.get("auto_prefix_chapter_titles", True)), + normalize_chapter_opening_caps=bool(payload.get("normalize_chapter_opening_caps", True)), + ) + job.status = JobStatus(payload.get("status", job.status.value)) + job.started_at = payload.get("started_at") + job.finished_at = payload.get("finished_at") + job.progress = float(payload.get("progress", 0.0)) + job.total_characters = int(payload.get("total_characters", 0)) + job.processed_characters = int(payload.get("processed_characters", 0)) + job.error = payload.get("error") + job.logs = [JobLog(**entry) for entry in payload.get("logs", [])] + result_payload = payload.get("result", {}) + audio_path_raw = result_payload.get("audio_path") + job.result.audio_path = Path(audio_path_raw) if audio_path_raw else None + job.result.subtitle_paths = [Path(item) for item in result_payload.get("subtitle_paths", [])] + job.result.artifacts = { + key: Path(value) for key, value in result_payload.get("artifacts", {}).items() + } + epub_path_raw = result_payload.get("epub_path") + job.result.epub_path = Path(epub_path_raw) if epub_path_raw else None + job.chapters = payload.get("chapters", []) + job.queue_position = payload.get("queue_position") + job.cancel_requested = bool(payload.get("cancel_requested", False)) + job.pause_requested = bool(payload.get("pause_requested", False)) + job.paused = bool(payload.get("paused", False)) + job.resume_token = payload.get("resume_token") + cover_path_raw = payload.get("cover_image_path") + job.cover_image_path = Path(cover_path_raw) if cover_path_raw else None + job.cover_image_mime = payload.get("cover_image_mime") + job.chunk_level = str(payload.get("chunk_level", job.chunk_level or "paragraph")) + job.chunks = self._normalize_chunks(payload.get("chunks")) + job.speakers = dict(payload.get("speakers", {})) + job.speaker_mode = str(payload.get("speaker_mode", job.speaker_mode or "single")) + job.generate_epub3 = bool(payload.get("generate_epub3", job.generate_epub3)) + job.speaker_analysis = payload.get("speaker_analysis", {}) + job.speaker_analysis_threshold = int( + payload.get("speaker_analysis_threshold", job.speaker_analysis_threshold or 3) + ) + job.analysis_requested = bool(payload.get("analysis_requested", job.analysis_requested)) + job.entity_summary = payload.get("entity_summary", {}) + job.manual_overrides = [dict(entry) for entry in payload.get("manual_overrides", []) if isinstance(entry, Mapping)] + job.pronunciation_overrides = [ + dict(entry) for entry in payload.get("pronunciation_overrides", []) if isinstance(entry, Mapping) + ] + job.heteronym_overrides = [ + dict(entry) for entry in payload.get("heteronym_overrides", []) if isinstance(entry, Mapping) + ] + job.normalization_overrides = dict(payload.get("normalization_overrides", {}) or {}) + job.pause_event.set() + return job + + def _load_state(self) -> None: + if not self._state_path.exists(): + return + try: + with self._state_path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + except Exception: + return + + version = int(payload.get("version", 0) or 0) + if version not in {STATE_VERSION, STATE_VERSION - 1}: + return + + jobs_payload = payload.get("jobs", []) + queue_payload = payload.get("queue", []) + loaded_jobs: Dict[str, Job] = {} + requeue: List[str] = [] + + for entry in jobs_payload: + try: + job = self._deserialize_job(entry) + except Exception: + continue + + if job.status in {JobStatus.RUNNING, JobStatus.PAUSED}: + job.status = JobStatus.PENDING + job.add_log("Job restored after restart: resetting to pending queue.", level="warning") + job.progress = 0.0 + job.processed_characters = 0 + job.pause_requested = False + job.paused = False + job.pause_event.set() + requeue.append(job.id) + elif job.status == JobStatus.PENDING: + requeue.append(job.id) + + loaded_jobs[job.id] = job + + with self._lock: + self._jobs = loaded_jobs + self._queue = [job_id for job_id in queue_payload if job_id in loaded_jobs] + for job_id in requeue: + if job_id not in self._queue: + self._queue.append(job_id) + self._update_queue_positions_locked() + + if self._queue: + self._ensure_worker() + + @staticmethod + def _coerce_bool(value: Any, default: bool = True) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"true", "1", "yes", "on"}: + return True + if lowered in {"false", "0", "no", "off"}: + return False + return default + if value is None: + return default + return bool(value) + + @staticmethod + def _coerce_optional_int(value: Any) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _normalize_metadata_tags(values: Optional[Mapping[str, Any]]) -> Dict[str, str]: + if not values: + return {} + normalized: Dict[str, str] = {} + for key, raw_value in values.items(): + if raw_value is None: + continue + key_str = str(key).strip() + if not key_str: + continue + normalized[key_str] = str(raw_value) + return normalized + + @classmethod + def _normalize_chapters(cls, chapters: Optional[Iterable[Any]]) -> List[Dict[str, Any]]: + if not chapters: + return [] + + normalized: List[Dict[str, Any]] = [] + for order, raw in enumerate(chapters): + if raw is None: + continue + + if isinstance(raw, str): + raw_dict: Dict[str, Any] = {"title": raw} + elif isinstance(raw, dict): + raw_dict = dict(raw) + else: + continue + + entry: Dict[str, Any] = {} + + id_value = raw_dict.get("id") or raw_dict.get("chapter_id") or raw_dict.get("key") + if id_value is not None: + entry["id"] = str(id_value) + + index_value = ( + cls._coerce_optional_int(raw_dict.get("index")) + or cls._coerce_optional_int(raw_dict.get("original_index")) + or cls._coerce_optional_int(raw_dict.get("source_index")) + or cls._coerce_optional_int(raw_dict.get("chapter_index")) + ) + if index_value is not None: + entry["index"] = index_value + + order_value = ( + cls._coerce_optional_int(raw_dict.get("order")) + or cls._coerce_optional_int(raw_dict.get("position")) + or cls._coerce_optional_int(raw_dict.get("sort")) + or cls._coerce_optional_int(raw_dict.get("sort_order")) + ) + entry["order"] = order_value if order_value is not None else order + + source_title = ( + raw_dict.get("source_title") + or raw_dict.get("original_title") + or raw_dict.get("base_title") + ) + if source_title: + entry["source_title"] = str(source_title) + + title_value = ( + raw_dict.get("title") + or raw_dict.get("name") + or raw_dict.get("label") + or raw_dict.get("chapter") + ) + if title_value is not None: + entry["title"] = str(title_value) + elif source_title: + entry["title"] = str(source_title) + else: + entry["title"] = f"Chapter {order + 1}" + + text_value = raw_dict.get("text") + if text_value is None: + text_value = raw_dict.get("content") or raw_dict.get("body") or raw_dict.get("value") + if text_value is not None: + entry["text"] = str(text_value) + + enabled = cls._coerce_bool( + raw_dict.get("enabled", raw_dict.get("include", raw_dict.get("selected", True))), + True, + ) + if "disabled" in raw_dict and cls._coerce_bool(raw_dict.get("disabled"), False): + enabled = False + entry["enabled"] = enabled + + metadata_payload = raw_dict.get("metadata") or raw_dict.get("metadata_tags") + normalized_metadata = cls._normalize_metadata_tags(metadata_payload) + if normalized_metadata: + entry["metadata"] = normalized_metadata + + voice_value = raw_dict.get("voice") + if voice_value: + entry["voice"] = str(voice_value) + + profile_value = raw_dict.get("voice_profile") + if profile_value: + entry["voice_profile"] = str(profile_value) + + formula_value = raw_dict.get("voice_formula") or raw_dict.get("formula") + if formula_value: + entry["voice_formula"] = str(formula_value) + + resolved_value = raw_dict.get("resolved_voice") + if resolved_value: + entry["resolved_voice"] = str(resolved_value) + + if "characters" in raw_dict: + try: + entry["characters"] = int(raw_dict.get("characters", 0)) + except (TypeError, ValueError): + entry["characters"] = len(str(entry.get("text", ""))) + else: + entry["characters"] = len(str(entry.get("text", ""))) + + normalized.append(entry) + + return normalized + + @classmethod + def _normalize_chunks(cls, chunks: Optional[Iterable[Any]]) -> List[Dict[str, Any]]: + if not chunks: + return [] + + normalized: List[Dict[str, Any]] = [] + for order, raw in enumerate(chunks): + if raw is None: + continue + if isinstance(raw, dict): + entry = dict(raw) + else: + continue + + chunk: Dict[str, Any] = {} + + identifier = entry.get("id") or entry.get("chunk_id") + if identifier is not None: + chunk["id"] = str(identifier) + + try: + chunk_index = int(entry.get("chunk_index", order)) + except (TypeError, ValueError): + chunk_index = order + chunk["chunk_index"] = chunk_index + + try: + chapter_index = int(entry.get("chapter_index", 0)) + except (TypeError, ValueError): + chapter_index = 0 + chunk["chapter_index"] = chapter_index + + level_raw = str(entry.get("level", "paragraph")).lower() + if level_raw not in {"paragraph", "sentence"}: + level_raw = "paragraph" + chunk["level"] = level_raw + + text_value = entry.get("text") + if text_value is not None: + chunk["text"] = str(text_value) + else: + chunk["text"] = "" + + normalized_value = entry.get("normalized_text") + if normalized_value is not None: + chunk["normalized_text"] = str(normalized_value) + + for text_key in ("display_text", "original_text"): + if text_key in entry and entry[text_key] is not None: + chunk[text_key] = str(entry[text_key]) + + speaker_value = entry.get("speaker_id", entry.get("speaker")) + chunk["speaker_id"] = str(speaker_value) if speaker_value else "narrator" + + for key in ("voice", "voice_profile", "voice_formula", "audio_path", "start", "end"): + if key in entry and entry[key] is not None: + chunk[key] = entry[key] + + normalized.append(chunk) + return normalized + + +def default_storage_root() -> Path: + base = Path.cwd() + uploads = base / "var" / "uploads" + outputs = base / "var" / "outputs" + outputs.mkdir(parents=True, exist_ok=True) + uploads.mkdir(parents=True, exist_ok=True) + return outputs + + +def build_service( + runner: Callable[[Job], None], + *, + output_root: Optional[Path] = None, + uploads_root: Optional[Path] = None, +) -> ConversionService: + output_root = output_root or default_storage_root() + service = ConversionService( + output_root=output_root, + uploads_root=uploads_root, + runner=runner, + ) + return service diff --git a/abogen/webui/static/dashboard.js b/abogen/webui/static/dashboard.js new file mode 100644 index 0000000..e2d0028 --- /dev/null +++ b/abogen/webui/static/dashboard.js @@ -0,0 +1,582 @@ +import { initReaderUI } from "./reader.js"; +import { initWizard } from "./wizard.js"; + +const dashboardState = (window.AbogenDashboardState = window.AbogenDashboardState || { + boundKeydown: false, + boundBeforeUnload: false, +}); + +const initDashboard = () => { + const uploadModal = + document.querySelector('[data-role="new-job-modal"]') || + document.querySelector('[data-role="upload-modal"]'); + const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]'); + const scope = uploadModal || document; + const sourceFileInput = scope.querySelector('#source_file'); + const dropzone = document.querySelector('[data-role="upload-dropzone"]'); + const dropzoneFilename = document.querySelector('[data-role="upload-dropzone-filename"]'); + + const parseJSONScript = (id) => { + const element = document.getElementById(id); + if (!element) return null; + try { + const raw = element.textContent || ""; + return raw ? JSON.parse(raw) : null; + } catch (error) { + console.warn(`Failed to parse JSON script: ${id}`, error); + return null; + } + }; + + const profileSelect = scope.querySelector('[data-role="voice-profile"]'); + const voiceField = scope.querySelector('[data-role="voice-field"]'); + const voiceSelect = scope.querySelector('[data-role="voice-select"]'); + const formulaField = scope.querySelector('[data-role="formula-field"]'); + const formulaInput = scope.querySelector('[data-role="voice-formula"]'); + const languageSelect = uploadModal?.querySelector("#language") || document.getElementById("language"); + const speedInput = uploadModal?.querySelector('#speed') || document.getElementById('speed'); + const previewButton = scope.querySelector('[data-role="voice-preview-button"]'); + const previewStatus = scope.querySelector('[data-role="voice-preview-status"]'); + const previewAudio = scope.querySelector('[data-role="voice-preview-audio"]'); + const sampleVoiceTexts = parseJSONScript('voice-sample-texts') || {}; + + const setDropzoneStatus = (message, state = "") => { + if (!dropzoneFilename) return; + if (!message) { + dropzoneFilename.hidden = true; + dropzoneFilename.textContent = ""; + dropzoneFilename.removeAttribute("data-state"); + return; + } + dropzoneFilename.hidden = false; + dropzoneFilename.textContent = message; + if (state) { + dropzoneFilename.dataset.state = state; + } else { + dropzoneFilename.removeAttribute("data-state"); + } + }; + + const updateDropzoneFilename = () => { + if (!sourceFileInput) { + setDropzoneStatus(""); + return; + } + const file = sourceFileInput.files && sourceFileInput.files[0]; + if (file) { + setDropzoneStatus(`Selected: ${file.name}`); + } else { + setDropzoneStatus(""); + } + }; + + const assignDroppedFile = (file) => { + if (!sourceFileInput || !file) { + return false; + } + try { + if (typeof DataTransfer === "undefined") { + throw new Error("DataTransfer API unavailable"); + } + const transfer = new DataTransfer(); + transfer.items.add(file); + sourceFileInput.files = transfer.files; + sourceFileInput.dispatchEvent(new Event("change", { bubbles: true })); + try { + sourceFileInput.focus({ preventScroll: true }); + } catch (error) { + // Ignore focus errors + } + return true; + } catch (error) { + console.warn("Unable to assign dropped file to input", error); + setDropzoneStatus("Drag & drop isn't supported here. Click to choose a file instead.", "error"); + return false; + } + }; + + const setDropzoneActive = (isActive) => { + if (!dropzone) return; + dropzone.classList.toggle("is-dragging", isActive); + if (isActive) { + dropzone.dataset.state = "drag"; + } else { + delete dropzone.dataset.state; + } + }; + + let lastTrigger = null; + let previewAbortController = null; + let previewObjectUrl = null; + let suppressPauseStatus = false; + + const dispatchUploadModalEvent = (type, detail = {}) => { + const eventName = `upload-modal:${type}`; + if (uploadModal) { + uploadModal.dispatchEvent(new CustomEvent(eventName, { detail, bubbles: true })); + return; + } + document.dispatchEvent(new CustomEvent(eventName, { detail })); + }; + + const openUploadModal = (trigger) => { + if (!uploadModal) return; + lastTrigger = trigger || null; + uploadModal.hidden = false; + uploadModal.dataset.open = "true"; + document.body.classList.add("modal-open"); + const focusTarget = uploadModal.querySelector("#source_file") || uploadModal.querySelector("#source_text") || uploadModal; + if (focusTarget instanceof HTMLElement) { + focusTarget.focus({ preventScroll: true }); + } + dispatchUploadModalEvent("open", { trigger: lastTrigger }); + }; + + const closeUploadModal = () => { + if (!uploadModal || uploadModal.hidden) { + return; + } + uploadModal.hidden = true; + delete uploadModal.dataset.open; + document.body.classList.remove("modal-open"); + if (lastTrigger && lastTrigger instanceof HTMLElement) { + lastTrigger.focus({ preventScroll: true }); + } + dispatchUploadModalEvent("close", { trigger: lastTrigger }); + }; + + openModalButtons.forEach((button) => { + if (!button || button.dataset.dashboardBound === "true") { + return; + } + button.dataset.dashboardBound = "true"; + button.addEventListener("click", (event) => { + event.preventDefault(); + openUploadModal(button); + }); + }); + + if (uploadModal && uploadModal.dataset.dashboardCloseBound !== "true") { + uploadModal.dataset.dashboardCloseBound = "true"; + uploadModal.addEventListener("click", (event) => { + const target = event.target; + if ( + target instanceof Element && + (target.closest('[data-role="new-job-modal-close"]') || + target.closest('[data-role="upload-modal-close"]') || + target.closest('[data-role="wizard-close"]') || + target.closest('[data-role="wizard-cancel"]')) + ) { + event.preventDefault(); + closeUploadModal(); + } + }); + } + + if (!dashboardState.boundKeydown) { + dashboardState.boundKeydown = true; + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + if (uploadModal && !uploadModal.hidden) { + closeUploadModal(); + return; + } + } + }); + } + + initReaderUI({ onBeforeOpen: closeUploadModal }); + + if (sourceFileInput) { + if (sourceFileInput.dataset.dashboardChangeBound !== "true") { + sourceFileInput.dataset.dashboardChangeBound = "true"; + sourceFileInput.addEventListener("change", updateDropzoneFilename); + } + updateDropzoneFilename(); + } else { + setDropzoneStatus(""); + } + + const resolveSampleText = (language) => { + const fallback = typeof sampleVoiceTexts === "object" && sampleVoiceTexts?.a + ? sampleVoiceTexts.a + : "This is a sample of the selected voice."; + if (!language || typeof sampleVoiceTexts !== "object" || !sampleVoiceTexts) { + return fallback; + } + const normalizedKey = language.toLowerCase(); + if (typeof sampleVoiceTexts[normalizedKey] === "string" && sampleVoiceTexts[normalizedKey].trim()) { + return sampleVoiceTexts[normalizedKey]; + } + const baseKey = normalizedKey.split(/[_.-]/)[0]; + if (baseKey && typeof sampleVoiceTexts[baseKey] === "string" && sampleVoiceTexts[baseKey].trim()) { + return sampleVoiceTexts[baseKey]; + } + return fallback; + }; + + const getSelectedLanguage = () => { + const value = languageSelect?.value || "a"; + return (value || "a").trim() || "a"; + }; + + const getSelectedSpeed = () => { + const raw = speedInput?.value || "1"; + const parsed = Number.parseFloat(raw); + return Number.isFinite(parsed) ? parsed : 1; + }; + + const cancelPreviewRequest = () => { + if (!previewAbortController) return; + previewAbortController.abort(); + previewAbortController = null; + }; + + const stopPreviewAudio = () => { + if (previewAudio) { + suppressPauseStatus = true; + try { + previewAudio.pause(); + } catch (error) { + // Ignore pause errors + } + previewAudio.removeAttribute("src"); + previewAudio.load(); + previewAudio.hidden = true; + suppressPauseStatus = false; + } + if (previewObjectUrl) { + URL.revokeObjectURL(previewObjectUrl); + previewObjectUrl = null; + } + }; + + const setPreviewStatus = (message, state = "") => { + if (!previewStatus) return; + if (!message) { + previewStatus.textContent = ""; + previewStatus.hidden = true; + previewStatus.removeAttribute("data-state"); + return; + } + previewStatus.textContent = message; + previewStatus.hidden = false; + if (state) { + previewStatus.dataset.state = state; + } else { + previewStatus.removeAttribute("data-state"); + } + }; + + const setPreviewLoading = (isLoading) => { + if (!previewButton) return; + previewButton.disabled = isLoading; + if (isLoading) { + previewButton.dataset.loading = "true"; + } else { + previewButton.removeAttribute("data-loading"); + } + }; + + const buildPreviewRequest = () => { + const language = getSelectedLanguage(); + const speed = getSelectedSpeed(); + const basePayload = { + language, + speed, + max_seconds: 8, + text: resolveSampleText(language), + }; + + const profileValue = profileSelect?.value || "__standard"; + + if (profileValue && profileValue !== "__standard") { + if (profileValue === "__formula") { + const formulaValue = (formulaInput?.value || "").trim(); + if (!formulaValue) { + return { error: "Enter a custom voice formula to preview." }; + } + return { + endpoint: "/api/voice-profiles/preview", + payload: { ...basePayload, formula: formulaValue }, + }; + } + return { + endpoint: "/api/voice-profiles/preview", + payload: { ...basePayload, profile: profileValue }, + }; + } + + const selectedVoice = (voiceSelect?.value || voiceSelect?.dataset.default || "").trim(); + if (!selectedVoice) { + return { error: "Select a narrator voice to preview." }; + } + return { + endpoint: "/api/speaker-preview", + payload: { ...basePayload, voice: selectedVoice }, + }; + }; + + const resetPreview = () => { + cancelPreviewRequest(); + stopPreviewAudio(); + setPreviewStatus("", ""); + }; + + if (previewAudio) { + previewAudio.addEventListener("ended", () => { + setPreviewStatus("Preview finished", "info"); + }); + previewAudio.addEventListener("pause", () => { + if (suppressPauseStatus || previewAudio.ended || previewAudio.currentTime === 0) { + return; + } + setPreviewStatus("Preview paused", "info"); + }); + } + + const handleVoicePreview = async () => { + if (!previewButton) return; + const request = buildPreviewRequest(); + if (!request) { + return; + } + if (request.error) { + setPreviewStatus(request.error, "error"); + cancelPreviewRequest(); + stopPreviewAudio(); + return; + } + + cancelPreviewRequest(); + stopPreviewAudio(); + previewAbortController = new AbortController(); + setPreviewLoading(true); + setPreviewStatus("Generating preview…", "loading"); + + try { + const response = await fetch(request.endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request.payload), + signal: previewAbortController.signal, + }); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Preview failed (status ${response.status})`); + } + const blob = await response.blob(); + previewObjectUrl = URL.createObjectURL(blob); + if (previewAudio) { + previewAudio.src = previewObjectUrl; + previewAudio.hidden = false; + try { + await previewAudio.play(); + setPreviewStatus("Preview playing", "success"); + } catch (error) { + setPreviewStatus("Preview ready. Press play to listen.", "success"); + } + } else { + setPreviewStatus("Preview ready.", "success"); + } + } catch (error) { + if (error.name === "AbortError") { + return; + } + console.error("Voice preview failed", error); + setPreviewStatus(error.message || "Preview failed", "error"); + stopPreviewAudio(); + } finally { + setPreviewLoading(false); + } + }; + + if (previewButton && previewButton.dataset.dashboardBound !== "true") { + previewButton.dataset.dashboardBound = "true"; + previewButton.addEventListener("click", (event) => { + event.preventDefault(); + handleVoicePreview(); + }); + } + + if (dropzone && dropzone.dataset.dashboardDragBound !== "true") { + dropzone.dataset.dashboardDragBound = "true"; + let dragDepth = 0; + + dropzone.addEventListener("dragenter", (event) => { + event.preventDefault(); + dragDepth += 1; + setDropzoneActive(true); + }); + + dropzone.addEventListener("dragover", (event) => { + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "copy"; + } + }); + + const handleDragLeave = (event) => { + if (event && dropzone.contains(event.relatedTarget)) { + return; + } + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) { + setDropzoneActive(false); + } + }; + + dropzone.addEventListener("dragleave", (event) => { + handleDragLeave(event); + }); + + dropzone.addEventListener("dragend", () => { + dragDepth = 0; + setDropzoneActive(false); + }); + + dropzone.addEventListener("drop", (event) => { + event.preventDefault(); + dragDepth = 0; + setDropzoneActive(false); + const files = event.dataTransfer && event.dataTransfer.files; + if (!files || !files.length) { + return; + } + openUploadModal(dropzone); + assignDroppedFile(files[0]); + }); + + dropzone.addEventListener("click", (event) => { + if (event.target.closest('[data-role="open-upload-modal"]')) { + return; + } + openUploadModal(dropzone); + }); + + dropzone.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openUploadModal(dropzone); + } + }); + } + + [voiceSelect, profileSelect, formulaInput, languageSelect, speedInput].forEach((input) => { + if (!input) return; + const eventName = input === formulaInput ? "input" : "change"; + input.addEventListener(eventName, () => { + resetPreview(); + }); + }); + + const hydrateDefaultVoice = () => { + if (!voiceSelect) return; + const defaultVoice = voiceSelect.dataset.default; + if (!defaultVoice) return; + const option = voiceSelect.querySelector(`option[value="${defaultVoice}"]`); + if (option) { + voiceSelect.value = defaultVoice; + } + }; + + const applySavedProfile = (option) => { + if (!option) return; + const presetFormula = option.dataset.formula || ""; + const profileLang = option.dataset.language || ""; + if (formulaInput) { + formulaInput.value = presetFormula; + formulaInput.readOnly = true; + formulaInput.dataset.state = "locked"; + } + if (profileLang && languageSelect) { + languageSelect.value = profileLang; + } + }; + + const updateVoiceControls = () => { + if (!profileSelect) { + return; + } + const value = profileSelect.value || "__standard"; + const isStandard = value === "__standard"; + const isFormula = value === "__formula"; + const isSavedProfile = !isStandard && !isFormula; + + const showVoiceField = isStandard; + if (voiceField) { + voiceField.hidden = !showVoiceField; + voiceField.setAttribute("aria-hidden", showVoiceField ? "false" : "true"); + voiceField.dataset.state = showVoiceField ? "visible" : "hidden"; + } + if (voiceSelect) { + voiceSelect.disabled = !isStandard; + voiceSelect.dataset.state = isStandard ? "editable" : "locked"; + if (isStandard) { + hydrateDefaultVoice(); + } + } + + if (isSavedProfile) { + applySavedProfile(profileSelect.selectedOptions[0] || null); + } else if (!isFormula && formulaInput) { + formulaInput.value = ""; + } + + const showFormulaField = isFormula; + if (formulaField) { + const shouldShow = showFormulaField; + formulaField.hidden = !shouldShow; + formulaField.setAttribute("aria-hidden", shouldShow ? "false" : "true"); + formulaField.dataset.state = shouldShow ? "visible" : "hidden"; + } + if (formulaInput) { + if (isFormula) { + formulaInput.disabled = false; + formulaInput.readOnly = false; + formulaInput.dataset.state = "editable"; + } else if (isSavedProfile) { + formulaInput.disabled = false; + formulaInput.readOnly = true; + formulaInput.dataset.state = "locked"; + } else { + formulaInput.disabled = true; + formulaInput.readOnly = true; + formulaInput.value = ""; + formulaInput.dataset.state = "editable"; + } + } + }; + + if (profileSelect) { + if (profileSelect.dataset.dashboardBound !== "true") { + profileSelect.dataset.dashboardBound = "true"; + profileSelect.addEventListener("change", updateVoiceControls); + } + updateVoiceControls(); + } else { + hydrateDefaultVoice(); + } + + if (!dashboardState.boundBeforeUnload) { + dashboardState.boundBeforeUnload = true; + window.addEventListener("beforeunload", () => { + cancelPreviewRequest(); + stopPreviewAudio(); + }); + } +}; + +window.AbogenDashboard = window.AbogenDashboard || {}; +window.AbogenDashboard.init = initDashboard; + +const bootDashboard = () => { + initDashboard(); + initWizard(); +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bootDashboard, { once: true }); +} else { + bootDashboard(); +} diff --git a/abogen/webui/static/entities.js b/abogen/webui/static/entities.js new file mode 100644 index 0000000..7484c06 --- /dev/null +++ b/abogen/webui/static/entities.js @@ -0,0 +1,247 @@ +(function () { + const root = document.querySelector('[data-override-root]'); + if (!root) { + return; + } + + const previewUrl = root.dataset.previewUrl || ""; + const defaultLanguage = root.dataset.language || "a"; + const table = root.querySelector('[data-role="override-table"]'); + const rows = table ? Array.from(table.querySelectorAll('[data-role="override-row"]')) : []; + const filterInput = root.querySelector('[data-role="override-filter"]'); + const filterClearButton = root.querySelector('[data-role="override-filter-clear"]'); + const filterEmptyMessage = root.querySelector('[data-role="filter-empty"]'); + + function base64ToBlob(base64, mimeType) { + const binary = atob(base64); + const length = binary.length; + const bytes = new Uint8Array(length); + for (let index = 0; index < length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new Blob([bytes], { type: mimeType }); + } + + function getControl(form, selector) { + if (!form) { + return null; + } + const direct = form.querySelector(selector); + if (direct) { + return direct; + } + if (!form.id) { + return null; + } + return root.querySelector(`${selector}[form="${form.id}"]`) || document.querySelector(`${selector}[form="${form.id}"]`); + } + + function resetPreview(container) { + if (!container) { + return; + } + const messageEl = container.querySelector('[data-role="preview-message"]'); + const audioEl = container.querySelector('[data-role="preview-audio"]'); + if (messageEl) { + messageEl.textContent = ""; + messageEl.removeAttribute('data-state'); + } + if (audioEl) { + const priorUrl = audioEl.dataset.objectUrl; + if (priorUrl) { + URL.revokeObjectURL(priorUrl); + delete audioEl.dataset.objectUrl; + } + audioEl.pause(); + audioEl.removeAttribute('src'); + audioEl.hidden = true; + } + } + + function buildPreviewPayload(form) { + if (!form) { + return null; + } + const tokenInput = getControl(form, 'input[name="token"]'); + const pronunciationInput = getControl(form, 'input[name="pronunciation"]'); + const voiceSelect = getControl(form, 'select[name="voice"]'); + const languageInput = getControl(form, 'input[name="lang"]'); + + const token = tokenInput && 'value' in tokenInput ? tokenInput.value.trim() : ""; + const pronunciation = pronunciationInput && 'value' in pronunciationInput ? pronunciationInput.value.trim() : ""; + const voice = voiceSelect && 'value' in voiceSelect ? voiceSelect.value.trim() : ""; + const language = languageInput && 'value' in languageInput ? languageInput.value.trim() : defaultLanguage; + + if (!token && !pronunciation) { + return null; + } + return { + token, + pronunciation, + voice, + language, + }; + } + + async function requestPreview(button) { + if (!previewUrl) { + return; + } + const formId = button.dataset.formId || ""; + const form = formId ? document.getElementById(formId) : button.closest('form'); + const container = button.closest('[data-role="preview-container"]'); + const messageEl = container ? container.querySelector('[data-role="preview-message"]') : null; + const audioEl = container ? container.querySelector('[data-role="preview-audio"]') : null; + + resetPreview(container); + + const payload = buildPreviewPayload(form); + if (!payload) { + if (messageEl) { + messageEl.textContent = "Enter a token or pronunciation first."; + messageEl.dataset.state = "error"; + } + return; + } + + button.disabled = true; + button.setAttribute('data-loading', 'true'); + + try { + const response = await fetch(previewUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + const contentType = response.headers.get('Content-Type') || ''; + let data = null; + if (contentType.includes('application/json')) { + try { + data = await response.json(); + } catch (parseError) { + if (!response.ok) { + throw new Error('Preview failed.'); + } + throw parseError instanceof Error ? parseError : new Error('Preview failed.'); + } + } else { + if (!response.ok) { + const fallback = await response.text().catch(() => ''); + throw new Error(fallback || 'Preview failed.'); + } + throw new Error('Preview failed.'); + } + + if (!response.ok || (data && data.error)) { + throw new Error((data && data.error) || 'Preview failed.'); + } + if (!data || typeof data !== 'object') { + throw new Error('Preview failed.'); + } + if (!data.audio_base64) { + throw new Error('Preview did not return audio.'); + } + + if (audioEl) { + const blob = base64ToBlob(data.audio_base64, 'audio/wav'); + const objectUrl = URL.createObjectURL(blob); + audioEl.src = objectUrl; + audioEl.dataset.objectUrl = objectUrl; + audioEl.hidden = false; + audioEl.load(); + audioEl.play().catch(() => { + /* playback might require user interaction; ignore */ + }); + } + + if (messageEl) { + messageEl.textContent = data.normalized_text || data.text || 'Preview ready.'; + messageEl.dataset.state = "success"; + } + } catch (error) { + if (messageEl) { + messageEl.textContent = error instanceof Error ? error.message : 'Preview failed.'; + messageEl.dataset.state = "error"; + } + } finally { + button.disabled = false; + button.removeAttribute('data-loading'); + } + } + + function attachPreviewHandlers() { + const previewButtons = root.querySelectorAll('[data-role="preview-button"]'); + previewButtons.forEach((button) => { + button.addEventListener('click', () => { + requestPreview(button); + }); + }); + } + + function applyFilter() { + if (!filterInput || rows.length === 0) { + return; + } + const term = filterInput.value.trim().toLowerCase(); + let visibleCount = 0; + rows.forEach((row) => { + const token = row.dataset.token || ""; + const pronunciationInput = row.querySelector('input[name="pronunciation"]'); + const voiceSelect = row.querySelector('select[name="voice"]'); + + const pronunciationValue = pronunciationInput && 'value' in pronunciationInput + ? pronunciationInput.value.trim().toLowerCase() + : ""; + const voiceOption = voiceSelect && 'selectedIndex' in voiceSelect && voiceSelect.selectedIndex >= 0 + ? voiceSelect.options[voiceSelect.selectedIndex] + : null; + const voiceValue = voiceOption && voiceOption.textContent + ? voiceOption.textContent.trim().toLowerCase() + : ""; + + if (!term || token.includes(term) || pronunciationValue.includes(term) || voiceValue.includes(term)) { + row.hidden = false; + visibleCount += 1; + } else { + row.hidden = true; + } + }); + + if (filterEmptyMessage) { + filterEmptyMessage.hidden = visibleCount !== 0; + } + } + + if (filterInput) { + filterInput.addEventListener('input', applyFilter); + } + + if (filterClearButton && filterInput) { + filterClearButton.addEventListener('click', () => { + filterInput.value = ""; + applyFilter(); + filterInput.focus(); + }); + } + + if (table) { + table.addEventListener('input', (event) => { + const target = event.target; + if (target && (target.matches('input[name="pronunciation"]') || target.matches('select[name="voice"]'))) { + applyFilter(); + } + }); + table.addEventListener('change', (event) => { + const target = event.target; + if (target && target.matches('select[name="voice"]')) { + applyFilter(); + } + }); + } + + attachPreviewHandlers(); + applyFilter(); +})(); diff --git a/abogen/webui/static/find_books.js b/abogen/webui/static/find_books.js new file mode 100644 index 0000000..a02abf6 --- /dev/null +++ b/abogen/webui/static/find_books.js @@ -0,0 +1,1057 @@ +const modal = document.querySelector('[data-role="opds-modal"]'); +const browser = modal?.querySelector('[data-role="opds-browser"]') || null; + +if (modal && browser) { + const statusEl = browser.querySelector('[data-role="opds-status"]'); + const resultsEl = browser.querySelector('[data-role="opds-results"]'); + const navEl = browser.querySelector('[data-role="opds-nav"]'); + const navBottomEl = browser.querySelector('[data-role="opds-nav-bottom"]'); + const alphaPickerEl = browser.querySelector('[data-role="opds-alpha-picker"]'); + const tabsEl = modal.querySelector('[data-role="opds-tabs"]'); + const searchForm = modal.querySelector('[data-role="opds-search"]'); + const searchInput = searchForm?.querySelector('input[name="q"]'); + const refreshButton = searchForm?.querySelector('[data-action="opds-refresh"]'); + const openButtons = document.querySelectorAll('[data-action="open-opds-modal"]'); + const closeTargets = modal.querySelectorAll('[data-role="opds-modal-close"]'); + + const TabIds = { + ROOT: 'root', + SEARCH: 'search', + CUSTOM: 'custom', + }; + + const EntryTypes = { + BOOK: 'book', + NAVIGATION: 'navigation', + OTHER: 'other', + }; + + const LETTER_ALL = 'ALL'; + const LETTER_NUMERIC = '#'; + const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + + const state = { + query: '', + currentHref: '', + activeTab: TabIds.ROOT, + tabs: [], + tabsReady: false, + requestToken: 0, + feedTitle: '', + lastEntries: [], + activeLetter: LETTER_ALL, + availableLetters: new Set(), + totalStats: null, + filteredStats: null, + status: { message: '', level: null }, + baseStatus: null, + lastContextKey: '', + currentLinks: {}, + alphabetBaseHref: '', + }; + + let isOpen = false; + let lastTrigger = null; + + const truncate = (text, limit = 160) => { + if (!text || typeof text !== 'string') { + return ''; + } + if (text.length <= limit) { + return text; + } + return `${text.slice(0, limit - 1).trim()}…`; + }; + + const formatAuthors = (authors) => { + if (!Array.isArray(authors) || !authors.length) { + return ''; + } + return authors.filter((author) => !!author).join(', '); + }; + + const formatSeriesIndex = (position) => { + if (position === null || position === undefined) { + return ''; + } + const numeric = Number(position); + if (Number.isFinite(numeric)) { + if (Math.abs(numeric - Math.round(numeric)) < 0.01) { + return String(Math.round(numeric)); + } + return numeric.toLocaleString(undefined, { + minimumFractionDigits: 1, + maximumFractionDigits: 2, + }); + } + if (typeof position === 'string') { + const trimmed = position.trim(); + if (trimmed) { + return trimmed; + } + } + return ''; + }; + + const formatSeriesLabel = (entry) => { + if (!entry) { + return ''; + } + const rawSeries = typeof entry.series === 'string' ? entry.series.trim() : ''; + const rawIndex = + entry.series_index ?? + entry.seriesIndex ?? + entry.series_position ?? + entry.seriesPosition ?? + entry.book_number ?? + entry.bookNumber ?? + null; + const indexLabel = formatSeriesIndex(rawIndex); + if (rawSeries && indexLabel) { + return `${rawSeries} · Book ${indexLabel}`; + } + if (rawSeries) { + return rawSeries; + } + if (indexLabel) { + return `Book ${indexLabel}`; + } + return ''; + }; + + const deriveBrowseMode = () => { + const title = (state.feedTitle || '').toLowerCase(); + if (!title) { + return 'generic'; + } + if (title.includes('author')) { + return 'author'; + } + if (title.includes('series')) { + return 'series'; + } + if (title.includes('title')) { + return 'title'; + } + if (title.includes('books')) { + return 'title'; + } + return 'generic'; + }; + + const stripLeadingArticle = (text) => text.replace(/^(?:the|a|an)\s+/i, '').trim(); + + const extractAlphabetSource = (entry) => { + if (!entry) { + return ''; + } + const mode = deriveBrowseMode(); + if (mode === 'author') { + if (Array.isArray(entry.authors) && entry.authors.length) { + return entry.authors[0] || ''; + } + } + if (mode === 'series' && entry.series) { + return entry.series; + } + if (entry.title) { + return entry.title; + } + if (entry.series) { + return entry.series; + } + const navLink = findNavigationLink(entry); + if (navLink?.title) { + return navLink.title; + } + return ''; + }; + + const deriveAlphabetKey = (entry) => { + const mode = deriveBrowseMode(); + let source = (extractAlphabetSource(entry) || '').trim(); + if (!source) { + return ''; + } + if (mode === 'author') { + if (source.includes(',')) { + source = source.split(',')[0]; + } else { + const parts = source.split(/\s+/); + if (parts.length > 1) { + source = parts[parts.length - 1]; + } + } + } else if (mode === 'title') { + source = stripLeadingArticle(source); + } + source = source.replace(/^[^\p{L}\p{N}]+/u, ''); + return source; + }; + + const deriveAlphabetLetter = (entry) => { + const key = deriveAlphabetKey(entry); + if (!key) { + return LETTER_NUMERIC; + } + const initial = key.charAt(0).toUpperCase(); + if (initial >= 'A' && initial <= 'Z') { + return initial; + } + return LETTER_NUMERIC; + }; + + const collectAlphabetCounts = (entries) => { + const counts = new Map(); + if (!Array.isArray(entries)) { + return counts; + } + entries.forEach((entry) => { + const letter = deriveAlphabetLetter(entry); + counts.set(letter, (counts.get(letter) || 0) + 1); + }); + return counts; + }; + + const detectEntryType = (entry, navigationLink) => { + if (!entry) { + return EntryTypes.OTHER; + } + const downloadLink = entry.download && entry.download.href ? entry.download.href : null; + if (downloadLink) { + return EntryTypes.BOOK; + } + const navLink = navigationLink === undefined ? findNavigationLink(entry) : navigationLink; + if (navLink && navLink.href) { + return EntryTypes.NAVIGATION; + } + return EntryTypes.OTHER; + }; + + const computeEntryStats = (entries) => { + const stats = { + [EntryTypes.BOOK]: 0, + [EntryTypes.NAVIGATION]: 0, + [EntryTypes.OTHER]: 0, + }; + if (!Array.isArray(entries)) { + return stats; + } + entries.forEach((entry) => { + const type = detectEntryType(entry); + stats[type] += 1; + }); + return stats; + }; + + const shouldShowAlphabetPicker = (entries, stats) => { + if (!alphaPickerEl || state.query) { + return false; + } + if (!Array.isArray(entries) || entries.length === 0) { + return false; + } + return true; + }; + + const refreshAlphabetActiveState = () => { + if (!alphaPickerEl) { + return; + } + const buttons = alphaPickerEl.querySelectorAll('button[data-letter]'); + buttons.forEach((button) => { + const value = button.dataset.letter || LETTER_ALL; + const isActive = value === state.activeLetter; + button.classList.toggle('is-active', isActive); + button.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + }); + }; + + const describeAlphabetLetter = (letter) => { + if (letter === LETTER_ALL) { + return 'all entries'; + } + if (letter === LETTER_NUMERIC) { + return 'numbers or symbols'; + } + return `letter ${letter}`; + }; + + const updateAlphabetPicker = (entries, { reset = false, stats = null } = {}) => { + if (!alphaPickerEl) { + return; + } + const list = Array.isArray(entries) ? entries : []; + if (reset) { + state.activeLetter = LETTER_ALL; + } + const counts = collectAlphabetCounts(list); + state.availableLetters = new Set(counts.keys()); + const shouldShow = shouldShowAlphabetPicker(list, stats); + if (!shouldShow) { + alphaPickerEl.innerHTML = ''; + alphaPickerEl.hidden = true; + state.activeLetter = LETTER_ALL; + return; + } + + alphaPickerEl.hidden = false; + alphaPickerEl.innerHTML = ''; + const letters = [LETTER_ALL, ...ALPHABET, LETTER_NUMERIC]; + letters.forEach((letter) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'opds-alpha-picker__button'; + button.dataset.letter = letter; + button.textContent = letter === LETTER_ALL ? 'All' : letter === LETTER_NUMERIC ? '# / 0-9' : letter; + const enabledCount = letter === LETTER_ALL ? list.length : counts.get(letter) || 0; + button.title = `Show entries for ${describeAlphabetLetter(letter)} (${enabledCount} in view)`; + button.addEventListener('click', () => { + handleAlphabetSelect(letter).catch((error) => { + console.error('Alphabet picker failed', error); + }); + }); + alphaPickerEl.appendChild(button); + }); + refreshAlphabetActiveState(); + }; + + const handleAlphabetSelect = async (letter) => { + const normalized = letter || LETTER_ALL; + if (normalized === LETTER_ALL) { + state.activeLetter = LETTER_ALL; + refreshAlphabetActiveState(); + const startLink = resolveRelLink(state.currentLinks, 'start') || resolveRelLink(state.currentLinks, '/start'); + const upLink = resolveRelLink(state.currentLinks, 'up') || resolveRelLink(state.currentLinks, '/up'); + const baseHref = startLink?.href || state.alphabetBaseHref || upLink?.href || state.currentHref || ''; + await loadFeed({ href: baseHref, query: '', letter: '', activeTab: baseHref ? TabIds.CUSTOM : TabIds.ROOT, updateTabs: true }); + return; + } + + state.activeLetter = normalized; + refreshAlphabetActiveState(); + const startLink = resolveRelLink(state.currentLinks, 'start') || resolveRelLink(state.currentLinks, '/start'); + const baseHref = startLink?.href || state.alphabetBaseHref || state.currentHref || ''; + await loadFeed({ href: baseHref, query: '', letter: normalized, activeTab: TabIds.CUSTOM, updateTabs: true }); + }; + + const applyAlphabetFilter = (entries) => { + if (!Array.isArray(entries) || !entries.length) { + return []; + } + if (state.activeLetter === LETTER_ALL) { + return entries.slice(); + } + return entries.filter((entry) => { + const letter = deriveAlphabetLetter(entry); + if (state.activeLetter === LETTER_NUMERIC) { + return letter === LETTER_NUMERIC; + } + return letter === state.activeLetter; + }); + }; + + const setEntries = (entries, { resetAlphabet = false, activeLetter = null } = {}) => { + const list = Array.isArray(entries) ? entries.slice() : []; + state.lastEntries = list; + const totalStats = computeEntryStats(list); + state.totalStats = totalStats; + if (resetAlphabet) { + state.activeLetter = LETTER_ALL; + } else if (activeLetter && activeLetter !== LETTER_ALL) { + state.activeLetter = activeLetter; + } + const filtered = applyAlphabetFilter(list); + const filteredStats = renderEntries(filtered); + state.filteredStats = filteredStats; + updateAlphabetPicker(list, { reset: resetAlphabet, stats: totalStats }); + return { stats: totalStats, filteredStats }; + }; + + const setStatus = (message, level, { persist = false } = {}) => { + if (!statusEl) { + return; + } + statusEl.textContent = message || ''; + if (level) { + statusEl.dataset.state = level; + } else { + delete statusEl.dataset.state; + } + state.status = { message: message || '', level: level || null }; + if (persist) { + state.baseStatus = { ...state.status }; + } + }; + + const clearStatus = () => setStatus('', null); + + const restoreBaseStatus = () => { + if (state.baseStatus) { + setStatus(state.baseStatus.message, state.baseStatus.level); + } else { + clearStatus(); + } + }; + + const focusSearch = () => { + if (!searchInput) { + return; + } + window.requestAnimationFrame(() => { + try { + searchInput.focus({ preventScroll: true }); + } catch (error) { + // Ignore focus issues + } + }); + }; + + const resolveRelLink = (links, rel) => { + if (!links) { + return null; + } + if (links[rel]) { + return links[rel]; + } + const key = Object.keys(links).find((entry) => entry === rel || entry.endsWith(rel)); + return key ? links[key] : null; + }; + + const findNavigationLink = (entry) => { + if (!entry || !Array.isArray(entry.links)) { + return null; + } + const candidates = entry.links.filter((link) => link && link.href); + return ( + candidates.find((link) => { + const rel = (link.rel || '').toLowerCase(); + const type = (link.type || '').toLowerCase(); + if (!link.href) { + return false; + } + if (rel.includes('acquisition')) { + return false; + } + if (rel === 'self') { + return false; + } + if (type.includes('opds-catalog')) { + return true; + } + if (rel.includes('subsection') || rel.includes('collection')) { + return true; + } + if (rel.startsWith('http://opds-spec.org/sort') || rel.startsWith('http://opds-spec.org/group')) { + return true; + } + return false; + }) || null + ); + }; + + const resolveTabIdForHref = (href) => { + if (!href) { + return TabIds.ROOT; + } + const matching = state.tabs.find((tab) => tab.href === href); + return matching ? matching.id : null; + }; + + const buildTabsFromFeed = (feed) => { + if (!feed || !Array.isArray(feed.entries)) { + return; + } + const seen = new Set(); + const nextTabs = []; + feed.entries.forEach((entry) => { + const navLink = findNavigationLink(entry); + if (!navLink || !navLink.href) { + return; + } + if (seen.has(navLink.href)) { + return; + } + seen.add(navLink.href); + const label = entry.title || navLink.title || 'Catalog view'; + nextTabs.push({ + id: navLink.href, + label, + href: navLink.href, + }); + }); + state.tabs = nextTabs; + state.tabsReady = true; + renderTabs(); + }; + + const renderTabs = () => { + if (!tabsEl) { + return; + } + tabsEl.innerHTML = ''; + const tabs = []; + tabs.push({ id: TabIds.ROOT, label: 'Catalog home', href: '' }); + state.tabs.forEach((tab) => tabs.push(tab)); + if (state.activeTab === TabIds.SEARCH && state.query) { + tabs.push({ + id: TabIds.SEARCH, + label: `Search: "${truncate(state.query, 32)}"`, + href: '', + isSearch: true, + }); + } + tabs.forEach((tab) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'opds-tab'; + if (tab.isSearch) { + button.classList.add('opds-tab--search'); + } + if (state.activeTab === tab.id || (tab.id !== TabIds.SEARCH && state.activeTab === tab.href)) { + button.classList.add('is-active'); + } + button.textContent = tab.label; + button.addEventListener('click', () => { + if (tab.id === TabIds.SEARCH) { + loadFeed({ href: '', query: state.query, activeTab: TabIds.SEARCH }); + return; + } + if (tab.id === TabIds.ROOT) { + loadFeed({ href: '', query: '', activeTab: TabIds.ROOT, updateTabs: true }); + return; + } + loadFeed({ href: tab.href, query: '', activeTab: tab.id }); + }); + tabsEl.appendChild(button); + }); + tabsEl.classList.toggle('is-empty', tabs.length <= 1); + }; + + const renderNav = (links) => { + const targets = [navEl, navBottomEl].filter(Boolean); + if (!targets.length) { + return; + } + targets.forEach((el) => { + el.innerHTML = ''; + }); + + const descriptors = [ + { key: 'up', label: 'Up one level' }, + { key: 'previous', label: 'Previous page' }, + { key: 'next', label: 'Next page' }, + ]; + descriptors.forEach(({ key, label }) => { + if (state.activeLetter !== LETTER_ALL && key !== 'up') { + return; + } + const link = resolveRelLink(links, key) || resolveRelLink(links, `/${key}`); + const hasLink = Boolean(link && link.href); + + if (!hasLink && key !== 'previous') { + return; + } + + targets.forEach((targetEl) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'button button--ghost'; + button.textContent = label; + + if (hasLink) { + button.addEventListener('click', () => { + const targetQuery = key === 'up' ? '' : state.query; + const tabId = resolveTabIdForHref(link.href); + loadFeed({ href: link.href, query: targetQuery, activeTab: tabId || (targetQuery ? TabIds.SEARCH : TabIds.CUSTOM) }); + }); + } else if (key === 'previous') { + button.disabled = true; + button.setAttribute('aria-disabled', 'true'); + } + targetEl.appendChild(button); + }); + }); + + targets.forEach((el) => { + el.hidden = !el.childElementCount; + }); + }; + + const createEntry = (entry) => { + const item = document.createElement('li'); + item.className = 'opds-browser__entry'; + + const header = document.createElement('div'); + header.className = 'opds-browser__entry-head'; + + const title = document.createElement('h3'); + title.className = 'opds-browser__title'; + const positionLabel = Number.isFinite(entry?.position) ? Number(entry.position) : null; + const baseTitle = entry.title || 'Untitled'; + title.textContent = positionLabel !== null ? `${positionLabel}. ${baseTitle}` : baseTitle; + header.appendChild(title); + + const authors = formatAuthors(entry.authors); + if (authors) { + const meta = document.createElement('p'); + meta.className = 'opds-browser__meta'; + meta.textContent = authors; + header.appendChild(meta); + } + + const seriesMetaText = formatSeriesLabel(entry); + if (seriesMetaText) { + const seriesMeta = document.createElement('p'); + seriesMeta.className = 'opds-browser__meta'; + seriesMeta.textContent = seriesMetaText; + header.appendChild(seriesMeta); + } + + if (entry.rating !== null && entry.rating !== undefined && entry.rating !== '') { + const ratingMeta = document.createElement('p'); + ratingMeta.className = 'opds-browser__meta'; + const ratingMax = entry.rating_max ?? entry.ratingMax ?? 5; + ratingMeta.textContent = `Rating: ${entry.rating}${ratingMax ? ` / ${ratingMax}` : ''}`; + header.appendChild(ratingMeta); + } + + if (Array.isArray(entry.tags) && entry.tags.length > 0) { + const tagsMeta = document.createElement('p'); + tagsMeta.className = 'opds-browser__meta'; + tagsMeta.textContent = `Tags: ${entry.tags.join(', ')}`; + header.appendChild(tagsMeta); + } + + item.appendChild(header); + + const summarySource = entry.summary || entry?.alternate?.title || entry?.download?.title || ''; + if (summarySource) { + const summary = document.createElement('p'); + summary.className = 'opds-browser__summary'; + summary.textContent = truncate(summarySource, 420); + item.appendChild(summary); + } + + const actions = document.createElement('div'); + actions.className = 'opds-browser__actions'; + + const downloadLink = entry.download && entry.download.href ? entry.download.href : null; + const alternateLink = entry.alternate && entry.alternate.href ? entry.alternate.href : null; + const navigationLink = findNavigationLink(entry); + const entryType = detectEntryType(entry, navigationLink); + + if (entryType === EntryTypes.NAVIGATION && navigationLink) { + item.classList.add('opds-browser__entry--navigation'); + } + + if (entryType === EntryTypes.BOOK) { + const queueButton = document.createElement('button'); + queueButton.type = 'button'; + queueButton.className = 'button'; + queueButton.textContent = 'Configure conversion'; + queueButton.addEventListener('click', () => importEntry(entry, queueButton)); + actions.appendChild(queueButton); + } else if (entryType === EntryTypes.NAVIGATION && navigationLink) { + const browseButton = document.createElement('button'); + browseButton.type = 'button'; + browseButton.className = 'button button--ghost'; + browseButton.textContent = 'Browse view'; + browseButton.addEventListener('click', () => { + clearStatus(); + const tabId = resolveTabIdForHref(navigationLink.href); + loadFeed({ href: navigationLink.href, query: '', activeTab: tabId || TabIds.CUSTOM }); + }); + actions.appendChild(browseButton); + } + + if (alternateLink && entryType !== EntryTypes.NAVIGATION) { + const previewLink = document.createElement('a'); + previewLink.className = 'button button--ghost'; + previewLink.href = alternateLink; + previewLink.target = '_blank'; + previewLink.rel = 'noreferrer'; + previewLink.textContent = 'Open in Calibre'; + actions.appendChild(previewLink); + } + + if (!actions.childElementCount) { + const fallback = document.createElement('span'); + fallback.className = 'opds-browser__hint'; + fallback.textContent = 'No downloadable formats exposed.'; + actions.appendChild(fallback); + } + + item.appendChild(actions); + return { element: item, type: entryType }; + }; + + const renderEntries = (entries) => { + if (!resultsEl) { + return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 }; + } + resultsEl.innerHTML = ''; + const list = Array.isArray(entries) ? entries : []; + if (!list.length) { + const empty = document.createElement('li'); + empty.className = 'opds-browser__empty'; + if (state.activeLetter !== LETTER_ALL) { + empty.textContent = `No entries start with ${describeAlphabetLetter(state.activeLetter)}.`; + } else if (state.query) { + empty.textContent = 'No results returned for this view yet.'; + } else { + empty.textContent = 'No catalog entries found here yet.'; + } + resultsEl.appendChild(empty); + return { [EntryTypes.BOOK]: 0, [EntryTypes.NAVIGATION]: 0, [EntryTypes.OTHER]: 0 }; + } + const fragment = document.createDocumentFragment(); + const stats = { + [EntryTypes.BOOK]: 0, + [EntryTypes.NAVIGATION]: 0, + [EntryTypes.OTHER]: 0, + }; + list.forEach((entry) => { + const { element, type } = createEntry(entry); + stats[type] += 1; + fragment.appendChild(element); + }); + resultsEl.appendChild(fragment); + return stats; + }; + + const importEntry = async (entry, trigger) => { + if (!entry?.download?.href) { + setStatus('This entry cannot be imported automatically.', 'error'); + return; + } + const button = trigger; + const originalLabel = button ? button.textContent : ''; + if (button) { + button.disabled = true; + button.dataset.loading = 'true'; + button.textContent = 'Preparing…'; + } + setStatus('Downloading book from Calibre. This can take a minute…', 'loading'); + try { + const requestPayload = { + href: entry.download.href, + title: entry.title || '', + }; + const metadata = {}; + if (entry.series) { + metadata.series = entry.series; + metadata.series_name = entry.series; + } + const seriesIndex = entry.series_index ?? entry.seriesIndex ?? null; + if (seriesIndex !== null && seriesIndex !== undefined && seriesIndex !== '') { + metadata.series_index = seriesIndex; + metadata.series_position = seriesIndex; + metadata.book_number = seriesIndex; + } + if (Array.isArray(entry.tags) && entry.tags.length > 0) { + const tagsText = entry.tags.join(', '); + metadata.tags = tagsText; + metadata.keywords = tagsText; + metadata.genre = tagsText; + } + if (typeof entry.summary === 'string' && entry.summary.trim()) { + metadata.description = entry.summary; + metadata.summary = entry.summary; + } + + if (Array.isArray(entry.authors) && entry.authors.length > 0) { + const authorsText = entry.authors.map((name) => String(name || '').trim()).filter(Boolean).join(', '); + if (authorsText) { + metadata.authors = authorsText; + metadata.author = authorsText; + } + } + + if (typeof entry.subtitle === 'string' && entry.subtitle.trim()) { + metadata.subtitle = entry.subtitle.trim(); + } + if (entry.rating !== null && entry.rating !== undefined && entry.rating !== '') { + metadata.rating = String(entry.rating); + } + if (entry.rating_max !== null && entry.rating_max !== undefined && entry.rating_max !== '') { + metadata.rating_max = String(entry.rating_max); + } + if (entry.published) { + metadata.published = entry.published; + metadata.publication_date = entry.published; + try { + const publishedDate = new Date(entry.published); + if (!Number.isNaN(publishedDate.getTime())) { + const year = String(publishedDate.getUTCFullYear()); + metadata.publication_year = year; + metadata.year = year; + } + } catch (error) { + // Ignore invalid date parsing issues + } + } + if (Object.keys(metadata).length > 0) { + requestPayload.metadata = metadata; + } + const response = await fetch('/api/integrations/calibre-opds/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestPayload), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Unable to queue this book.'); + } + setStatus('Preparing the conversion wizard…', 'success'); + closeModal(); + const redirectUrl = payload.redirect_url || ''; + if (redirectUrl) { + const wizard = window.AbogenWizard; + if (wizard?.requestStep) { + try { + const target = new URL(redirectUrl, window.location.origin); + if (payload.pending_id && !target.searchParams.has('pending_id')) { + target.searchParams.set('pending_id', payload.pending_id); + } + target.searchParams.set('format', 'json'); + if (!target.searchParams.has('step')) { + target.searchParams.set('step', 'book'); + } + await wizard.requestStep(target.toString(), { method: 'GET' }); + } catch (wizardError) { + console.error('Unable to open wizard via JSON payload', wizardError); + window.location.assign(redirectUrl); + } + } else { + window.location.assign(redirectUrl); + } + } + + } catch (error) { + setStatus(error instanceof Error ? error.message : 'Unable to queue this book.', 'error'); + } finally { + if (button) { + button.disabled = false; + delete button.dataset.loading; + if (originalLabel) { + button.textContent = originalLabel; + } + } + } + }; + + const loadFeed = async ({ href = '', query = '', letter = '', activeTab = null, updateTabs = false } = {}) => { + const params = new URLSearchParams(); + const normalizedHref = href || ''; + const normalizedQuery = (query || '').trim(); + let normalizedLetter = (letter || '').trim(); + if (normalizedLetter === LETTER_ALL) { + normalizedLetter = ''; + } + if (normalizedQuery) { + normalizedLetter = ''; + } + if (normalizedLetter && normalizedLetter !== LETTER_NUMERIC) { + normalizedLetter = normalizedLetter.toUpperCase(); + } + if (normalizedHref) { + params.set('href', normalizedHref); + } + if (normalizedQuery) { + params.set('q', normalizedQuery); + } + if (!normalizedQuery && normalizedLetter) { + params.set('letter', normalizedLetter); + } + + const requestId = ++state.requestToken; + setStatus('Loading catalog…', 'loading'); + + try { + const url = `/api/integrations/calibre-opds/feed${params.toString() ? `?${params.toString()}` : ''}`; + const response = await fetch(url); + const payload = await response.json(); + if (requestId !== state.requestToken) { + return; + } + if (!response.ok) { + throw new Error(payload.error || 'Unable to load the Calibre catalog.'); + } + const feed = payload.feed || {}; + state.feedTitle = feed.title || ''; + state.currentHref = normalizedHref; + state.currentLinks = feed.links || {}; + const selfLink = resolveRelLink(state.currentLinks, 'self'); + if (selfLink?.href) { + state.currentHref = selfLink.href; + } + state.query = normalizedLetter ? '' : normalizedQuery; + if (!normalizedLetter) { + const startLink = resolveRelLink(state.currentLinks, 'start') || resolveRelLink(state.currentLinks, '/start'); + if (startLink?.href) { + state.alphabetBaseHref = startLink.href; + } else if (state.currentHref) { + state.alphabetBaseHref = state.currentHref; + } + } + if (typeof activeTab === 'string') { + state.activeTab = activeTab; + } else if (normalizedQuery) { + state.activeTab = TabIds.SEARCH; + } else if (normalizedLetter) { + state.activeTab = TabIds.CUSTOM; + } else if (normalizedHref) { + state.activeTab = resolveTabIdForHref(normalizedHref) || TabIds.CUSTOM; + } else { + state.activeTab = TabIds.ROOT; + } + + if (searchInput) { + searchInput.value = state.query || ''; + } + + if (updateTabs || !state.tabsReady) { + buildTabsFromFeed(feed); + } else { + renderTabs(); + } + + renderNav(feed.links); + const { stats } = setEntries(feed.entries || [], { + resetAlphabet: !normalizedLetter, + activeLetter: normalizedLetter || null, + }); + const books = stats?.[EntryTypes.BOOK] || 0; + const views = stats?.[EntryTypes.NAVIGATION] || 0; + + if (normalizedLetter) { + const letterDescription = describeAlphabetLetter(normalizedLetter); + if (books && views) { + setStatus( + `Showing ${books} book${books === 1 ? '' : 's'} and ${views} catalog view${views === 1 ? '' : 's'} for ${letterDescription}.`, + 'success', + { persist: true }, + ); + } else if (books) { + setStatus(`Found ${books} book${books === 1 ? '' : 's'} for ${letterDescription}.`, 'success', { persist: true }); + } else if (views) { + setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} for ${letterDescription}.`, 'info', { persist: true }); + } else { + setStatus(`No catalog entries found for ${letterDescription}.`, 'info', { persist: true }); + } + return; + } + + if (normalizedQuery) { + if (books) { + setStatus(`Found ${books} book${books === 1 ? '' : 's'} for "${normalizedQuery}".`, 'success', { persist: true }); + } else if (views) { + setStatus( + `Browse ${views} catalog view${views === 1 ? '' : 's'} related to "${normalizedQuery}".`, + 'info', + { persist: true }, + ); + } else { + setStatus(`No results for "${normalizedQuery}".`, 'error', { persist: true }); + } + return; + } + + if (books && views) { + setStatus(`Showing ${books} book${books === 1 ? '' : 's'} and ${views} catalog view${views === 1 ? '' : 's'}.`, 'success', { persist: true }); + } else if (books) { + setStatus(`Found ${books} book${books === 1 ? '' : 's'} in this view.`, 'success', { persist: true }); + } else if (views) { + setStatus(`Browse ${views} catalog view${views === 1 ? '' : 's'} to drill deeper.`, 'info', { persist: true }); + } else { + setStatus('No catalog entries found here yet.', 'info', { persist: true }); + } + } catch (error) { + if (requestId !== state.requestToken) { + return; + } + setStatus(error instanceof Error ? error.message : 'Unable to load the Calibre catalog.', 'error', { persist: true }); + setEntries([], { resetAlphabet: true }); + if (navEl) { + navEl.innerHTML = ''; + } + state.currentLinks = {}; + } + }; + + const openModal = (trigger) => { + if (isOpen) { + focusSearch(); + return; + } + isOpen = true; + lastTrigger = trigger || null; + modal.hidden = false; + modal.dataset.open = 'true'; + document.body.classList.add('modal-open'); + focusSearch(); + loadFeed({ href: state.currentHref || '', query: state.query || '', activeTab: state.activeTab || TabIds.ROOT, updateTabs: !state.tabsReady }); + }; + + const closeModal = () => { + if (!isOpen) { + return; + } + isOpen = false; + modal.hidden = true; + delete modal.dataset.open; + document.body.classList.remove('modal-open'); + if (lastTrigger instanceof HTMLElement) { + lastTrigger.focus({ preventScroll: true }); + } + }; + + const handleKeydown = (event) => { + if (event.key === 'Escape' && isOpen) { + event.preventDefault(); + closeModal(); + } + }; + + document.addEventListener('keydown', handleKeydown); + + openButtons.forEach((button) => { + button.addEventListener('click', (event) => { + event.preventDefault(); + openModal(button); + }); + }); + + closeTargets.forEach((target) => { + target.addEventListener('click', (event) => { + event.preventDefault(); + closeModal(); + }); + }); + + modal.addEventListener('click', (event) => { + if (event.target === modal) { + closeModal(); + } + }); + + if (searchForm && searchInput) { + searchForm.addEventListener('submit', (event) => { + event.preventDefault(); + const query = searchInput.value.trim(); + if (!query) { + loadFeed({ href: '', query: '', activeTab: TabIds.ROOT, updateTabs: true }); + } else { + loadFeed({ href: '', query, activeTab: TabIds.SEARCH }); + } + }); + } + + if (refreshButton && searchInput) { + refreshButton.addEventListener('click', () => { + searchInput.value = ''; + loadFeed({ href: '', query: '', activeTab: TabIds.ROOT, updateTabs: true }); + }); + } +} diff --git a/abogen/webui/static/prepare.js b/abogen/webui/static/prepare.js new file mode 100644 index 0000000..e01efc5 --- /dev/null +++ b/abogen/webui/static/prepare.js @@ -0,0 +1,2863 @@ +const prepareState = (window.AbogenPrepareState = window.AbogenPrepareState || { + modalEventsBound: false, +}); + +const initPrepare = (root = document) => { + const rootEl = root instanceof HTMLElement ? root : document; + const form = rootEl.querySelector(".prepare-form") || document.querySelector(".prepare-form"); + if (!form) return; + if (form.dataset.prepareInitialized === "true") { + return; + } + form.dataset.prepareInitialized = "true"; + + const wizardModal = document.querySelector('[data-role="wizard-modal"]'); + const uploadModal = + document.querySelector('[data-role="new-job-modal"]') || + document.querySelector('[data-role="upload-modal"]'); + const openUploadTriggers = Array.from(document.querySelectorAll('[data-role="open-upload-modal"]')); + + const showWizardModal = () => { + if (!wizardModal) return; + wizardModal.hidden = false; + wizardModal.dataset.open = "true"; + wizardModal.removeAttribute("aria-hidden"); + document.body.classList.add("modal-open"); + }; + + const hideWizardModal = () => { + if (!wizardModal) return; + wizardModal.hidden = true; + delete wizardModal.dataset.open; + wizardModal.setAttribute("aria-hidden", "true"); + }; + + const triggerUploadModal = () => { + const existingTrigger = openUploadTriggers.find((button) => button !== null); + if (existingTrigger) { + existingTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + return; + } + if (!uploadModal) return; + uploadModal.hidden = false; + uploadModal.dataset.open = "true"; + document.body.classList.add("modal-open"); + const focusTarget = uploadModal.querySelector("#source_file") || uploadModal.querySelector("#source_text") || uploadModal; + if (focusTarget instanceof HTMLElement) { + focusTarget.focus({ preventScroll: true }); + } + }; + + showWizardModal(); + + if (!prepareState.modalEventsBound) { + prepareState.modalEventsBound = true; + document.addEventListener("upload-modal:open", hideWizardModal); + document.addEventListener("upload-modal:close", showWizardModal); + } + + const parseJSONScript = (id) => { + const el = document.getElementById(id); + if (!el) return null; + try { + const content = el.textContent || ""; + return content ? JSON.parse(content) : null; + } catch (error) { + console.warn(`Failed to parse JSON script for ${id}`, error); + return null; + } + }; + + const voiceCatalog = parseJSONScript("voice-catalog-data") || []; + const languageMap = parseJSONScript("voice-language-map") || {}; + const voiceCatalogMap = new Map(voiceCatalog.map((voice) => [voice.id, voice])); + + const sampleIndexState = new WeakMap(); + const speakerHints = new Map(); + + const canonicalizeEntityKey = (value) => (value || "").toLowerCase().replace(/\s+/g, " ").trim(); + + const readSpeakerSamples = (speakerItem) => { + if (!speakerItem) return []; + const template = speakerItem.querySelector('template[data-role="speaker-samples"]'); + if (!template) return []; + let parsed = []; + try { + const raw = template.innerHTML || "[]"; + const data = JSON.parse(raw); + if (Array.isArray(data)) { + parsed = data; + } + } catch (error) { + console.warn("Unable to parse speaker samples", error); + return []; + } + + const seen = new Set(); + const normalised = []; + for (const entry of parsed) { + let excerpt = ""; + let genderHint = ""; + if (typeof entry === "string") { + excerpt = entry; + } else if (entry && typeof entry === "object") { + excerpt = String(entry.excerpt || ""); + genderHint = typeof entry.gender_hint === "string" ? entry.gender_hint : ""; + } + const key = excerpt.trim(); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + normalised.push({ excerpt: key, genderHint }); + } + return normalised; + }; + + const registerSpeakerHintFromNode = (node) => { + if (!node) return; + const nameNode = node.querySelector(".speaker-list__name"); + const label = nameNode?.textContent || ""; + const key = canonicalizeEntityKey(label); + if (!key) return; + const genderInput = node.querySelector('[data-role="gender-input"]'); + const voiceSelect = node.querySelector('[data-role="speaker-voice"]'); + const formulaInput = node.querySelector('[data-role="speaker-formula"]'); + let resolvedVoice = ""; + if (voiceSelect) { + const selectedValue = voiceSelect.value || voiceSelect.dataset.prevManual || ""; + if (selectedValue && selectedValue !== "__custom_mix") { + resolvedVoice = selectedValue; + } else if (voiceSelect.dataset.defaultVoice) { + resolvedVoice = voiceSelect.dataset.defaultVoice; + } else if (form.dataset.baseVoice) { + resolvedVoice = form.dataset.baseVoice; + } + } else if (form.dataset.baseVoice) { + resolvedVoice = form.dataset.baseVoice; + } + if (!resolvedVoice && formulaInput?.value?.trim()) { + const formula = formulaInput.value.trim(); + const firstTerm = formula.split("+")[0] || ""; + const [voiceId] = firstTerm.split("*"); + if (voiceId) { + resolvedVoice = voiceId.trim(); + } + } + speakerHints.set(key, { + gender: (genderInput?.value || "unknown").toLowerCase(), + voice: resolvedVoice, + }); + }; + + const rebuildSpeakerHints = () => { + speakerHints.clear(); + form.querySelectorAll(".speaker-list__item").forEach((item) => registerSpeakerHintFromNode(item)); + }; + + const getPronunciationText = (container) => { + if (!container) return ""; + const input = container.querySelector('[data-role="speaker-pronunciation"]'); + const raw = input?.value?.trim(); + if (raw) { + return raw; + } + return (container.dataset.defaultPronunciation || "").trim(); + }; + + const syncPronunciationPreview = (container) => { + if (!container) return; + const text = getPronunciationText(container); + const previewButtons = container.querySelectorAll('[data-role="speaker-preview"][data-preview-source]'); + previewButtons.forEach((button) => { + const source = button.dataset.previewSource; + if (["pronunciation", "generated", "mix"].includes(source)) { + button.dataset.previewText = text; + } + }); + }; + + const setSpeakerSample = (speakerItem, index) => { + if (!speakerItem) return; + const samples = readSpeakerSamples(speakerItem); + if (!samples.length) return; + const maxIndex = samples.length; + const normalisedIndex = ((index % maxIndex) + maxIndex) % maxIndex; + sampleIndexState.set(speakerItem, normalisedIndex); + const sample = samples[normalisedIndex]; + const article = speakerItem.querySelector('[data-role="speaker-sample"]'); + if (!article) return; + const textNode = article.querySelector('[data-role="sample-text"]'); + const hintNode = article.querySelector('[data-role="sample-hint"]'); + if (textNode) { + textNode.textContent = sample.excerpt; + } + if (hintNode) { + if (sample.genderHint) { + hintNode.hidden = false; + hintNode.textContent = sample.genderHint; + } else { + hintNode.hidden = true; + hintNode.textContent = ""; + } + } + const previewButton = article.querySelector('[data-role="speaker-preview"][data-preview-source="sample"]'); + if (previewButton) { + previewButton.dataset.previewText = sample.excerpt; + } + const voiceBrowserButton = article.querySelector('[data-role="open-voice-browser"]'); + if (voiceBrowserButton) { + voiceBrowserButton.dataset.sampleIndex = String(normalisedIndex); + } + }; + + const initialiseSpeakerItem = (speakerItem) => { + syncPronunciationPreview(speakerItem); + const samples = readSpeakerSamples(speakerItem); + if (samples.length) { + setSpeakerSample(speakerItem, 0); + const nextButton = speakerItem.querySelector('[data-role="speaker-next-sample"]'); + if (nextButton) { + nextButton.disabled = samples.length <= 1; + } + } + }; + + const formatCustomMixLabel = (formula) => { + if (!formula) return "Custom mix"; + const segments = formula + .split("+") + .map((segment) => segment.trim()) + .filter((segment) => segment.length); + if (!segments.length) { + return "Custom mix"; + } + const parts = segments.map((segment) => { + const [voiceIdRaw, weightRaw] = segment.split("*").map((token) => token.trim()); + const voiceId = voiceIdRaw || ""; + const voiceMeta = voiceCatalogMap.get(voiceId); + const displayName = voiceMeta?.display_name || voiceId || "Voice"; + const weight = Number.parseFloat(weightRaw || ""); + if (!Number.isNaN(weight)) { + return `${displayName} ${(weight * 100).toFixed(0)}%`; + } + return displayName; + }); + return parts.join(" + "); + }; + + const ensureCustomMixOption = (select) => { + if (!select) return null; + let option = select.querySelector('option[data-role="custom-mix-option"]'); + if (!option) { + option = document.createElement("option"); + option.value = "__custom_mix"; + option.dataset.role = "custom-mix-option"; + option.hidden = true; + option.disabled = true; + option.textContent = "Custom mix"; + const firstOptGroup = select.querySelector("optgroup"); + if (firstOptGroup) { + select.insertBefore(option, firstOptGroup); + } else { + select.appendChild(option); + } + } + return option; + }; + + const updateCustomMixOption = (select, formula) => { + const option = ensureCustomMixOption(select); + if (!option) return; + if (formula) { + option.hidden = false; + option.disabled = false; + option.textContent = formatCustomMixLabel(formula); + } else { + option.hidden = true; + option.disabled = true; + option.textContent = "Custom mix"; + } + }; + + const chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]")); + + const setRowExpansion = (row, expanded) => { + if (!row) return; + const details = row.querySelector('[data-role="chapter-details"]'); + const toggle = row.querySelector('[data-role="chapter-toggle"]'); + const isExpanded = Boolean(expanded); + row.dataset.expanded = isExpanded ? "true" : "false"; + if (details) { + details.hidden = !isExpanded; + details.setAttribute("aria-hidden", isExpanded ? "false" : "true"); + } + if (toggle) { + toggle.setAttribute("aria-expanded", isExpanded ? "true" : "false"); + toggle.setAttribute("aria-label", isExpanded ? "Hide chapter details" : "Show chapter details"); + } + }; + + const toggleRowExpansion = (row, force) => { + if (!row) return; + const current = row.dataset.expanded === "true"; + const next = typeof force === "boolean" ? force : !current; + setRowExpansion(row, next); + }; + + const isRowEnabled = (row) => { + const checkbox = row?.querySelector('[data-role="chapter-enabled"]'); + return checkbox ? checkbox.checked : true; + }; + + const updateRowState = (row) => { + const enabled = row.querySelector('[data-role=chapter-enabled]'); + const inputs = Array.from(row.querySelectorAll("input[type=text], select, textarea")); + const toggle = row.querySelector('[data-role="chapter-toggle"]'); + const isChecked = enabled ? enabled.checked : true; + row.dataset.disabled = isChecked ? "false" : "true"; + + inputs.forEach((input) => { + if (input === enabled) return; + input.disabled = !isChecked; + if (!isChecked) { + if (input.tagName === "SELECT") { + input.dataset.prevValue = input.value; + input.value = "__default"; + } + if (input.dataset.role === "formula-input") { + input.value = ""; + input.hidden = true; + input.setAttribute("aria-hidden", "true"); + } + } else if (input.tagName === "SELECT" && input.dataset.prevValue) { + input.value = input.dataset.prevValue; + } + }); + + const select = row.querySelector("select[data-role=voice-select]"); + toggleFormula(select); + + if (!isChecked) { + setRowExpansion(row, false); + } + + if (toggle) { + toggle.disabled = !isChecked; + toggle.setAttribute("aria-disabled", isChecked ? "false" : "true"); + } + + }; + + const toggleFormula = (select) => { + if (!select) return; + const container = select.closest("[data-role=chapter-row]"); + const formulaInput = container.querySelector('[data-role=formula-input]'); + const isFormula = select.value === "formula"; + formulaInput.hidden = !isFormula; + formulaInput.setAttribute("aria-hidden", isFormula ? "false" : "true"); + if (!isFormula) { + formulaInput.value = ""; + } + if (isFormula) { + formulaInput.required = true; + } else { + formulaInput.required = false; + } + }; + + chapterRows.forEach((row) => { + setRowExpansion(row, row.dataset.expanded === "true"); + const enabled = row.querySelector('[data-role=chapter-enabled]'); + if (enabled) { + enabled.addEventListener("change", () => updateRowState(row)); + updateRowState(row); + } + const select = row.querySelector("select[data-role=voice-select]"); + if (select) { + select.addEventListener("change", () => toggleFormula(select)); + toggleFormula(select); + } + const toggleButton = row.querySelector('[data-role="chapter-toggle"]'); + if (toggleButton) { + toggleButton.addEventListener("click", () => { + if (!isRowEnabled(row)) { + setRowExpansion(row, false); + return; + } + toggleRowExpansion(row); + }); + } + }); + + const updatePreviewVoice = (select) => { + const container = select.closest(".speaker-list__item"); + if (!container) return; + const previewButtons = container.querySelectorAll('[data-role="speaker-preview"]'); + if (!previewButtons.length) return; + + const formulaInput = container.querySelector('[data-role="speaker-formula"]'); + const mixContainer = container.querySelector('[data-role="speaker-mix"]'); + const mixLabel = container.querySelector('[data-role="speaker-mix-label"]'); + + const formulaValue = formulaInput?.value?.trim() || ""; + updateCustomMixOption(select, formulaValue); + + const defaultVoice = select.dataset.defaultVoice || ""; + let assignedVoice = select.value || defaultVoice; + if (select.value === "__custom_mix" || formulaValue) { + assignedVoice = formulaValue || defaultVoice; + } + + if (formulaValue) { + if (mixContainer) mixContainer.hidden = false; + if (mixLabel) mixLabel.textContent = formulaValue; + } else { + if (mixContainer) mixContainer.hidden = true; + if (mixLabel) mixLabel.textContent = ""; + if (assignedVoice === "__custom_mix" || !assignedVoice) { + assignedVoice = defaultVoice; + } + } + + previewButtons.forEach((button) => { + const kind = button.dataset.previewKind || ""; + if (kind === "generated") { + button.hidden = !formulaValue; + button.dataset.voice = assignedVoice; + return; + } + + const context = button.dataset.previewContext || ""; + if (context === "mix") { + button.dataset.voice = formulaValue || assignedVoice; + return; + } + + button.dataset.voice = assignedVoice || defaultVoice || button.dataset.voice || ""; + }); + }; + + const voiceSelects = Array.from(form.querySelectorAll('[data-role="speaker-voice"]')); + voiceSelects.forEach((select) => { + ensureCustomMixOption(select); + select.addEventListener("change", (event) => { + const target = event.target; + const container = target.closest(".speaker-list__item"); + if (!container) return; + const formulaInput = container.querySelector('[data-role="speaker-formula"]'); + const mixContainer = container.querySelector('[data-role="speaker-mix"]'); + const mixLabel = container.querySelector('[data-role="speaker-mix-label"]'); + + if (target.value === "__custom_mix") { + if (!formulaInput?.value?.trim()) { + const previous = target.dataset.prevManual || ""; + target.value = previous; + } + updatePreviewVoice(target); + registerSpeakerHintFromNode(container); + return; + } + + if (!target.dataset.suppressFormulaClear) { + if (formulaInput) { + formulaInput.value = ""; + } + if (mixLabel) { + mixLabel.textContent = ""; + } + if (mixContainer) { + mixContainer.hidden = true; + } + updateCustomMixOption(target, ""); + } + + target.dataset.prevManual = target.value || ""; + updatePreviewVoice(target); + delete target.dataset.suppressFormulaClear; + if (container) { + registerSpeakerHintFromNode(container); + } + }); + updatePreviewVoice(select); + }); + + const speakerItems = Array.from(form.querySelectorAll(".speaker-list__item")); + speakerItems.forEach((item) => { + initialiseSpeakerItem(item); + const pronunciationInput = item.querySelector('[data-role="speaker-pronunciation"]'); + if (pronunciationInput) { + const sync = () => syncPronunciationPreview(item); + pronunciationInput.addEventListener("input", sync); + pronunciationInput.addEventListener("change", sync); + } + registerSpeakerHintFromNode(item); + }); + rebuildSpeakerHints(); + + const activeStepInput = form.querySelector('[data-role="active-step-input"]'); + const analysisButtons = Array.from(form.querySelectorAll('[data-role="submit-speaker-analysis"]')); + analysisButtons.forEach((button) => { + button.addEventListener("click", () => { + if (activeStepInput) { + activeStepInput.value = "entities"; + } + }); + }); + + const voiceModal = document.querySelector('[data-role="voice-modal"]'); + let activeGenderFilter = ""; + + const clamp = (value, min, max) => Math.min(Math.max(value, min), max); + + const parseFormula = (formula) => { + const mix = new Map(); + if (!formula) return mix; + const parts = formula.split("+"); + parts.forEach((part) => { + const segment = part.trim(); + if (!segment) return; + const pieces = segment.split("*"); + const voiceId = pieces[0].trim(); + if (!voiceId) return; + let weight = 1; + if (pieces[1]) { + const parsed = Number.parseFloat(pieces[1].trim()); + if (!Number.isNaN(parsed) && parsed > 0) { + weight = parsed; + } + } + mix.set(voiceId, clamp(weight, 0.05, 1)); + }); + return mix; + }; + + const normaliseMix = (mix) => { + const entries = Array.from(mix.entries()); + const total = entries.reduce((sum, [, weight]) => sum + weight, 0); + if (!total) return mix; + entries.forEach(([voiceId, weight]) => { + mix.set(voiceId, weight / total); + }); + return mix; + }; + + const formatMix = (mix) => { + const entries = Array.from(mix.entries()); + if (!entries.length) return ""; + let total = entries.reduce((sum, [, weight]) => sum + weight, 0); + if (total < 0.5) { + const scale = 0.5 / total; + entries.forEach(([voiceId, weight]) => { + mix.set(voiceId, clamp(weight * scale, 0.05, 1)); + }); + total = entries.reduce((sum, [, weight]) => sum + weight, 0); + } + return entries + .map(([voiceId, weight]) => `${voiceId}*${(weight / total).toFixed(2)}`) + .join("+"); + }; + + const genderLabel = (value) => { + switch ((value || "unknown").toLowerCase()) { + case "male": + return "Male"; + case "female": + return "Female"; + case "either": + return "Either"; + default: + return "Unknown"; + } + }; + + const buildRandomMix = (gender, countOverride) => { + const genderCode = (gender || "unknown").toLowerCase(); + const pool = voiceCatalog.filter((voice) => { + const code = (voice.gender_code || "").toLowerCase(); + if (genderCode === "female") return code === "f"; + if (genderCode === "male") return code === "m"; + if (genderCode === "either") return code === "f" || code === "m"; + return true; + }); + if (!pool.length) { + return null; + } + const voices = [...pool]; + for (let i = voices.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [voices[i], voices[j]] = [voices[j], voices[i]]; + } + const count = clamp(countOverride || Math.floor(Math.random() * 4) + 1, 1, 4); + const selected = voices.slice(0, count); + const mix = new Map(); + const rawWeights = selected.map(() => Math.random() + 0.2); + const total = rawWeights.reduce((sum, weight) => sum + weight, 0); + selected.forEach((voice, index) => { + mix.set(voice.id, rawWeights[index] / total); + }); + return mix; + }; + + const applyFormulaToSpeaker = (speakerItem, formula) => { + if (!speakerItem) return; + const select = speakerItem.querySelector('[data-role="speaker-voice"]'); + const formulaInput = speakerItem.querySelector('[data-role="speaker-formula"]'); + const mixLabel = speakerItem.querySelector('[data-role="speaker-mix-label"]'); + const mixContainer = speakerItem.querySelector('[data-role="speaker-mix"]'); + + if (formulaInput) { + formulaInput.value = formula || ""; + } + if (mixLabel) { + mixLabel.textContent = formula || ""; + } + if (mixContainer) { + mixContainer.hidden = !formula; + } + + if (!select) { + return; + } + + ensureCustomMixOption(select); + + if (formula) { + if (select.value !== "__custom_mix") { + select.dataset.prevManual = select.value || select.dataset.prevManual || select.dataset.defaultVoice || ""; + } + select.dataset.suppressFormulaClear = "1"; + updateCustomMixOption(select, formula); + select.value = "__custom_mix"; + } else { + const fallback = select.dataset.prevManual || select.dataset.defaultVoice || ""; + select.dataset.suppressFormulaClear = "1"; + updateCustomMixOption(select, ""); + select.value = fallback; + } + + updatePreviewVoice(select); + delete select.dataset.suppressFormulaClear; + registerSpeakerHintFromNode(speakerItem); + }; + + const hideGenderMenus = () => { + form.querySelectorAll('[data-role="gender-menu"]').forEach((menu) => { + menu.hidden = true; + menu.setAttribute("aria-hidden", "true"); + }); + form.querySelectorAll('[data-role="gender-pill"]').forEach((pill) => { + pill.classList.remove("is-open"); + }); + }; + + const setGenderForSpeaker = (genderContainer, value) => { + if (!genderContainer) return; + const normalized = value || "unknown"; + const input = genderContainer.querySelector('[data-role="gender-input"]'); + if (input) { + input.value = normalized; + } + const pill = genderContainer.querySelector('[data-role="gender-pill"]'); + if (pill) { + pill.dataset.current = normalized; + pill.textContent = `${genderLabel(normalized)} voice`; + } + const options = genderContainer.querySelectorAll('[data-role="gender-option"]'); + options.forEach((option) => { + if ((option.dataset.value || "unknown") === normalized) { + option.dataset.state = "active"; + } else { + option.removeAttribute("data-state"); + } + }); + const speakerItem = genderContainer.closest(".speaker-list__item"); + registerSpeakerHintFromNode(speakerItem); + }; + + Array.from(form.querySelectorAll('[data-role="speaker-gender"]')).forEach((container) => { + const input = container.querySelector('[data-role="gender-input"]'); + setGenderForSpeaker(container, input?.value || "unknown"); + }); + + const modalState = { + speakerItem: null, + samples: [], + recommended: new Set(), + mix: new Map(), + highlighted: "", + defaultVoice: "", + previewSettings: { language: "a", speed: "1", useGpu: "true" }, + }; + + const resetModalState = () => { + modalState.speakerItem = null; + modalState.samples = []; + modalState.recommended = new Set(); + modalState.mix = new Map(); + modalState.highlighted = ""; + modalState.defaultVoice = ""; + modalState.previewSettings = { language: "a", speed: "1", useGpu: "true" }; + }; + + const getMixFormula = () => formatMix(normaliseMix(new Map(modalState.mix))); + + const renderVoiceList = (elements) => { + if (!elements) return; + const { list, searchInput, languageSelect } = elements; + if (!list) return; + list.innerHTML = ""; + const term = (searchInput?.value || "").trim().toLowerCase(); + const languageFilter = languageSelect?.value || ""; + const filtered = voiceCatalog + .filter((voice) => { + if (languageFilter && voice.language !== languageFilter) return false; + if (activeGenderFilter && voice.gender_code !== activeGenderFilter) return false; + if (term) { + const haystacks = [voice.display_name, voice.id, voice.language_label, languageMap[voice.language]] + .filter(Boolean) + .map((value) => value.toLowerCase()); + if (!haystacks.some((value) => value.includes(term))) { + return false; + } + } + return true; + }) + .sort((a, b) => { + const aRecommended = modalState.recommended.has(a.id) ? 0 : 1; + const bRecommended = modalState.recommended.has(b.id) ? 0 : 1; + if (aRecommended !== bRecommended) { + return aRecommended - bRecommended; + } + return a.display_name.localeCompare(b.display_name); + }); + + if (!filtered.length) { + const emptyItem = document.createElement("li"); + emptyItem.className = "voice-browser__empty"; + emptyItem.textContent = "No voices matched your filters."; + list.appendChild(emptyItem); + return; + } + + filtered.forEach((voice) => { + const item = document.createElement("li"); + const button = document.createElement("button"); + button.type = "button"; + button.className = "voice-browser__entry"; + button.dataset.role = "voice-modal-item"; + button.dataset.voiceId = voice.id; + if (modalState.mix.has(voice.id)) { + button.dataset.inMix = "true"; + } + if (modalState.highlighted === voice.id) { + button.setAttribute("aria-current", "true"); + } + if (modalState.recommended.has(voice.id)) { + button.dataset.recommended = "true"; + } + const nameSpan = document.createElement("span"); + nameSpan.className = "voice-browser__entry-name"; + nameSpan.textContent = voice.display_name; + const metaSpan = document.createElement("span"); + metaSpan.className = "voice-browser__entry-meta"; + metaSpan.textContent = `${voice.language_label} · ${voice.gender}`; + button.appendChild(nameSpan); + button.appendChild(metaSpan); + item.appendChild(button); + list.appendChild(item); + }); + }; + + const renderMix = (elements) => { + const { mixList, mixTotal } = elements; + if (!mixList) return; + mixList.innerHTML = ""; + const entries = Array.from(normaliseMix(new Map(modalState.mix)).entries()); + const total = entries.reduce((sum, [, weight]) => sum + weight, 0); + if (mixTotal) { + mixTotal.textContent = `Total weight: ${total.toFixed(2)}`; + } + if (!entries.length) { + const empty = document.createElement("p"); + empty.className = "voice-browser__empty"; + empty.textContent = "Add voices from the list to build a blend."; + mixList.appendChild(empty); + return; + } + entries.forEach(([voiceId, weight]) => { + const wrapper = document.createElement("div"); + wrapper.className = "voice-browser__mix-item"; + wrapper.dataset.voiceId = voiceId; + + const header = document.createElement("div"); + header.className = "voice-browser__mix-header"; + const voiceMeta = voiceCatalogMap.get(voiceId) || {}; + const title = document.createElement("span"); + title.className = "voice-browser__mix-name"; + title.textContent = voiceMeta.display_name || voiceId; + const weightLabel = document.createElement("span"); + weightLabel.className = "voice-browser__mix-weight"; + weightLabel.textContent = weight.toFixed(2); + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "voice-browser__mix-remove"; + removeBtn.setAttribute("aria-label", `Remove ${title.textContent} from blend`); + removeBtn.textContent = "✕"; + removeBtn.addEventListener("click", () => { + modalState.mix.delete(voiceId); + if (modalState.highlighted === voiceId) { + modalState.highlighted = ""; + } + renderMix(elements); + renderVoiceList(elements); + updateModalMeta(elements); + updateApplyState(elements); + }); + header.appendChild(title); + header.appendChild(weightLabel); + header.appendChild(removeBtn); + + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = "5"; + slider.max = "100"; + slider.step = "1"; + slider.value = String(Math.round(weight * 100)); + slider.addEventListener("input", () => { + const value = clamp(Number.parseInt(slider.value, 10) / 100, 0.05, 1); + modalState.mix.set(voiceId, value); + modalState.highlighted = voiceId; + renderMix(elements); + updateModalMeta(elements); + updateApplyState(elements); + }); + + wrapper.appendChild(header); + wrapper.appendChild(slider); + mixList.appendChild(wrapper); + }); + }; + + const renderSamples = (elements) => { + if (!elements) return; + const { samplesContainer } = elements; + if (!samplesContainer) return; + samplesContainer.innerHTML = ""; + + if (!modalState.samples.length) { + const empty = document.createElement("p"); + empty.className = "hint"; + empty.textContent = "No sample paragraphs available yet."; + samplesContainer.appendChild(empty); + return; + } + + const formula = getMixFormula(); + modalState.samples.forEach((text, index) => { + const sample = document.createElement("article"); + sample.className = "voice-browser__sample"; + sample.dataset.sampleIndex = String(index); + if (index === 0) { + sample.dataset.active = "true"; + } + + const paragraph = document.createElement("p"); + paragraph.textContent = text; + const actions = document.createElement("div"); + actions.className = "voice-browser__sample-actions"; + + const previewButton = document.createElement("button"); + previewButton.type = "button"; + previewButton.className = "button button--ghost button--small"; + previewButton.dataset.role = "speaker-preview"; + previewButton.dataset.previewText = text; + previewButton.dataset.language = modalState.previewSettings.language; + previewButton.dataset.speed = modalState.previewSettings.speed; + previewButton.dataset.useGpu = modalState.previewSettings.useGpu; + previewButton.dataset.voice = formula || modalState.defaultVoice || ""; + previewButton.textContent = "Preview sample"; + + actions.appendChild(previewButton); + sample.appendChild(paragraph); + sample.appendChild(actions); + samplesContainer.appendChild(sample); + }); + }; + + const updateModalMeta = (elements) => { + if (!elements) return; + const { nameLabel, metaLabel } = elements; + if (!nameLabel || !metaLabel) return; + if (!modalState.mix.size) { + nameLabel.textContent = "Select voices to build a blend"; + metaLabel.textContent = ""; + return; + } + const highlight = modalState.highlighted && modalState.mix.has(modalState.highlighted) + ? modalState.highlighted + : Array.from(modalState.mix.keys())[0]; + modalState.highlighted = highlight; + const voice = voiceCatalogMap.get(highlight); + if (!voice) { + nameLabel.textContent = highlight; + metaLabel.textContent = ""; + return; + } + nameLabel.textContent = voice.display_name; + metaLabel.textContent = `${voice.language_label} · ${voice.gender}`; + }; + + const updateApplyState = (elements) => { + const { applyButton } = elements || {}; + if (!applyButton) return; + const formula = getMixFormula(); + applyButton.disabled = !formula; + }; + + const refreshModal = (elements) => { + renderVoiceList(elements); + renderMix(elements); + renderSamples(elements); + updateModalMeta(elements); + updateApplyState(elements); + }; + + const openVoiceBrowser = (speakerItem, sampleIndex = 0) => { + if (!voiceModal) return; + modalState.speakerItem = speakerItem; + const select = speakerItem.querySelector('[data-role="speaker-voice"]'); + const previewTrigger = speakerItem.querySelector('[data-role="speaker-preview"][data-preview-source="pronunciation"]'); + const formulaInput = speakerItem.querySelector('[data-role="speaker-formula"]'); + modalState.defaultVoice = select?.dataset.defaultVoice || previewTrigger?.dataset.voice || ""; + modalState.mix = formulaInput?.value ? parseFormula(formulaInput.value) : new Map(); + if (!modalState.mix.size && select && select.value) { + modalState.mix.set(select.value, 1); + } + modalState.mix = normaliseMix(modalState.mix); + + modalState.previewSettings = { + language: previewTrigger?.dataset.language || "a", + speed: previewTrigger?.dataset.speed || "1", + useGpu: previewTrigger?.dataset.useGpu || "true", + }; + + const samples = readSpeakerSamples(speakerItem); + let excerpts = samples.map((sample) => sample.excerpt); + const storedIndex = sampleIndexState.get(speakerItem) || 0; + let effectiveIndex = Number.isFinite(sampleIndex) ? sampleIndex : 0; + if (!Number.isFinite(effectiveIndex) || effectiveIndex < 0 || effectiveIndex >= excerpts.length) { + effectiveIndex = storedIndex; + } + if (excerpts.length && effectiveIndex > 0 && effectiveIndex < excerpts.length) { + const [selected] = excerpts.splice(effectiveIndex, 1); + excerpts.unshift(selected); + } + if (!excerpts.length) { + const sampleButton = speakerItem.querySelector('[data-role="speaker-preview"][data-preview-source="sample"]'); + const previewText = sampleButton?.dataset.previewText?.trim(); + if (previewText) { + excerpts = [previewText]; + } + } + modalState.samples = Array.from(new Set(excerpts)); + modalState.recommended = new Set( + Array.from(speakerItem.querySelectorAll('[data-role="recommended-voice"]')).map((btn) => btn.dataset.voice).filter(Boolean) + ); + activeGenderFilter = ""; + + const elements = { + list: voiceModal.querySelector('[data-role="voice-modal-list"]'), + searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'), + languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'), + genderButtons: Array.from(voiceModal.querySelectorAll('[data-role="voice-modal-gender"]')), + mixList: voiceModal.querySelector('[data-role="voice-modal-mix-list"]'), + mixTotal: voiceModal.querySelector('[data-role="voice-modal-mix-total"]'), + samplesContainer: voiceModal.querySelector('[data-role="voice-modal-samples"]'), + applyButton: voiceModal.querySelector('[data-role="voice-modal-apply"]'), + nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'), + metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'), + }; + + if (elements.searchInput) elements.searchInput.value = ""; + if (elements.languageSelect) elements.languageSelect.value = ""; + elements.genderButtons.forEach((button) => { + button.setAttribute("aria-pressed", button.dataset.value === "" ? "true" : "false"); + }); + + refreshModal(elements); + + voiceModal.hidden = false; + voiceModal.dataset.open = "true"; + document.body.classList.add("modal-open"); + if (elements.searchInput) { + setTimeout(() => elements.searchInput.focus({ preventScroll: true }), 0); + } + }; + + const closeVoiceBrowser = () => { + if (!voiceModal || voiceModal.hidden) return; + voiceModal.hidden = true; + voiceModal.removeAttribute("data-open"); + document.body.classList.remove("modal-open"); + resetModalState(); + }; + + if (voiceModal) { + const elements = { + list: voiceModal.querySelector('[data-role="voice-modal-list"]'), + searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'), + languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'), + genderButtons: Array.from(voiceModal.querySelectorAll('[data-role="voice-modal-gender"]')), + mixList: voiceModal.querySelector('[data-role="voice-modal-mix-list"]'), + mixTotal: voiceModal.querySelector('[data-role="voice-modal-mix-total"]'), + samplesContainer: voiceModal.querySelector('[data-role="voice-modal-samples"]'), + applyButton: voiceModal.querySelector('[data-role="voice-modal-apply"]'), + nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'), + metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'), + randomButton: voiceModal.querySelector('[data-role="voice-modal-random"]'), + clearButton: voiceModal.querySelector('[data-role="voice-modal-clear"]'), + }; + + if (elements.searchInput) { + elements.searchInput.addEventListener("input", () => renderVoiceList(elements)); + } + if (elements.languageSelect) { + elements.languageSelect.addEventListener("change", () => renderVoiceList(elements)); + } + elements.genderButtons.forEach((button) => { + button.addEventListener("click", () => { + activeGenderFilter = button.dataset.value || ""; + elements.genderButtons.forEach((btn) => btn.setAttribute("aria-pressed", btn === button ? "true" : "false")); + renderVoiceList(elements); + }); + }); + if (elements.list) { + elements.list.addEventListener("click", (event) => { + const target = event.target.closest('[data-role="voice-modal-item"]'); + if (!target) return; + event.preventDefault(); + const voiceId = target.dataset.voiceId; + if (!voiceId) return; + if (!modalState.mix.has(voiceId)) { + modalState.mix.set(voiceId, 0.5); + } + modalState.highlighted = voiceId; + renderMix(elements); + renderVoiceList(elements); + updateModalMeta(elements); + updateApplyState(elements); + }); + } + if (elements.randomButton) { + elements.randomButton.addEventListener("click", () => { + const genderInput = modalState.speakerItem?.querySelector('[data-role="gender-input"]'); + const gender = genderInput?.value || "unknown"; + const mix = buildRandomMix(gender); + if (mix) { + modalState.mix = mix; + modalState.highlighted = Array.from(mix.keys())[0]; + refreshModal(elements); + } + }); + } + if (elements.clearButton) { + elements.clearButton.addEventListener("click", () => { + modalState.mix.clear(); + modalState.highlighted = ""; + refreshModal(elements); + }); + } + if (elements.applyButton) { + elements.applyButton.addEventListener("click", (event) => { + event.preventDefault(); + if (!modalState.speakerItem) return; + const formula = getMixFormula(); + if (!formula) return; + applyFormulaToSpeaker(modalState.speakerItem, formula); + closeVoiceBrowser(); + }); + } + voiceModal.addEventListener("click", (event) => { + if (event.target.closest('[data-role="voice-modal-close"]')) { + event.preventDefault(); + closeVoiceBrowser(); + } + }); + if (elements.samplesContainer) { + elements.samplesContainer.addEventListener("click", (event) => { + const sample = event.target.closest(".voice-browser__sample"); + if (!sample) return; + elements.samplesContainer + .querySelectorAll(".voice-browser__sample") + .forEach((node) => node.removeAttribute("data-active")); + sample.dataset.active = "true"; + }); + } + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !voiceModal.hidden) { + closeVoiceBrowser(); + } + }); + + renderVoiceList(elements); + } + + const entitySummaryData = parseJSONScript("entity-summary-data") || {}; + const entityCacheKeyData = parseJSONScript("entity-cache-key"); + const manualOverridesSeed = parseJSONScript("manual-overrides-data") || []; + const pronunciationOverridesSeed = parseJSONScript("pronunciation-overrides-data") || []; + const heteronymOverridesSeed = parseJSONScript("heteronym-overrides-data") || []; + + const entityTabs = form.querySelector('[data-role="entity-tabs"]'); + const entitiesUrl = form.dataset.entitiesUrl || ""; + const manualUpsertUrl = form.dataset.manualUpsertUrl || ""; + const manualDeleteUrlTemplate = form.dataset.manualDeleteUrlTemplate || ""; + const manualSearchUrl = form.dataset.manualSearchUrl || ""; + const baseVoice = form.dataset.baseVoice || form.dataset.voice || ""; + const languageCode = form.dataset.language || "en"; + const defaultSpeed = form.dataset.speed || "1.0"; + const useGpuDefault = form.dataset.useGpu || "true"; + let entitiesEnabled = form.dataset.entitiesEnabled !== "false"; + + const entityState = { + summary: entitySummaryData && typeof entitySummaryData === "object" ? entitySummaryData : {}, + cacheKey: typeof entityCacheKeyData === "string" ? entityCacheKeyData : "", + manualOverrides: Array.isArray(manualOverridesSeed) ? [...manualOverridesSeed] : [], + pronunciationOverrides: Array.isArray(pronunciationOverridesSeed) ? [...pronunciationOverridesSeed] : [], + heteronymOverrides: Array.isArray(heteronymOverridesSeed) ? [...heteronymOverridesSeed] : [], + filters: { + people: 0, + entities: 0, + entitiesKind: "all", + }, + }; + + let highlightedOverrideId = ""; + let highlightMode = "manual"; + const dirtyOverrideIds = new Set(); + let activeEntityPanel = ""; + let overrideFlushPromise = null; + let markOverrideDirty = () => {}; + let flushManualOverrides = () => null; + let hasTriggeredEntitiesRefresh = false; + + if (entityTabs) { + const tabButtons = Array.from(entityTabs.querySelectorAll('[data-role="entity-tab"]')); + const tabPanels = new Map( + Array.from(entityTabs.querySelectorAll('[data-role="entity-panel"]')).map((panel) => [panel.dataset.panel || "", panel]) + ); + + const peopleSummaryContainer = entityTabs.querySelector('[data-role="people-summary"]'); + const peopleStatsNode = peopleSummaryContainer?.querySelector('[data-role="people-stats"]'); + const peopleListNode = peopleSummaryContainer?.querySelector('[data-role="entity-list-people"]'); + const peopleFilterNode = peopleSummaryContainer?.querySelector('[data-role="entity-filter-people"]'); + + const entitySummaryContainer = entityTabs.querySelector('[data-role="entities-summary"]'); + const entityStatsNode = entitySummaryContainer?.querySelector('[data-role="entity-stats"]'); + const entityListNode = entitySummaryContainer?.querySelector('[data-role="entity-list-entities"]'); + const entitiesFilterNode = entitySummaryContainer?.querySelector('[data-role="entity-filter-entities"]'); + const entitiesKindFilterNode = entitySummaryContainer?.querySelector('[data-role="entity-filter-kind"]'); + const entityRowTemplate = entityTabs.querySelector('template[data-role="entity-row-template"]'); + const entitiesRefreshButton = entitySummaryContainer?.querySelector('[data-role="entities-refresh"]'); + const entitySpinner = entitySummaryContainer?.querySelector('[data-role="entities-spinner"]'); + const globalEntitySpinner = entityTabs.querySelector('[data-role="global-entity-spinner"]'); + + const manualOverridesRoot = entityTabs.querySelector('[data-role="manual-overrides"]'); + const manualOverrideList = manualOverridesRoot?.querySelector('[data-role="manual-override-list"]'); + const manualOverrideTemplate = manualOverridesRoot?.querySelector('template[data-role="manual-override-template"]'); + const manualOverrideResultsList = manualOverridesRoot?.querySelector('[data-role="manual-override-results"]'); + const manualOverrideQueryInput = manualOverridesRoot?.querySelector('[data-role="manual-override-query"]'); + const manualOverrideSearchButton = manualOverridesRoot?.querySelector('[data-role="manual-override-search"]'); + const manualOverrideAddCustomButton = manualOverridesRoot?.querySelector('[data-role="manual-override-add-custom"]'); + const manualOverridesEmpty = manualOverridesRoot?.querySelector('[data-role="manual-overrides-empty"]'); + const manualOverrideSaveButton = manualOverridesRoot?.querySelector('[data-role="manual-override-save-all"]'); + const manualOverrideStatusNode = manualOverridesRoot?.querySelector('[data-role="manual-override-status"]'); + const heteronymOverridesRoot = manualOverridesRoot?.querySelector('[data-role="heteronym-overrides"]'); + const heteronymOverrideList = heteronymOverridesRoot?.querySelector('[data-role="heteronym-override-list"]'); + const heteronymOverrideTemplate = heteronymOverridesRoot?.querySelector('template[data-role="heteronym-override-template"]'); + const heteronymOverridesEmpty = heteronymOverridesRoot?.querySelector('[data-role="heteronym-overrides-empty"]'); + let manualOverrideStatusTimer = null; + let manualOverrideStatusNonce = 0; + + const setManualOverrideStatus = (message, state = "") => { + if (!manualOverrideStatusNode) return; + manualOverrideStatusNonce += 1; + const nonce = manualOverrideStatusNonce; + if (manualOverrideStatusTimer) { + window.clearTimeout(manualOverrideStatusTimer); + manualOverrideStatusTimer = null; + } + manualOverrideStatusNode.textContent = message || ""; + if (state) { + manualOverrideStatusNode.dataset.state = state; + } else { + manualOverrideStatusNode.removeAttribute("data-state"); + } + if (state === "success" && message) { + manualOverrideStatusTimer = window.setTimeout(() => { + if (manualOverrideStatusNonce !== nonce) { + return; + } + manualOverrideStatusNode.textContent = ""; + manualOverrideStatusNode.removeAttribute("data-state"); + manualOverrideStatusTimer = null; + }, 4000); + } + }; + + if (entitiesRefreshButton) { + entitiesRefreshButton.disabled = !entitiesEnabled; + entitiesRefreshButton.setAttribute("aria-disabled", entitiesEnabled ? "false" : "true"); + } + + const parseThreshold = (value) => { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; + }; + + if (peopleFilterNode) { + peopleFilterNode.addEventListener("change", () => { + entityState.filters.people = parseThreshold(peopleFilterNode.value); + renderEntitySummary(); + }); + } + + if (entitiesFilterNode) { + entitiesFilterNode.addEventListener("change", () => { + entityState.filters.entities = parseThreshold(entitiesFilterNode.value); + renderEntitySummary(); + }); + } + + if (entitiesKindFilterNode) { + entitiesKindFilterNode.addEventListener("change", () => { + const value = (entitiesKindFilterNode.value || "all").trim(); + entityState.filters.entitiesKind = value || "all"; + renderEntitySummary(); + }); + } + + const cloneTemplate = (template) => { + if (!template) return null; + if (template.content && template.content.firstElementChild) { + return template.content.firstElementChild.cloneNode(true); + } + return template.cloneNode(true); + }; + + const formatMentions = (value) => { + const count = Number(value || 0); + return `${count.toLocaleString()} mention${count === 1 ? "" : "s"}`; + }; + + const formatEntityKindLabel = (value) => { + if (!value) { + return "Unknown"; + } + if (/^[A-Z0-9_]+$/.test(value) && value.length <= 4) { + return value.replace(/_/g, " "); + } + const lowerParts = value.toLowerCase().split("_"); + return lowerParts + .map((part, index) => { + if (index > 0 && ["of", "and", "the", "in", "on", "at"].includes(part)) { + return part; + } + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join(" "); + }; + + const buildOverrideLookup = () => { + const map = new Map(); + const register = (entry, origin) => { + if (!entry || typeof entry !== "object") return; + const normalizedToken = entry.normalized || entry.token || ""; + const canonical = canonicalizeEntityKey(normalizedToken); + const tokenLabel = entry.token || entry.normalized || ""; + const pronunciation = entry.pronunciation || ""; + if (!canonical || !tokenLabel) return; + map.set(canonical, { + ...entry, + origin, + token: tokenLabel, + normalized: normalizedToken, + pronunciation, + }); + }; + + const pronunciationOverrides = Array.isArray(entityState.pronunciationOverrides) + ? entityState.pronunciationOverrides + : []; + pronunciationOverrides.forEach((entry) => register(entry, entry?.source || "history")); + + const manualOverrides = Array.isArray(entityState.manualOverrides) ? entityState.manualOverrides : []; + manualOverrides.forEach((entry) => register(entry, "manual")); + + return map; + }; + + const buildPossessivePreviewSamples = (pronText, tokenLabel) => { + const samples = new Set(); + const base = (pronText || "").trim(); + const fallback = (tokenLabel || "").trim(); + if (base) { + samples.add(base); + } else if (fallback) { + samples.add(fallback); + } + const reference = (fallback || base).trim(); + if (!reference) { + return Array.from(samples); + } + const root = (base || reference).trim(); + if (!root) { + return Array.from(samples); + } + const lowerReference = reference.toLowerCase(); + const lowerRoot = root.toLowerCase(); + const endsWithApostrophe = lowerRoot.endsWith("'") || lowerRoot.endsWith("’"); + const endsWithApostropheS = lowerRoot.endsWith("'s") || lowerRoot.endsWith("’s"); + if (!endsWithApostropheS) { + const possessive = `${root}'s`; + const altPossessive = `${root}’s`; + samples.add(possessive); + samples.add(altPossessive); + } + if ((lowerReference.endsWith("s") || lowerRoot.endsWith("s")) && !endsWithApostrophe) { + samples.add(`${root}'`); + samples.add(`${root}’`); + } + return Array.from(samples); + }; + + const joinPossessivePreviewSamples = (pronText, tokenLabel) => { + const variants = buildPossessivePreviewSamples(pronText, tokenLabel); + return variants.join("\n").trim(); + }; + + const setEntitiesLoading = (isLoading) => { + if (globalEntitySpinner) { + globalEntitySpinner.hidden = !isLoading; + } + if (!entitySummaryContainer) { + return; + } + if (isLoading) { + entitySummaryContainer.dataset.loading = "true"; + } else { + delete entitySummaryContainer.dataset.loading; + } + if (entitySpinner) { + entitySpinner.hidden = !isLoading; + entitySpinner.setAttribute("aria-hidden", isLoading ? "false" : "true"); + } + if (entitiesRefreshButton) { + const disabled = isLoading || !entitiesEnabled; + entitiesRefreshButton.disabled = disabled; + entitiesRefreshButton.setAttribute("aria-disabled", disabled ? "true" : "false"); + } + if (entityStatsNode && isLoading) { + entityStatsNode.textContent = "Updating entity analysis…"; + } + }; + + function triggerEntitiesRefresh(force = false) { + if (!entitiesEnabled) { + return; + } + if (force) { + hasTriggeredEntitiesRefresh = true; + performEntitiesRefresh(true); + return; + } + if (!hasTriggeredEntitiesRefresh) { + hasTriggeredEntitiesRefresh = true; + performEntitiesRefresh(true); + } + } + + function activateEntityTab(panelKey) { + if (activeEntityPanel === "manual" && panelKey !== activeEntityPanel) { + void flushManualOverrides(); + } + tabButtons.forEach((button) => { + const isActive = button.dataset.panel === panelKey; + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-selected", isActive ? "true" : "false"); + }); + tabPanels.forEach((panel, key) => { + if (!panel) return; + const isActive = key === panelKey; + panel.classList.toggle("is-active", isActive); + panel.hidden = !isActive; + panel.setAttribute("aria-hidden", isActive ? "false" : "true"); + }); + activeEntityPanel = panelKey; + if (panelKey === "entities") { + triggerEntitiesRefresh(); + } + } + + function populateVoiceOptions(select, selectedVoice) { + if (!select) return; + const narratorLabel = baseVoice ? `Use narrator voice (${baseVoice})` : "Use narrator voice"; + select.innerHTML = ""; + const narratorOption = document.createElement("option"); + narratorOption.value = ""; + narratorOption.textContent = narratorLabel; + if (!selectedVoice) { + narratorOption.selected = true; + } + select.appendChild(narratorOption); + voiceCatalog.forEach((voice) => { + const option = document.createElement("option"); + option.value = voice.id; + option.textContent = `${voice.display_name} · ${voice.language_label} · ${voice.gender}`; + if (selectedVoice === voice.id) { + option.selected = true; + } + select.appendChild(option); + }); + if (selectedVoice) { + const hasMatch = Array.from(select.options).some((option) => option.value === selectedVoice); + if (!hasMatch) { + const fallback = document.createElement("option"); + fallback.value = selectedVoice; + fallback.textContent = selectedVoice; + fallback.selected = true; + select.appendChild(fallback); + } + } + } + + function renderEntitySummary() { + const summary = entityState.summary || {}; + const stats = summary.stats || {}; + const errors = Array.isArray(summary.errors) ? summary.errors : []; + const peopleEntries = Array.isArray(summary.people) ? summary.people : []; + const entityEntries = Array.isArray(summary.entities) ? summary.entities : []; + const overrideLookup = buildOverrideLookup(); + const peopleThreshold = Number.parseInt(entityState.filters?.people, 10) || 0; + const entityThreshold = Number.parseInt(entityState.filters?.entities, 10) || 0; + const entityKindSelection = (entityState.filters?.entitiesKind || "all").toLowerCase(); + + const ensureSelectValue = (node, value) => { + if (!node) return; + const target = String(value); + if (node.value !== target) { + node.value = target; + } + }; + + ensureSelectValue(peopleFilterNode, peopleThreshold); + ensureSelectValue(entitiesFilterNode, entityThreshold); + ensureSelectValue(entitiesKindFilterNode, entityKindSelection || "all"); + + if (entitiesKindFilterNode) { + const seenKinds = new Set(); + const existingValue = entitiesKindFilterNode.value || "all"; + const options = Array.from(entityEntries || []) + .map((entry) => String(entry?.kind || "")) + .filter((kind) => kind && kind !== "PERSON"); + entitiesKindFilterNode.innerHTML = ""; + const addOption = (value, label) => { + if (seenKinds.has(value)) return; + const opt = document.createElement("option"); + opt.value = value; + opt.textContent = label; + entitiesKindFilterNode.appendChild(opt); + seenKinds.add(value); + }; + addOption("all", "All"); + addOption("proper_noun", "Proper nouns"); + options.forEach((kind) => { + const normalized = kind.toLowerCase(); + addOption(normalized, formatEntityKindLabel(kind)); + }); + if (!seenKinds.has(existingValue)) { + entityState.filters.entitiesKind = "all"; + } + ensureSelectValue(entitiesKindFilterNode, entityState.filters.entitiesKind || "all"); + } + + const renderDisabled = (listNode, message) => { + if (!listNode) return; + listNode.innerHTML = ""; + const emptyItem = document.createElement("li"); + emptyItem.className = "entity-summary__empty"; + emptyItem.textContent = message; + listNode.appendChild(emptyItem); + }; + + if (!entitiesEnabled) { + const disabledMessage = "Entity recognition is turned off in Settings."; + if (entityStatsNode) { + entityStatsNode.textContent = disabledMessage; + } + if (peopleStatsNode) { + peopleStatsNode.textContent = disabledMessage; + } + if (peopleFilterNode) { + peopleFilterNode.disabled = true; + } + if (entitiesFilterNode) { + entitiesFilterNode.disabled = true; + } + if (entitiesKindFilterNode) { + entitiesKindFilterNode.disabled = true; + entitiesKindFilterNode.value = "all"; + } + renderDisabled(peopleListNode, disabledMessage); + renderDisabled(entityListNode, "Enable entity recognition to populate detected entities."); + return; + } + + if (peopleFilterNode) { + peopleFilterNode.disabled = false; + } + if (entitiesFilterNode) { + entitiesFilterNode.disabled = false; + } + if (entitiesKindFilterNode) { + entitiesKindFilterNode.disabled = false; + } + + if (entityStatsNode) { + if (errors.length) { + entityStatsNode.textContent = errors.join(" · "); + } else if (stats.processed) { + const parts = []; + if (typeof stats.chapters === "number") { + parts.push(`${stats.chapters} chapter${stats.chapters === 1 ? "" : "s"}`); + } + if (typeof stats.tokens === "number") { + parts.push(`${stats.tokens.toLocaleString()} tokens processed`); + } + if (typeof stats.people === "number") { + parts.push(`${stats.people} character${stats.people === 1 ? "" : "s"}`); + } + if (typeof stats.entities === "number") { + parts.push(`${stats.entities} ${stats.entities === 1 ? "entity" : "entities"}`); + } + entityStatsNode.textContent = parts.length ? parts.join(" · ") : "Entity analysis up to date."; + } else { + entityStatsNode.textContent = "Entity analysis will populate once you continue from chapters."; + } + } + + const renderGroup = (listNode, entries, threshold, options) => { + if (!listNode) { + return { visible: 0, total: entries.length }; + } + listNode.innerHTML = ""; + let filtered = entries.filter((entry) => Number(entry?.count || 0) >= threshold); + if (options.kindFilter) { + filtered = filtered.filter((entry) => options.kindFilter(entry)); + } + if (!filtered.length) { + const emptyItem = document.createElement("li"); + emptyItem.className = "entity-summary__empty"; + emptyItem.textContent = entries.length ? options.filteredEmptyText : options.emptyText; + listNode.appendChild(emptyItem); + return { visible: 0, total: entries.length }; + } + filtered.forEach((entity) => { + const item = cloneTemplate(entityRowTemplate); + if (!item) return; + const normalized = entity.normalized || entity.label || entity.token || ""; + const tokenLabel = entity.label || entity.token || normalized || "Untitled entity"; + item.dataset.entityId = entity.id || normalized || tokenLabel; + item.dataset.entityCategory = options.groupKey; + item.dataset.normalized = normalized.toLowerCase(); + if (entity.kind) { + item.dataset.entityKind = entity.kind; + } + + const labelEl = item.querySelector('[data-role="entity-label"]'); + if (labelEl) { + labelEl.textContent = tokenLabel; + } + + const kindEl = item.querySelector('[data-role="entity-kind"]'); + if (kindEl) { + const kind = entity.kind || entity.category || ""; + if (options.hideKind || !kind) { + kindEl.textContent = ""; + kindEl.hidden = true; + } else { + kindEl.hidden = false; + kindEl.textContent = formatEntityKindLabel(kind); + } + } + + const countEl = item.querySelector('[data-role="entity-count"]'); + if (countEl) { + countEl.textContent = formatMentions(entity.count); + } + + const samplesContainer = item.querySelector('[data-role="entity-samples"]'); + if (samplesContainer) { + samplesContainer.innerHTML = ""; + const samples = Array.isArray(entity.samples) ? entity.samples : []; + if (!samples.length) { + const hint = document.createElement("p"); + hint.className = "hint"; + hint.textContent = "No sample sentences captured yet."; + samplesContainer.appendChild(hint); + } else { + const list = document.createElement("ul"); + list.className = "entity-summary__samples-list"; + samples.slice(0, 3).forEach((sample) => { + const text = typeof sample === "string" ? sample : sample?.excerpt; + if (!text) return; + const entry = document.createElement("li"); + entry.textContent = text; + list.appendChild(entry); + }); + samplesContainer.appendChild(list); + } + } + + const normalizedKey = canonicalizeEntityKey(normalized || tokenLabel); + const overrideMeta = normalizedKey ? overrideLookup.get(normalizedKey) : null; + + const overrideButton = item.querySelector('[data-role="entity-add-override"]'); + if (overrideButton) { + overrideButton.dataset.entityToken = entity.label || entity.token || ""; + overrideButton.dataset.entityNormalized = normalized; + overrideButton.dataset.entityCategory = options.groupKey; + overrideButton.dataset.entityCount = String(entity.count || 0); + const sampleContext = Array.isArray(entity.samples) && entity.samples.length + ? typeof entity.samples[0] === "string" + ? entity.samples[0] + : entity.samples[0]?.excerpt || "" + : ""; + if (sampleContext) { + overrideButton.dataset.entityContext = sampleContext; + } + if (entity.kind) { + overrideButton.dataset.entityKind = entity.kind; + } + overrideButton.textContent = overrideMeta + ? overrideMeta.origin === "manual" + ? "Edit manual override" + : "Edit pronunciation override" + : "Add manual override"; + if (overrideMeta) { + item.dataset.hasOverride = "true"; + } else { + delete item.dataset.hasOverride; + } + } + + const inlineOverride = item.querySelector('[data-role="inline-override"]'); + if (inlineOverride) { + const override = overrideMeta; + if (override) { + inlineOverride.hidden = false; + item.dataset.hasOverride = "true"; + item.dataset.overrideId = override.id || override.normalized || override.token || ""; + if (override.origin) { + item.dataset.overrideSource = override.origin; + } else { + delete item.dataset.overrideSource; + } + inlineOverride.dataset.pendingToken = override.token || tokenLabel; + inlineOverride.dataset.pendingNormalized = override.normalized || normalized; + inlineOverride.dataset.pendingContext = override.context || inlineOverride.dataset.pendingContext || ""; + inlineOverride.dataset.pendingCategory = options.groupKey; + inlineOverride.dataset.pendingKind = entity.kind || ""; + const pronInput = inlineOverride.querySelector('[data-role="manual-override-pronunciation"]'); + if (pronInput) { + pronInput.value = override.pronunciation || ""; + pronInput.placeholder = override.token || tokenLabel; + } + const voiceSelect = inlineOverride.querySelector('[data-role="manual-override-voice"]'); + if (voiceSelect) { + populateVoiceOptions(voiceSelect, override.voice || ""); + } + const previewButton = inlineOverride.querySelector('[data-role="speaker-preview"]'); + if (previewButton) { + const previewValue = joinPossessivePreviewSamples(override.pronunciation, override.token || tokenLabel); + previewButton.dataset.previewText = previewValue || override.token || tokenLabel; + previewButton.dataset.voice = override.voice || override.voice_profile || baseVoice || ""; + previewButton.dataset.language = languageCode; + previewButton.dataset.speed = defaultSpeed; + previewButton.dataset.useGpu = useGpuDefault; + } + if ( + highlightMode === "inline" && + highlightedOverrideId && + (override.id === highlightedOverrideId || override.normalized === highlightedOverrideId || override.token === highlightedOverrideId) + ) { + item.classList.add("is-highlighted"); + inlineOverride.hidden = false; + setTimeout(() => item.classList.remove("is-highlighted"), 2200); + highlightedOverrideId = ""; + } + } else { + inlineOverride.hidden = true; + delete item.dataset.overrideId; + delete item.dataset.overrideSource; + } + } + + const previewButton = item.querySelector('[data-entity-preview="true"]'); + if (previewButton) { + const previewVoice = overrideMeta?.voice || baseVoice || ""; + const previewText = Array.isArray(entity.samples) && entity.samples.length + ? typeof entity.samples[0] === "string" + ? entity.samples[0] + : entity.samples[0]?.excerpt || tokenLabel + : tokenLabel; + previewButton.hidden = !previewText; + if (previewText) { + previewButton.dataset.previewText = previewText; + previewButton.dataset.voice = previewVoice; + previewButton.dataset.language = languageCode; + previewButton.dataset.speed = defaultSpeed; + previewButton.dataset.useGpu = useGpuDefault; + previewButton.dataset.previewSource = "entity"; + } + } + + listNode.appendChild(item); + }); + return { visible: filtered.length, total: entries.length }; + }; + + const peopleRender = renderGroup(peopleListNode, peopleEntries, peopleThreshold, { + groupKey: "people", + hideKind: true, + emptyText: "No characters detected yet.", + filteredEmptyText: "No characters match the selected mention filter.", + }); + + if (peopleFilterNode && !peopleEntries.length) { + peopleFilterNode.value = "0"; + } + + if (peopleStatsNode) { + if (errors.length) { + peopleStatsNode.textContent = errors.join(" · "); + } else if (!peopleEntries.length) { + peopleStatsNode.textContent = "No characters detected yet."; + } else if (!peopleRender.visible) { + peopleStatsNode.textContent = "Adjust the mention filter to see additional characters."; + } else { + let label = "all mentions"; + if (peopleThreshold > 1) { + label = `${peopleThreshold}+ mentions`; + } else if (peopleThreshold === 1) { + label = "1+ mention"; + } + peopleStatsNode.textContent = `Showing ${peopleRender.visible} of ${peopleRender.total} characters (${label}).`; + } + } + + const entitiesRender = renderGroup(entityListNode, entityEntries, entityThreshold, { + groupKey: "entities", + hideKind: false, + emptyText: "No entities detected yet.", + filteredEmptyText: "No entities match the selected mention filter.", + kindFilter: (entry) => { + if (!entityKindSelection || entityKindSelection === "all") { + return true; + } + const kind = (entry.kind || "").toLowerCase(); + if (entityKindSelection === "proper_noun") { + return !kind || kind === "propn" || kind === "noun" || kind === "proper_noun"; + } + return kind === entityKindSelection; + }, + }); + + if (entitiesFilterNode && !entityEntries.length) { + entitiesFilterNode.value = "0"; + } + + if ( + entityStatsNode && + !errors.length && + stats.processed && + typeof stats.entities === "number" && + entityThreshold > 0 && + stats.entities > entitiesRender.visible + ) { + const filterLabel = entityThreshold > 1 ? `${entityThreshold}+ mentions` : "1+ mention"; + entityStatsNode.textContent += ` · Filter hiding ${stats.entities - entitiesRender.visible} entries (${filterLabel})`; + } + } + + function renderManualOverrides() { + if (!manualOverrideList) return; + manualOverrideList.innerHTML = ""; + const overrides = Array.isArray(entityState.manualOverrides) ? entityState.manualOverrides : []; + if (!overrides.length) { + if (manualOverridesEmpty) { + manualOverridesEmpty.hidden = false; + } + return; + } + + if (manualOverridesEmpty) { + manualOverridesEmpty.hidden = true; + } + + overrides.forEach((override) => { + const node = cloneTemplate(manualOverrideTemplate); + if (!node) return; + const overrideId = override.id || override.normalized || override.token || ""; + node.dataset.overrideId = overrideId; + node.dataset.token = override.token || ""; + node.dataset.normalized = override.normalized || ""; + node.dataset.context = override.context || ""; + + const labelEl = node.querySelector('[data-role="override-label"]'); + if (labelEl) { + labelEl.textContent = override.token || override.normalized || "Manual override"; + } + + const notesEl = node.querySelector('[data-role="override-notes"]'); + if (notesEl) { + const notes = override.notes || override.context || ""; + if (notes) { + notesEl.textContent = notes; + } else { + notesEl.hidden = true; + } + } + + const pronInput = node.querySelector('[data-role="manual-override-pronunciation"]'); + if (pronInput) { + pronInput.value = override.pronunciation || ""; + pronInput.placeholder = override.token || override.normalized || ""; + pronInput.dataset.overrideId = overrideId; + } + + const voiceSelect = node.querySelector('[data-role="manual-override-voice"]'); + if (voiceSelect) { + populateVoiceOptions(voiceSelect, override.voice || ""); + voiceSelect.dataset.overrideId = overrideId; + } + + const previewButton = node.querySelector('[data-role="speaker-preview"]'); + if (previewButton) { + const previewVoice = override.voice || voiceSelect?.value || baseVoice || ""; + const previewText = joinPossessivePreviewSamples(override.pronunciation, override.token || override.normalized || ""); + previewButton.dataset.overrideId = overrideId; + previewButton.dataset.previewText = previewText || override.pronunciation || override.token || ""; + previewButton.dataset.voice = previewVoice; + previewButton.dataset.language = languageCode; + previewButton.dataset.speed = defaultSpeed; + previewButton.dataset.useGpu = useGpuDefault; + } + + const deleteButton = node.querySelector('[data-role="manual-override-delete"]'); + if (deleteButton) { + deleteButton.dataset.overrideId = overrideId; + } + + const metaEl = node.querySelector('[data-role="manual-override-meta"]'); + if (metaEl) { + const parts = []; + if (override.source) { + parts.push(`Source: ${override.source}`); + } + if (override.updated_at) { + const timestamp = Number(override.updated_at) * 1000; + if (!Number.isNaN(timestamp)) { + parts.push(`Updated ${new Date(timestamp).toLocaleString()}`); + } + } + metaEl.textContent = parts.join(" · "); + } + + manualOverrideList.appendChild(node); + if (highlightedOverrideId && highlightedOverrideId === overrideId) { + node.classList.add("is-highlighted"); + setTimeout(() => node.classList.remove("is-highlighted"), 2400); + node.scrollIntoView({ behavior: "smooth", block: "center" }); + highlightedOverrideId = ""; + } + }); + } + + const escapeRegExp = (value) => String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + const resolveHeteronymTooltip = (entry) => { + if (!entry || typeof entry !== "object") return ""; + const options = Array.isArray(entry.options) ? entry.options : []; + if (options.length < 2) return ""; + const parts = []; + options.slice(0, 2).forEach((opt) => { + const label = String(opt?.label || "").trim(); + const example = String(opt?.example_sentence || "").trim(); + if (label && example) { + parts.push(`${label}: ${example}`); + } else if (example) { + parts.push(example); + } + }); + return parts.join("\n"); + }; + + const fillHighlightedSentence = (container, sentence, token, tooltip) => { + if (!container) return; + container.textContent = ""; + const rawSentence = String(sentence || ""); + const rawToken = String(token || ""); + if (!rawSentence) { + return; + } + if (!rawToken) { + container.textContent = rawSentence; + return; + } + + const pattern = new RegExp(`\\b${escapeRegExp(rawToken)}\\b`, "i"); + const match = pattern.exec(rawSentence); + if (!match) { + container.textContent = rawSentence; + return; + } + + const before = rawSentence.slice(0, match.index); + const hit = rawSentence.slice(match.index, match.index + match[0].length); + const after = rawSentence.slice(match.index + match[0].length); + + if (before) { + container.appendChild(document.createTextNode(before)); + } + const chip = document.createElement("span"); + chip.className = "chip"; + chip.textContent = hit; + if (tooltip) { + chip.title = tooltip; + } + container.appendChild(chip); + if (after) { + container.appendChild(document.createTextNode(after)); + } + }; + + function renderHeteronymOverrides() { + if (!heteronymOverrideList) return; + heteronymOverrideList.innerHTML = ""; + const entries = Array.isArray(entityState.heteronymOverrides) ? entityState.heteronymOverrides : []; + if (!entries.length) { + if (heteronymOverridesEmpty) { + heteronymOverridesEmpty.hidden = false; + } + return; + } + + if (heteronymOverridesEmpty) { + heteronymOverridesEmpty.hidden = true; + } + + entries.forEach((entry) => { + if (!entry || typeof entry !== "object") return; + const entryId = String(entry.entry_id || entry.id || "").trim(); + if (!entryId) return; + const sentence = String(entry.sentence || "").trim(); + if (!sentence) return; + const token = String(entry.token || "").trim(); + + const node = cloneTemplate(heteronymOverrideTemplate); + if (!node) return; + node.dataset.entryId = entryId; + + const sentenceEl = node.querySelector('[data-role="heteronym-sentence"]'); + if (sentenceEl) { + const tooltip = resolveHeteronymTooltip(entry); + fillHighlightedSentence(sentenceEl, sentence, token, tooltip); + } + + const notesEl = node.querySelector('[data-role="heteronym-notes"]'); + if (notesEl) { + const suggested = String(entry.default_choice || "").trim(); + if (suggested) { + const options = Array.isArray(entry.options) ? entry.options : []; + const match = options.find((opt) => String(opt?.key || "").trim() === suggested); + const label = String(match?.label || "").trim(); + notesEl.textContent = label ? `Suggested: ${label}` : ""; + notesEl.hidden = !notesEl.textContent; + } else { + notesEl.hidden = true; + } + } + + const optionsContainer = node.querySelector('[data-role="heteronym-options"]'); + if (optionsContainer) { + optionsContainer.innerHTML = ""; + const options = Array.isArray(entry.options) ? entry.options : []; + const selectedKey = String(entry.choice || entry.default_choice || "").trim(); + options.slice(0, 2).forEach((opt, index) => { + if (!opt || typeof opt !== "object") return; + const key = String(opt.key || "").trim(); + const label = String(opt.label || "Option").trim(); + const previewSentence = String(opt.replacement_sentence || sentence).trim(); + if (!key) return; + + const wrapper = document.createElement("div"); + wrapper.className = "entity-inline-override__actions"; + + const choiceLabel = document.createElement("label"); + choiceLabel.className = "toggle-pill"; + const input = document.createElement("input"); + input.type = "radio"; + input.name = `heteronym-${entryId}-choice`; + input.value = key; + if (selectedKey) { + input.checked = selectedKey === key; + } else { + input.checked = index === 0; + } + const span = document.createElement("span"); + span.textContent = label; + choiceLabel.appendChild(input); + choiceLabel.appendChild(span); + + const previewButton = document.createElement("button"); + previewButton.type = "button"; + previewButton.className = "button button--ghost button--small"; + previewButton.dataset.role = "speaker-preview"; + previewButton.dataset.previewText = previewSentence; + previewButton.dataset.voice = baseVoice || ""; + previewButton.dataset.language = languageCode; + previewButton.dataset.speed = defaultSpeed; + previewButton.dataset.useGpu = useGpuDefault; + previewButton.textContent = "Preview"; + previewButton.disabled = !previewButton.dataset.voice; + previewButton.setAttribute("aria-disabled", previewButton.disabled ? "true" : "false"); + + wrapper.appendChild(choiceLabel); + wrapper.appendChild(previewButton); + optionsContainer.appendChild(wrapper); + }); + } + + heteronymOverrideList.appendChild(node); + }); + } + + function applyEntityPayload(payload, options = {}) { + if (payload && typeof payload === "object") { + if (payload.summary) { + entityState.summary = payload.summary; + } + if (Array.isArray(payload.manual_overrides)) { + entityState.manualOverrides = payload.manual_overrides; + } + if (Array.isArray(payload.pronunciation_overrides)) { + entityState.pronunciationOverrides = payload.pronunciation_overrides; + } + if (Array.isArray(payload.heteronym_overrides)) { + entityState.heteronymOverrides = payload.heteronym_overrides; + } + if (typeof payload.cache_key === "string") { + entityState.cacheKey = payload.cache_key; + } + if (Object.prototype.hasOwnProperty.call(payload, "recognition_enabled")) { + entitiesEnabled = payload.recognition_enabled !== false; + form.dataset.entitiesEnabled = entitiesEnabled ? "true" : "false"; + if (entitiesRefreshButton) { + entitiesRefreshButton.disabled = !entitiesEnabled; + entitiesRefreshButton.setAttribute("aria-disabled", entitiesEnabled ? "false" : "true"); + } + } + } + if (options.highlightId) { + highlightedOverrideId = options.highlightId; + } + renderEntitySummary(); + renderManualOverrides(); + renderHeteronymOverrides(); + } + + const filterVoicesByGender = (voices, genderHint) => { + const normalized = (genderHint || "unknown").toLowerCase(); + if (normalized === "female") { + return voices.filter((voice) => (voice.gender_code || "").toLowerCase() === "f"); + } + if (normalized === "male") { + return voices.filter((voice) => (voice.gender_code || "").toLowerCase() === "m"); + } + if (normalized === "either") { + return voices.filter((voice) => { + const code = (voice.gender_code || "").toLowerCase(); + return code === "f" || code === "m"; + }); + } + return voices.slice(); + }; + + const pickRandomVoiceForOverride = (genderHint) => { + if (!Array.isArray(voiceCatalog) || !voiceCatalog.length) { + return baseVoice || ""; + } + const normalizedLanguage = (languageCode || "").trim().toLowerCase(); + const languageCandidates = () => { + const keys = []; + if (normalizedLanguage) keys.push(normalizedLanguage); + if (languageCode && languageCode !== normalizedLanguage) { + keys.push(languageCode); + } + for (const key of keys) { + const mapped = languageMap?.[key]; + if (Array.isArray(mapped) && mapped.length) { + const lookup = new Set(mapped); + const matches = voiceCatalog.filter((voice) => lookup.has(voice.id)); + if (matches.length) { + return matches; + } + } + } + const direct = voiceCatalog.filter((voice) => (voice.language || "").toLowerCase() === normalizedLanguage); + return direct; + }; + + const preferred = languageCandidates(); + let pool = filterVoicesByGender(preferred.length ? preferred : voiceCatalog, genderHint); + if (!pool.length) { + pool = voiceCatalog.slice(); + } + if (!pool.length) { + return baseVoice || ""; + } + const selected = pool[Math.floor(Math.random() * pool.length)]; + return selected?.id || baseVoice || ""; + }; + + const resolveOverrideVoice = (data) => { + const hasVoiceProp = Boolean(data && Object.prototype.hasOwnProperty.call(data, "voice")); + const voiceValueRaw = typeof data?.voice === "string" ? data.voice : ""; + const voiceValue = voiceValueRaw.trim(); + if (voiceValue) { + return voiceValue; + } + if (hasVoiceProp) { + return ""; + } + const normalizedKey = canonicalizeEntityKey(data.normalized || data.token || ""); + let genderHint = (data.gender || "").toLowerCase(); + if (normalizedKey) { + const speakerHint = speakerHints.get(normalizedKey); + if (speakerHint) { + if (speakerHint.voice) { + return speakerHint.voice; + } + if (!genderHint || genderHint === "unknown") { + genderHint = speakerHint.gender || genderHint; + } + } + } + if (!genderHint || genderHint === "unknown") { + if (data.category === "people" || data.kind === "PERSON") { + genderHint = "either"; + } else { + genderHint = ""; + } + } + if (baseVoice) { + return ""; + } + return pickRandomVoiceForOverride(genderHint); + }; + + function collectOverridePayload(overrideId) { + if (!overrideId || !manualOverrideList) return null; + const selectorId = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(overrideId) : overrideId.replace(/["\\]/g, "\\$&"); + const node = manualOverrideList.querySelector(`[data-override-id="${selectorId}"]`); + if (!node) return null; + const pronInput = node.querySelector('[data-role="manual-override-pronunciation"]'); + const voiceSelect = node.querySelector('[data-role="manual-override-voice"]'); + return { + id: overrideId, + token: node.dataset.token || "", + normalized: node.dataset.normalized || "", + pronunciation: pronInput?.value?.trim() || "", + voice: voiceSelect?.value || "", + notes: "", + context: node.dataset.context || "", + }; + } + + async function saveManualOverride(overrideId) { + const payload = collectOverridePayload(overrideId); + if (!payload || !manualUpsertUrl) return false; + try { + const response = await fetch(manualUpsertUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(`Override save failed with status ${response.status}`); + } + const data = await response.json(); + applyEntityPayload(data, { highlightId: data.override?.id || payload.id }); + return true; + } catch (error) { + console.error("Failed to save manual override", error); + return false; + } + } + + markOverrideDirty = (overrideId) => { + if (!overrideId) return; + dirtyOverrideIds.add(overrideId); + setManualOverrideStatus("Unsaved changes", "pending"); + }; + + flushManualOverrides = (options = {}) => { + const { overrideId } = options || {}; + const pendingIds = Array.isArray(overrideId) ? overrideId : overrideId ? [overrideId] : Array.from(dirtyOverrideIds); + const targetIds = pendingIds.filter((id) => dirtyOverrideIds.has(id)); + if (!targetIds.length) return null; + setManualOverrideStatus("Saving overrides…", "pending"); + targetIds.forEach((id) => dirtyOverrideIds.delete(id)); + const runFlush = async () => { + if (overrideFlushPromise) { + try { + await overrideFlushPromise; + } catch (error) { + console.warn("Previous override flush failed", error); + } + } + const results = await Promise.all(targetIds.map((id) => saveManualOverride(id))); + let hasFailure = false; + results.forEach((ok, index) => { + if (!ok) { + hasFailure = true; + dirtyOverrideIds.add(targetIds[index]); + } + }); + if (hasFailure) { + setManualOverrideStatus("Some overrides failed to save.", "error"); + } else { + setManualOverrideStatus("Overrides saved.", "success"); + } + }; + overrideFlushPromise = runFlush() + .catch((error) => { + console.error("Failed flushing manual overrides", error); + setManualOverrideStatus("Failed to save overrides.", "error"); + }) + .finally(() => { + overrideFlushPromise = null; + }); + return overrideFlushPromise; + }; + + async function deleteManualOverride(overrideId) { + if (!overrideId || !manualDeleteUrlTemplate) return; + dirtyOverrideIds.delete(overrideId); + const targetUrl = manualDeleteUrlTemplate.replace("__OVERRIDE_ID__", encodeURIComponent(overrideId)); + try { + const response = await fetch(targetUrl, { method: "DELETE" }); + if (!response.ok) { + throw new Error(`Override delete failed with status ${response.status}`); + } + const data = await response.json(); + applyEntityPayload(data); + } catch (error) { + console.error("Failed to delete manual override", error); + } + } + + async function performEntitiesRefresh(force = false) { + if (!entitiesUrl) return; + setEntitiesLoading(true); + try { + const url = new URL(entitiesUrl, window.location.origin); + if (force) { + url.searchParams.set("refresh", "1"); + } + if (entityState.cacheKey) { + url.searchParams.set("cache_key", entityState.cacheKey); + } + const response = await fetch(url.toString(), { method: "GET" }); + if (!response.ok) { + throw new Error(`Entity refresh failed with status ${response.status}`); + } + const data = await response.json(); + applyEntityPayload(data); + } catch (error) { + console.error("Failed to refresh entity summary", error); + } finally { + setEntitiesLoading(false); + } + } + + async function performManualOverrideSearch(query) { + if (!manualSearchUrl || !manualOverrideResultsList) return; + manualOverrideResultsList.innerHTML = ""; + try { + const url = new URL(manualSearchUrl, window.location.origin); + if (query) { + url.searchParams.set("q", query); + } + const response = await fetch(url.toString(), { method: "GET" }); + if (!response.ok) { + throw new Error(`Search failed with status ${response.status}`); + } + const data = await response.json(); + const results = Array.isArray(data.results) ? data.results : []; + if (!results.length) { + const emptyItem = document.createElement("li"); + emptyItem.className = "manual-overrides__results-empty"; + emptyItem.textContent = query ? "No matches found." : "Start typing to search tokens."; + manualOverrideResultsList.appendChild(emptyItem); + return; + } + results.forEach((entry) => { + const item = document.createElement("li"); + item.className = "manual-overrides__result"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "button button--ghost button--small"; + button.dataset.role = "manual-override-result"; + button.dataset.token = entry.token || entry.normalized || ""; + if (entry.normalized) { + button.dataset.normalized = entry.normalized; + } + if (entry.context) { + button.dataset.context = entry.context; + } else if (Array.isArray(entry.samples) && entry.samples.length) { + const sample = entry.samples[0]; + button.dataset.context = typeof sample === "string" ? sample : sample?.excerpt || ""; + } + if (entry.pronunciation) { + button.dataset.pronunciation = entry.pronunciation; + } + if (entry.voice) { + button.dataset.voice = entry.voice; + } + button.dataset.source = entry.source || "search"; + button.textContent = entry.token || entry.normalized || "Unnamed token"; + item.appendChild(button); + manualOverrideResultsList.appendChild(item); + }); + } catch (error) { + console.error("Failed to search manual override candidates", error); + } + } + + async function createOverrideFromData(data) { + if (!data || !manualUpsertUrl) return false; + const token = (data.token || "").trim(); + if (!token) return false; + const normalized = (data.normalized || "").trim(); + const voiceChoice = resolveOverrideVoice({ ...data, token, normalized }); + const payload = { + token, + normalized, + context: data.context || "", + pronunciation: data.pronunciation || "", + voice: voiceChoice || "", + source: data.source || "manual", + }; + try { + const response = await fetch(manualUpsertUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(`Override creation failed with status ${response.status}`); + } + const body = await response.json(); + const highlighted = body.override?.id || payload.normalized || payload.token; + applyEntityPayload(body, { highlightId: highlighted }); + if (highlightMode === "inline") { + highlightedOverrideId = highlighted; + const targetPanel = activeEntityPanel || "entities"; + activateEntityTab(targetPanel); + } else { + activateEntityTab("manual"); + if (manualOverrideResultsList) { + manualOverrideResultsList.innerHTML = ""; + } + } + return true; + } catch (error) { + console.error("Failed to create manual override", error); + return false; + } + } + + tabButtons.forEach((button) => { + button.addEventListener("click", () => { + highlightMode = button.dataset.panel === "manual" ? "manual" : "entities"; + activateEntityTab(button.dataset.panel || "people"); + }); + }); + + if (entitiesRefreshButton) { + entitiesRefreshButton.addEventListener("click", () => { + void flushManualOverrides(); + triggerEntitiesRefresh(true); + }); + } + + const extractInlineOverrideState = (inlineOverride, row) => { + if (!inlineOverride || inlineOverride.hidden) { + return null; + } + const container = row || inlineOverride.closest('[data-role="entity-row"]'); + if (!container) { + return null; + } + const tokenLabel = inlineOverride.dataset.pendingToken || container.querySelector('[data-role="entity-label"]')?.textContent || ""; + if (!tokenLabel) { + return null; + } + const normalized = (inlineOverride.dataset.pendingNormalized || container.dataset.normalized || tokenLabel || "").trim(); + const context = (inlineOverride.dataset.pendingContext || "").trim(); + const pronInput = inlineOverride.querySelector('[data-role="manual-override-pronunciation"]'); + const voiceSelect = inlineOverride.querySelector('[data-role="manual-override-voice"]'); + const pronunciationRaw = pronInput?.value ?? ""; + const pronunciation = pronunciationRaw.trim(); + const voiceRaw = voiceSelect?.value ?? ""; + const voice = voiceRaw; + const previewVoice = voice || baseVoice || ""; + const previewText = pronunciation || tokenLabel; + return { + token: tokenLabel, + normalized, + context, + pronunciation, + voice, + previewVoice, + previewText, + category: inlineOverride.dataset.pendingCategory || container.dataset.entityCategory || "", + kind: inlineOverride.dataset.pendingKind || container.dataset.entityKind || "", + }; + }; + + const syncInlinePreviewUI = (row, inlineOverride, state, targetButton) => { + if (!state) { + return; + } + const previewText = state.previewText || state.token; + const previewVoice = state.previewVoice || baseVoice || ""; + const applyDatasets = (button) => { + if (!button) return; + const previewValue = joinPossessivePreviewSamples(previewText, state.token); + button.dataset.previewText = previewValue || previewText || state.token || ""; + button.dataset.voice = previewVoice; + button.dataset.language = languageCode; + button.dataset.speed = defaultSpeed; + button.dataset.useGpu = useGpuDefault; + if (!button.dataset.previewSource || button.dataset.previewSource === "entity") { + button.dataset.previewSource = "manual"; + } + }; + + if (targetButton) { + applyDatasets(targetButton); + return; + } + + if (inlineOverride) { + applyDatasets(inlineOverride.querySelector('[data-role="speaker-preview"]')); + } + if (row) { + applyDatasets(row.querySelector('[data-entity-preview="true"]')); + } + }; + + const persistInlineOverrideState = (state) => { + if (!state) { + return null; + } + highlightMode = "inline"; + setManualOverrideStatus("Saving overrides…", "pending"); + return createOverrideFromData({ + token: state.token, + normalized: state.normalized, + context: state.context, + pronunciation: state.pronunciation, + voice: state.voice, + category: state.category, + kind: state.kind, + source: "entity-inline", + }) + .then((ok) => { + if (ok) { + setManualOverrideStatus("Overrides saved.", "success"); + } else { + setManualOverrideStatus("Failed to save overrides.", "error"); + } + return ok; + }) + .catch((error) => { + setManualOverrideStatus("Failed to save overrides.", "error"); + throw error; + }); + }; + + const handleEntityListClick = (event) => { + const addTrigger = event.target.closest('[data-role="entity-add-override"]'); + if (addTrigger) { + event.preventDefault(); + const row = addTrigger.closest('[data-role="entity-row"]'); + if (!row) { + return; + } + const inlineOverride = row.querySelector('[data-role="inline-override"]'); + if (!inlineOverride) { + highlightMode = "manual"; + createOverrideFromData({ + token: addTrigger.dataset.entityToken || "", + normalized: addTrigger.dataset.entityNormalized || "", + context: addTrigger.dataset.entityContext || "", + category: addTrigger.dataset.entityCategory || "", + kind: addTrigger.dataset.entityKind || "", + source: "entity", + }); + return; + } + + inlineOverride.hidden = false; + inlineOverride.setAttribute("aria-hidden", "false"); + const pronInput = inlineOverride.querySelector('[data-role="manual-override-pronunciation"]'); + const voiceSelect = inlineOverride.querySelector('[data-role="manual-override-voice"]'); + const previewButton = inlineOverride.querySelector('[data-role="speaker-preview"]'); + const token = addTrigger.dataset.entityToken || row.querySelector('[data-role="entity-label"]')?.textContent || ""; + const normalized = addTrigger.dataset.entityNormalized || row.dataset.normalized || token; + const context = addTrigger.dataset.entityContext || ""; + + if (!row.dataset.hasOverride) { + if (pronInput) { + pronInput.value = ""; + } + if (voiceSelect) { + populateVoiceOptions(voiceSelect, ""); + } + } + + inlineOverride.dataset.pendingToken = token; + inlineOverride.dataset.pendingNormalized = normalized; + inlineOverride.dataset.pendingContext = context; + inlineOverride.dataset.pendingCategory = addTrigger.dataset.entityCategory || row.dataset.entityCategory || ""; + inlineOverride.dataset.pendingKind = addTrigger.dataset.entityKind || row.dataset.entityKind || ""; + + const inlineState = extractInlineOverrideState(inlineOverride, row); + syncInlinePreviewUI(row, inlineOverride, inlineState, previewButton); + + highlightMode = "inline"; + highlightedOverrideId = row.dataset.overrideId || ""; + pronInput?.focus({ preventScroll: true }); + return; + } + + const saveTrigger = event.target.closest('[data-role="inline-override-save"]'); + if (saveTrigger) { + event.preventDefault(); + const inlineOverride = saveTrigger.closest('[data-role="inline-override"]'); + const row = saveTrigger.closest('[data-role="entity-row"]'); + if (!inlineOverride || !row) { + return; + } + const inlineState = extractInlineOverrideState(inlineOverride, row); + syncInlinePreviewUI(row, inlineOverride, inlineState); + void persistInlineOverrideState(inlineState); + return; + } + + const removeTrigger = event.target.closest('[data-role="inline-override-remove"]'); + if (removeTrigger) { + event.preventDefault(); + const row = removeTrigger.closest('[data-role="entity-row"]'); + if (!row) { + return; + } + const overrideId = row.dataset.overrideId; + if (overrideId) { + deleteManualOverride(overrideId); + } else { + const inlineOverride = removeTrigger.closest('[data-role="inline-override"]'); + if (inlineOverride) { + inlineOverride.hidden = true; + inlineOverride.setAttribute("aria-hidden", "true"); + } + } + } + }; + + [peopleListNode, entityListNode].forEach((list) => { + if (!list) return; + list.addEventListener("click", handleEntityListClick); + }); + + if (manualOverrideSearchButton) { + manualOverrideSearchButton.addEventListener("click", () => { + const query = manualOverrideQueryInput?.value?.trim() || ""; + performManualOverrideSearch(query); + }); + } + + if (manualOverrideQueryInput) { + manualOverrideQueryInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + performManualOverrideSearch(manualOverrideQueryInput.value.trim()); + } + }); + } + + if (manualOverrideAddCustomButton) { + manualOverrideAddCustomButton.addEventListener("click", () => { + const token = window.prompt("Enter the token to override:"); + if (!token) return; + highlightMode = "manual"; + createOverrideFromData({ token: token.trim(), source: "manual" }); + }); + } + + if (manualOverrideResultsList) { + manualOverrideResultsList.addEventListener("click", (event) => { + const resultButton = event.target.closest('[data-role="manual-override-result"]'); + if (!resultButton) return; + event.preventDefault(); + highlightMode = "manual"; + createOverrideFromData({ + token: resultButton.dataset.token || "", + normalized: resultButton.dataset.normalized || "", + context: resultButton.dataset.context || "", + pronunciation: resultButton.dataset.pronunciation || "", + voice: resultButton.dataset.voice || "", + source: resultButton.dataset.source || "search", + }); + }); + } + + if (manualOverrideList) { + manualOverrideList.addEventListener("input", (event) => { + const input = event.target.closest('[data-role="manual-override-pronunciation"]'); + if (!input) return; + const overrideId = input.dataset.overrideId || input.closest('[data-override-id]')?.dataset.overrideId; + const container = input.closest(".manual-override"); + const previewButton = container?.querySelector('[data-role="speaker-preview"]'); + if (previewButton) { + const fallback = container?.dataset.token || input.placeholder || ""; + const previewValue = joinPossessivePreviewSamples(input.value.trim(), fallback); + previewButton.dataset.previewText = previewValue || input.value.trim() || fallback; + } + if (overrideId) { + markOverrideDirty(overrideId); + } + }); + + manualOverrideList.addEventListener("change", (event) => { + const select = event.target.closest('[data-role="manual-override-voice"]'); + if (!select) return; + const overrideId = select.dataset.overrideId || select.closest('[data-override-id]')?.dataset.overrideId; + const container = select.closest(".manual-override"); + const previewButton = container?.querySelector('[data-role="speaker-preview"]'); + if (previewButton) { + previewButton.dataset.voice = select.value || baseVoice || select.dataset.defaultVoice || ""; + } + if (overrideId) { + markOverrideDirty(overrideId); + } + }); + + manualOverrideList.addEventListener("click", (event) => { + const previewButton = event.target.closest('[data-role="speaker-preview"]'); + if (previewButton?.dataset.overrideId) { + void flushManualOverrides({ overrideId: previewButton.dataset.overrideId }); + } + const deleteButton = event.target.closest('[data-role="manual-override-delete"]'); + if (!deleteButton) return; + event.preventDefault(); + const overrideId = deleteButton.dataset.overrideId; + if (!overrideId) return; + deleteManualOverride(overrideId); + }); + } + + const initialTab = tabButtons.find((button) => button.classList.contains("is-active")); + activateEntityTab(initialTab?.dataset.panel || "people"); + renderEntitySummary(); + renderManualOverrides(); + renderHeteronymOverrides(); + triggerEntitiesRefresh(); + } + + const handleDeferredSubmit = (event) => { + const hasDirty = dirtyOverrideIds.size > 0; + const hasPendingFlush = Boolean(overrideFlushPromise); + if (!hasDirty && !hasPendingFlush) return; + if (form.dataset.skipDirtyFlush === "true") { + delete form.dataset.skipDirtyFlush; + return; + } + event.preventDefault(); + const submitter = event.submitter || null; + const resumeSubmission = () => { + form.dataset.skipDirtyFlush = "true"; + if (typeof form.requestSubmit === "function") { + form.requestSubmit(submitter || undefined); + } else if (submitter && typeof submitter.click === "function") { + submitter.click(); + } else if (typeof form.submit === "function") { + form.submit(); + } + }; + + const flushPromise = overrideFlushPromise || flushManualOverrides(); + if (!flushPromise) { + resumeSubmission(); + return; + } + flushPromise.finally(resumeSubmission); + }; + + form.addEventListener("submit", handleDeferredSubmit); + + form.addEventListener("click", (event) => { + const previewFromOverride = event.target.closest('[data-role="speaker-preview"]'); + if (previewFromOverride) { + const entityRow = previewFromOverride.closest('[data-role="entity-row"]'); + const inlineOverride = entityRow?.querySelector('[data-role="inline-override"]'); + if (inlineOverride && !inlineOverride.hidden) { + const inlineState = extractInlineOverrideState(inlineOverride, entityRow); + syncInlinePreviewUI(entityRow, inlineOverride, inlineState, previewFromOverride); + void persistInlineOverrideState(inlineState); + } + } + if (previewFromOverride?.dataset.overrideId) { + void flushManualOverrides({ overrideId: previewFromOverride.dataset.overrideId }); + } + const genderMenu = event.target.closest('[data-role="gender-menu"]'); + const genderPill = event.target.closest('[data-role="gender-pill"]'); + if (!genderMenu && !genderPill) { + hideGenderMenus(); + } + + if (genderPill) { + event.preventDefault(); + const menu = genderPill.parentElement?.querySelector('[data-role="gender-menu"]'); + const isOpen = menu && !menu.hidden; + hideGenderMenus(); + if (menu && !isOpen) { + menu.hidden = false; + menu.setAttribute("aria-hidden", "false"); + genderPill.classList.add("is-open"); + } + return; + } + + const genderOption = event.target.closest('[data-role="gender-option"]'); + if (genderOption) { + event.preventDefault(); + const container = genderOption.closest('[data-role="speaker-gender"]'); + setGenderForSpeaker(container, genderOption.dataset.value); + hideGenderMenus(); + return; + } + + const nextSampleButton = event.target.closest('[data-role="speaker-next-sample"]'); + if (nextSampleButton) { + event.preventDefault(); + const container = nextSampleButton.closest(".speaker-list__item"); + if (!container) return; + const currentIndex = sampleIndexState.get(container) || 0; + setSpeakerSample(container, currentIndex + 1); + return; + } + + const clearMixButton = event.target.closest('[data-role="clear-mix"]'); + if (clearMixButton) { + event.preventDefault(); + const container = clearMixButton.closest(".speaker-list__item"); + applyFormulaToSpeaker(container, ""); + return; + } + + const generateButton = event.target.closest('[data-role="generate-voice"]'); + if (generateButton) { + event.preventDefault(); + const container = generateButton.closest(".speaker-list__item"); + if (!container) return; + const genderInput = container.querySelector('[data-role="gender-input"]'); + const genderValue = genderInput?.value || "unknown"; + const mix = buildRandomMix(genderValue); + if (!mix) { + console.warn("No voices available to generate a mix for", genderValue); + return; + } + const formula = formatMix(normaliseMix(mix)); + applyFormulaToSpeaker(container, formula); + return; + } + + const modalTrigger = event.target.closest('[data-role="open-voice-browser"]'); + if (modalTrigger) { + event.preventDefault(); + const container = modalTrigger.closest(".speaker-list__item"); + if (!container) return; + const sampleIndex = Number.parseInt(modalTrigger.dataset.sampleIndex || "0", 10); + openVoiceBrowser(container, Number.isNaN(sampleIndex) ? 0 : sampleIndex); + return; + } + + const chip = event.target.closest('[data-role="recommended-voice"]'); + if (!chip) return; + event.preventDefault(); + const container = chip.closest(".speaker-list__item"); + if (!container) return; + const select = container.querySelector('[data-role="speaker-voice"]'); + if (!select) return; + select.value = chip.dataset.voice || ""; + select.dispatchEvent(new Event("change", { bubbles: true })); + select.dataset.prevManual = select.value || ""; + }); + + form.addEventListener("input", (event) => { + const inlineOverride = event.target.closest('[data-role="inline-override"]'); + if (!inlineOverride || inlineOverride.hidden) { + return; + } + const entityRow = inlineOverride.closest('[data-role="entity-row"]'); + const inlineState = extractInlineOverrideState(inlineOverride, entityRow); + syncInlinePreviewUI(entityRow, inlineOverride, inlineState); + if (inlineState) { + setManualOverrideStatus("Unsaved changes", "pending"); + } + }); + + form.addEventListener("change", (event) => { + const inlineOverride = event.target.closest('[data-role="inline-override"]'); + if (!inlineOverride || inlineOverride.hidden) { + return; + } + const entityRow = inlineOverride.closest('[data-role="entity-row"]'); + const inlineState = extractInlineOverrideState(inlineOverride, entityRow); + syncInlinePreviewUI(entityRow, inlineOverride, inlineState); + if (inlineState) { + setManualOverrideStatus("Unsaved changes", "pending"); + } + }); + + document.addEventListener("click", (event) => { + if (!form.contains(event.target)) { + hideGenderMenus(); + } + }); +}; + +window.AbogenPrepare = window.AbogenPrepare || {}; +window.AbogenPrepare.init = initPrepare; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => initPrepare()); +} else { + initPrepare(); +} diff --git a/abogen/webui/static/queue.js b/abogen/webui/static/queue.js new file mode 100644 index 0000000..184467a --- /dev/null +++ b/abogen/webui/static/queue.js @@ -0,0 +1,45 @@ +import { initReaderUI } from "./reader.js"; + +const queueState = (window.AbogenQueueState = window.AbogenQueueState || { + boundOverwritePrompt: false, +}); + +const handleOverwritePrompt = (event) => { + const detail = event?.detail || {}; + const title = detail.title || "this item"; + const message = detail.message || `Audiobookshelf already has "${title}". Overwrite?`; + if (!window.confirm(message)) { + return; + } + + const url = detail.url; + if (!url || typeof htmx === "undefined") { + return; + } + + const target = detail.target || "#jobs-panel"; + const values = { overwrite: "true" }; + if (detail.values && typeof detail.values === "object") { + Object.assign(values, detail.values); + } + + htmx.ajax("POST", url, { + target, + swap: "innerHTML", + values, + }); +}; + +const initQueuePage = () => { + initReaderUI(); + if (!queueState.boundOverwritePrompt) { + queueState.boundOverwritePrompt = true; + document.addEventListener("audiobookshelf-overwrite-prompt", handleOverwritePrompt); + } +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initQueuePage, { once: true }); +} else { + initQueuePage(); +} diff --git a/abogen/webui/static/reader.js b/abogen/webui/static/reader.js new file mode 100644 index 0000000..fd26820 --- /dev/null +++ b/abogen/webui/static/reader.js @@ -0,0 +1,221 @@ +const readerButtonRegistry = new WeakSet(); +let initialized = false; +let readerModal = null; +let readerFrame = null; +let readerHint = null; +let readerTitle = null; +let readerTrigger = null; +let defaultReaderHint = ""; + +const resolveEventMatch = (event, selector) => { + const target = event.target; + if (target instanceof Element) { + const match = target.closest(selector); + if (match) { + return match; + } + } + const path = typeof event.composedPath === "function" ? event.composedPath() : []; + for (const node of path) { + if (node instanceof Element) { + if (node.matches(selector)) { + return node; + } + const match = node.closest(selector); + if (match) { + return match; + } + } + } + return null; +}; + +const closeReaderModal = () => { + if (!readerModal) { + return; + } + if (readerModal.hidden) { + return; + } + readerModal.hidden = true; + readerModal.removeAttribute("data-open"); + document.body.classList.remove("modal-open"); + if (readerFrame) { + const frameWindow = readerFrame.contentWindow; + if (frameWindow) { + try { + frameWindow.postMessage({ type: "abogen:reader:pause", currentTime: 0 }, window.location.origin); + } catch (error) { + // Ignore cross-origin messaging errors. + } + } + window.setTimeout(() => { + readerFrame.src = "about:blank"; + }, 75); + } + if (readerHint && defaultReaderHint) { + readerHint.textContent = defaultReaderHint; + } + if (readerTitle) { + readerTitle.textContent = "Read & listen"; + } + if (readerTrigger instanceof HTMLElement) { + try { + readerTrigger.focus({ preventScroll: true }); + } catch (error) { + // Ignore focus errors. + } + } + readerTrigger = null; +}; + +const createBindReaderButtons = (openReaderModal) => { + return (root) => { + const context = root instanceof Element ? root : document; + const buttons = context.querySelectorAll('[data-role="open-reader"]'); + buttons.forEach((button) => { + if (!(button instanceof HTMLElement)) { + return; + } + if (readerButtonRegistry.has(button)) { + return; + } + button.addEventListener("click", (event) => { + event.preventDefault(); + openReaderModal(button); + }); + readerButtonRegistry.add(button); + }); + }; +}; + +export const initReaderUI = (options = {}) => { + if (initialized) { + return { + bindReaderButtons: createBindReaderButtons((trigger) => { + if (typeof options.onBeforeOpen === "function") { + options.onBeforeOpen(); + } + openReader(trigger, options); + }), + closeReaderModal, + }; + } + + readerModal = document.querySelector('[data-role="reader-modal"]'); + readerFrame = readerModal?.querySelector('[data-role="reader-frame"]') || null; + readerHint = readerModal?.querySelector('[data-role="reader-modal-hint"]') || null; + readerTitle = readerModal?.querySelector('#reader-modal-title') || null; + defaultReaderHint = readerHint?.textContent || ""; + + const openReaderModal = (trigger) => { + if (typeof options.onBeforeOpen === "function") { + options.onBeforeOpen(); + } + if (!(trigger instanceof HTMLElement)) { + return; + } + const url = trigger.dataset.readerUrl || ""; + if (!url) { + return; + } + if (!readerModal || !readerFrame) { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + readerTrigger = trigger; + const bookTitle = trigger.dataset.bookTitle || ""; + if (readerTitle) { + readerTitle.textContent = bookTitle ? `${bookTitle} · reader` : "Read & listen"; + } + if (readerHint) { + readerHint.textContent = bookTitle + ? `Preview ${bookTitle} directly in your browser.` + : defaultReaderHint; + } + readerModal.hidden = false; + readerModal.dataset.open = "true"; + document.body.classList.add("modal-open"); + readerFrame.src = url; + try { + readerFrame.focus({ preventScroll: true }); + } catch (error) { + // Ignore focus errors. + } + }; + + const bindReaderButtons = createBindReaderButtons(openReaderModal); + bindReaderButtons(); + + document.addEventListener("click", (event) => { + const closeButton = resolveEventMatch(event, '[data-role="reader-modal-close"]'); + if (closeButton) { + event.preventDefault(); + closeReaderModal(); + return; + } + const trigger = resolveEventMatch(event, '[data-role="open-reader"]'); + if (trigger instanceof HTMLElement) { + event.preventDefault(); + openReaderModal(trigger); + } + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeReaderModal(); + } + }); + + document.addEventListener("htmx:afterSwap", (event) => { + const fragment = event?.detail?.target; + if (fragment instanceof Element) { + bindReaderButtons(fragment); + } else { + bindReaderButtons(); + } + }); + + initialized = true; + + return { + bindReaderButtons, + closeReaderModal, + }; +}; + +const openReader = (trigger, options) => { + if (typeof options.onBeforeOpen === "function") { + options.onBeforeOpen(); + } + if (!(trigger instanceof HTMLElement)) { + return; + } + const url = trigger.dataset.readerUrl || ""; + if (!url) { + return; + } + if (!readerModal || !readerFrame) { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + readerTrigger = trigger; + const bookTitle = trigger.dataset.bookTitle || ""; + if (readerTitle) { + readerTitle.textContent = bookTitle ? `${bookTitle} · reader` : "Read & listen"; + } + if (readerHint) { + readerHint.textContent = bookTitle + ? `Preview ${bookTitle} directly in your browser.` + : defaultReaderHint; + } + readerModal.hidden = false; + readerModal.dataset.open = "true"; + document.body.classList.add("modal-open"); + readerFrame.src = url; + try { + readerFrame.focus({ preventScroll: true }); + } catch (error) { + // Ignore focus errors. + } +}; diff --git a/abogen/webui/static/settings.js b/abogen/webui/static/settings.js new file mode 100644 index 0000000..4a3e187 --- /dev/null +++ b/abogen/webui/static/settings.js @@ -0,0 +1,882 @@ +const form = document.querySelector('.settings__form'); +const navButtons = Array.from(document.querySelectorAll('.settings-nav__item')); +const panels = Array.from(document.querySelectorAll('.settings-panel')); +const llmNavButton = navButtons.find((button) => button.dataset.section === 'llm'); + +const statusSelectors = { + llm: document.querySelector('[data-role="llm-preview-status"]'), + normalization: document.querySelector('[data-role="normalization-preview-status"]'), + calibre: document.querySelector('[data-role="calibre-test-status"]'), + audiobookshelf: document.querySelector('[data-role="audiobookshelf-test-status"]'), +}; + +const outputAreas = { + llm: document.querySelector('[data-role="llm-preview-output"]'), + normalization: document.querySelector('[data-role="normalization-preview-output"]'), +}; + +const normalizationAudio = document.querySelector('[data-role="normalization-preview-audio"]'); + +const folderModal = document.querySelector('[data-role="audiobookshelf-folder-modal"]'); +const folderModalOverlay = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-overlay"]') : null; +const folderList = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-list"]') : null; +const folderStatusMessage = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-status"]') : null; +const folderFilter = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-filter"]') : null; +const folderEmptyState = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-empty"]') : null; +const defaultFolderEmptyMessage = folderEmptyState ? folderEmptyState.textContent : 'No folders match your filter.'; +let folderModalOpener = null; +let folderModalPreviousFocus = null; +let audiobookshelfFolderSource = []; + +const contractionModal = document.querySelector('[data-role="contraction-modal"]'); +const contractionModalOverlay = contractionModal ? contractionModal.querySelector('[data-role="contraction-modal-overlay"]') : null; +let contractionModalOpener = null; +let contractionModalPreviousFocus = null; + +function setStatus(target, message, state) { + if (!target) { + return; + } + target.textContent = message || ''; + if (state) { + target.dataset.state = state; + } else { + delete target.dataset.state; + } +} + +function clearStatus(target) { + setStatus(target, '', null); +} + +function activatePanel(section) { + if (!section) { + return; + } + navButtons.forEach((button) => { + const isActive = button.dataset.section === section; + button.classList.toggle('is-active', isActive); + }); + let activePanel = null; + panels.forEach((panel) => { + const isActive = panel.dataset.section === section; + panel.classList.toggle('is-active', isActive); + if (isActive) { + activePanel = panel; + } + }); + if (activePanel) { + const focusable = activePanel.querySelector('input, select, textarea'); + if (focusable) { + window.requestAnimationFrame(() => { + focusable.focus({ preventScroll: false }); + }); + } + } +} + +function initNavigation() { + if (!navButtons.length || !panels.length) { + return; + } + navButtons.forEach((button) => { + button.addEventListener('click', () => { + activatePanel(button.dataset.section); + if (button.dataset.section) { + window.history.replaceState(null, '', `#${button.dataset.section}`); + } + }); + }); + const hash = window.location.hash.replace('#', ''); + if (hash && panels.some((panel) => panel.dataset.section === hash)) { + activatePanel(hash); + } else { + const current = navButtons.find((button) => button.classList.contains('is-active')); + if (current) { + activatePanel(current.dataset.section); + } + } + window.addEventListener('hashchange', () => { + const section = window.location.hash.replace('#', ''); + if (section) { + activatePanel(section); + } + }); +} + +function parseNumber(value, fallback) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function normalizeFolderToken(value) { + return String(value || '').trim().toLowerCase(); +} + +function setFolderModalStatus(message, state) { + if (!folderStatusMessage) { + return; + } + folderStatusMessage.textContent = message || ''; + if (state) { + folderStatusMessage.dataset.state = state; + folderStatusMessage.hidden = false; + } else { + delete folderStatusMessage.dataset.state; + folderStatusMessage.hidden = !message; + } +} + +function clearFolderModalContents() { + if (folderList) { + folderList.innerHTML = ''; + } + if (folderEmptyState) { + folderEmptyState.textContent = defaultFolderEmptyMessage; + folderEmptyState.hidden = true; + } +} + +function openFolderModal(opener) { + if (!folderModal) { + return; + } + folderModalOpener = opener || null; + folderModalPreviousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + folderModal.hidden = false; + folderModal.dataset.open = 'true'; + document.body.classList.add('modal-open'); + if (folderFilter) { + folderFilter.value = ''; + folderFilter.disabled = true; + } + clearFolderModalContents(); + setFolderModalStatus('Loading folders...', 'loading'); +} + +function closeFolderModal(event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + if (!folderModal || folderModal.hidden) { + return; + } + folderModal.dataset.open = 'false'; + folderModal.hidden = true; + document.body.classList.remove('modal-open'); + audiobookshelfFolderSource = []; + if (folderFilter) { + folderFilter.value = ''; + folderFilter.disabled = false; + } + clearFolderModalContents(); + setFolderModalStatus('', null); + const focusTarget = folderModalPreviousFocus && typeof folderModalPreviousFocus.focus === 'function' + ? folderModalPreviousFocus + : folderModalOpener; + if (focusTarget && typeof focusTarget.focus === 'function') { + focusTarget.focus({ preventScroll: false }); + } + folderModalPreviousFocus = null; + folderModalOpener = null; +} + +function openContractionModal(opener) { + if (!contractionModal) { + return; + } + contractionModalOpener = opener || null; + contractionModalPreviousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + contractionModal.hidden = false; + contractionModal.dataset.open = 'true'; + document.body.classList.add('modal-open'); + const focusTarget = contractionModal.querySelector('input, button, select, textarea'); + if (focusTarget instanceof HTMLElement) { + focusTarget.focus({ preventScroll: true }); + } +} + +function closeContractionModal(event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + if (!contractionModal || contractionModal.hidden) { + return; + } + contractionModal.dataset.open = 'false'; + contractionModal.hidden = true; + document.body.classList.remove('modal-open'); + const focusTarget = + (contractionModalPreviousFocus && typeof contractionModalPreviousFocus.focus === 'function' + ? contractionModalPreviousFocus + : contractionModalOpener) || null; + if (focusTarget && typeof focusTarget.focus === 'function') { + focusTarget.focus({ preventScroll: true }); + } + contractionModalPreviousFocus = null; + contractionModalOpener = null; +} + +function initContractionModal() { + if (!contractionModal) { + return; + } + const openButton = document.querySelector('[data-action="contraction-modal-open"]'); + if (openButton) { + openButton.addEventListener('click', () => openContractionModal(openButton)); + } + const closeButtons = contractionModal.querySelectorAll('[data-action="contraction-modal-close"]'); + closeButtons.forEach((button) => { + button.addEventListener('click', closeContractionModal); + }); + if (contractionModalOverlay) { + contractionModalOverlay.addEventListener('click', closeContractionModal); + } + contractionModal.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + closeContractionModal(event); + } + }); +} + +function renderFolderList(query) { + if (!folderList) { + return; + } + folderList.innerHTML = ''; + const normalizedQuery = normalizeFolderToken(query); + const matches = audiobookshelfFolderSource.filter((entry) => { + const tokens = [ + normalizeFolderToken(entry.name), + normalizeFolderToken(entry.path), + normalizeFolderToken(entry.id), + ]; + return !normalizedQuery || tokens.some((token) => token.includes(normalizedQuery)); + }); + if (!matches.length) { + if (folderEmptyState) { + folderEmptyState.textContent = normalizedQuery ? defaultFolderEmptyMessage : 'No folders found for this library.'; + folderEmptyState.hidden = false; + } + return; + } + if (folderEmptyState) { + folderEmptyState.textContent = defaultFolderEmptyMessage; + folderEmptyState.hidden = true; + } + matches.forEach((entry) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'folder-picker__item'; + button.setAttribute('role', 'option'); + if (entry.id) { + button.dataset.folderId = entry.id; + } + const displayName = entry.name || entry.path || entry.id || 'Unnamed folder'; + const nameEl = document.createElement('span'); + nameEl.className = 'folder-picker__item-name'; + nameEl.textContent = displayName; + button.appendChild(nameEl); + if (entry.path && (!entry.name || entry.path.toLowerCase() !== entry.name.toLowerCase())) { + const pathEl = document.createElement('span'); + pathEl.className = 'folder-picker__item-path'; + pathEl.textContent = entry.path; + button.appendChild(pathEl); + } + if (entry.id) { + const idEl = document.createElement('span'); + idEl.className = 'folder-picker__item-id'; + idEl.textContent = entry.id; + button.appendChild(idEl); + } + button.addEventListener('click', () => handleFolderSelection(entry)); + folderList.appendChild(button); + }); +} + +function populateFolderPicker(entries) { + audiobookshelfFolderSource = Array.isArray(entries) ? entries : []; + if (!audiobookshelfFolderSource.length) { + if (folderFilter) { + folderFilter.value = ''; + folderFilter.disabled = true; + } + setFolderModalStatus('No folders found for this library.', 'info'); + if (folderEmptyState) { + folderEmptyState.textContent = 'No folders found for this library.'; + folderEmptyState.hidden = false; + } + return; + } + if (folderFilter) { + folderFilter.disabled = false; + folderFilter.value = ''; + folderFilter.focus({ preventScroll: true }); + } + setFolderModalStatus('', null); + if (folderEmptyState) { + folderEmptyState.textContent = defaultFolderEmptyMessage; + folderEmptyState.hidden = true; + } + renderFolderList(''); +} + +function handleFolderSelection(entry) { + const folderInput = form ? form.querySelector('#audiobookshelf_folder_id') : null; + if (folderInput) { + folderInput.value = entry.id || ''; + folderInput.dispatchEvent(new Event('input', { bubbles: true })); + } + closeFolderModal(); + const status = statusSelectors.audiobookshelf; + if (status) { + const label = entry.name || entry.path || entry.id || 'selected folder'; + setStatus(status, `Selected folder '${label}'.`, 'success'); + } +} + +function initFolderPicker() { + if (!folderModal) { + return; + } + const closeButtons = folderModal.querySelectorAll('[data-action="audiobookshelf-folder-close"]'); + closeButtons.forEach((button) => { + button.addEventListener('click', closeFolderModal); + }); + if (folderModalOverlay) { + folderModalOverlay.addEventListener('click', closeFolderModal); + } + if (folderFilter) { + folderFilter.addEventListener('input', () => renderFolderList(folderFilter.value)); + } + folderModal.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + closeFolderModal(); + } + }); +} + +function collectLLMFields() { + const baseUrl = form.querySelector('#llm_base_url'); + const apiKey = form.querySelector('#llm_api_key'); + const model = form.querySelector('#llm_model'); + const prompt = form.querySelector('#llm_prompt'); + const timeout = form.querySelector('#llm_timeout'); + const context = form.querySelector('input[name="llm_context_mode"]:checked'); + return { + base_url: baseUrl ? baseUrl.value.trim() : '', + api_key: apiKey ? apiKey.value.trim() : '', + model: model ? model.value.trim() : '', + prompt: prompt ? prompt.value : '', + context_mode: context ? context.value : 'sentence', + timeout: timeout ? parseNumber(timeout.value, 30) : 30, + }; +} + +function updateModelOptions(models) { + const select = form.querySelector('#llm_model'); + if (!select) { + return; + } + const current = select.dataset.currentModel || select.value; + select.innerHTML = ''; + if (!Array.isArray(models) || !models.length) { + const option = document.createElement('option'); + option.value = ''; + option.textContent = 'No models found'; + select.appendChild(option); + select.dataset.currentModel = ''; + select.disabled = true; + return; + } + const fragment = document.createDocumentFragment(); + let matchedCurrent = false; + models.forEach((entry) => { + let identifier = ''; + let label = ''; + if (typeof entry === 'string') { + identifier = entry; + label = entry; + } else if (entry && typeof entry === 'object') { + identifier = String(entry.id || entry.name || entry.label || '').trim(); + label = String(entry.label || entry.name || identifier || '').trim(); + } + if (!identifier) { + return; + } + if (!label) { + label = identifier; + } + const option = document.createElement('option'); + option.value = identifier; + option.textContent = label; + if (identifier === current) { + option.selected = true; + matchedCurrent = true; + } + fragment.appendChild(option); + }); + select.appendChild(fragment); + if (!matchedCurrent && select.options.length) { + select.selectedIndex = 0; + } + select.dataset.currentModel = select.value || ''; + select.disabled = false; +} + +async function refreshModels(button) { + const status = statusSelectors.llm; + const llmFields = collectLLMFields(); + if (!llmFields.base_url) { + setStatus(status, 'Enter a base URL before refreshing models.', 'error'); + return; + } + clearStatus(status); + setStatus(status, 'Fetching models…'); + button.disabled = true; + try { + const response = await fetch('/api/llm/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + base_url: llmFields.base_url, + api_key: llmFields.api_key, + timeout: llmFields.timeout, + }), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Unable to load models.'); + } + updateModelOptions(payload.models || []); + const count = Array.isArray(payload.models) ? payload.models.length : 0; + if (count) { + setStatus(status, `Loaded ${count} model${count === 1 ? '' : 's'}.`, 'success'); + } else { + setStatus(status, 'No models were returned.', 'error'); + } + } catch (error) { + setStatus(status, error instanceof Error ? error.message : 'Failed to load models.', 'error'); + } finally { + button.disabled = false; + } +} + +async function previewLLM(button) { + const status = statusSelectors.llm; + const output = outputAreas.llm; + const previewText = document.querySelector('#llm_preview_text'); + if (!previewText) { + return; + } + const llmFields = collectLLMFields(); + if (!llmFields.base_url) { + setStatus(status, 'Enter a base URL to preview.', 'error'); + return; + } + if (!llmFields.model) { + setStatus(status, 'Select a model to preview.', 'error'); + return; + } + const sample = previewText.value.trim(); + if (!sample) { + setStatus(status, 'Add some sample text first.', 'error'); + return; + } + clearStatus(status); + if (output) { + output.textContent = ''; + } + setStatus(status, 'Generating preview…'); + button.disabled = true; + try { + const response = await fetch('/api/llm/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: sample, + base_url: llmFields.base_url, + api_key: llmFields.api_key, + model: llmFields.model, + prompt: llmFields.prompt, + context_mode: llmFields.context_mode, + timeout: llmFields.timeout, + }), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Preview failed.'); + } + if (output) { + output.textContent = payload.normalized_text || ''; + } + setStatus(status, 'Preview ready.', 'success'); + } catch (error) { + if (output) { + output.textContent = ''; + } + setStatus(status, error instanceof Error ? error.message : 'Preview failed.', 'error'); + } finally { + button.disabled = false; + } +} + +function collectNormalizationSettings() { + if (!form) { + return null; + } + const normalization = { + normalization_numbers: Boolean(form.querySelector('input[name="normalization_numbers"]')?.checked), + normalization_currency: Boolean(form.querySelector('input[name="normalization_currency"]')?.checked), + normalization_titles: Boolean(form.querySelector('input[name="normalization_titles"]')?.checked), + normalization_footnotes: Boolean(form.querySelector('input[name="normalization_footnotes"]')?.checked), + normalization_terminal: Boolean(form.querySelector('input[name="normalization_terminal"]')?.checked), + normalization_caps_quotes: Boolean(form.querySelector('input[name="normalization_caps_quotes"]')?.checked), + normalization_phoneme_hints: Boolean(form.querySelector('input[name="normalization_phoneme_hints"]')?.checked), + normalization_apostrophes_contractions: Boolean(form.querySelector('input[name="normalization_apostrophes_contractions"]')?.checked), + normalization_apostrophes_plural_possessives: Boolean(form.querySelector('input[name="normalization_apostrophes_plural_possessives"]')?.checked), + normalization_apostrophes_sibilant_possessives: Boolean(form.querySelector('input[name="normalization_apostrophes_sibilant_possessives"]')?.checked), + normalization_apostrophes_decades: Boolean(form.querySelector('input[name="normalization_apostrophes_decades"]')?.checked), + normalization_apostrophes_leading_elisions: Boolean(form.querySelector('input[name="normalization_apostrophes_leading_elisions"]')?.checked), + normalization_contraction_aux_be: Boolean(form.querySelector('input[name="normalization_contraction_aux_be"]')?.checked), + normalization_contraction_aux_have: Boolean(form.querySelector('input[name="normalization_contraction_aux_have"]')?.checked), + normalization_contraction_modal_will: Boolean(form.querySelector('input[name="normalization_contraction_modal_will"]')?.checked), + normalization_contraction_modal_would: Boolean(form.querySelector('input[name="normalization_contraction_modal_would"]')?.checked), + normalization_contraction_negation_not: Boolean(form.querySelector('input[name="normalization_contraction_negation_not"]')?.checked), + normalization_contraction_let_us: Boolean(form.querySelector('input[name="normalization_contraction_let_us"]')?.checked), + normalization_apostrophe_mode: form.querySelector('input[name="normalization_apostrophe_mode"]:checked')?.value || 'spacy', + }; + return normalization; +} + +function collectCalibreFields() { + if (!form) { + return {}; + } + const enabled = Boolean(form.querySelector('input[name="calibre_opds_enabled"]')?.checked); + const baseUrl = form.querySelector('#calibre_opds_base_url')?.value?.trim() || ''; + const username = form.querySelector('#calibre_opds_username')?.value?.trim() || ''; + const passwordInput = form.querySelector('#calibre_opds_password'); + const password = passwordInput ? passwordInput.value : ''; + const hasSecret = passwordInput?.dataset.hasSecret === 'true'; + const clearSaved = Boolean(form.querySelector('input[name="calibre_opds_password_clear"]')?.checked); + const useSavedPassword = !password && hasSecret && !clearSaved; + const verify = Boolean(form.querySelector('input[name="calibre_opds_verify_ssl"]')?.checked); + return { + enabled, + base_url: baseUrl, + username, + password, + verify_ssl: verify, + use_saved_password: useSavedPassword, + clear_saved_password: clearSaved, + }; +} + +function collectAudiobookshelfFields() { + if (!form) { + return {}; + } + const baseUrl = form.querySelector('#audiobookshelf_base_url')?.value?.trim() || ''; + const libraryId = form.querySelector('#audiobookshelf_library_id')?.value?.trim() || ''; + const collectionId = form.querySelector('#audiobookshelf_collection_id')?.value?.trim() || ''; + const folderId = form.querySelector('#audiobookshelf_folder_id')?.value?.trim() || ''; + const tokenInput = form.querySelector('#audiobookshelf_api_token'); + const apiToken = tokenInput?.value?.trim() || ''; + const hasSecret = tokenInput?.dataset.hasSecret === 'true'; + const clearToken = Boolean(form.querySelector('input[name="audiobookshelf_api_token_clear"]')?.checked); + const useSavedToken = !apiToken && hasSecret && !clearToken; + const timeoutInput = form.querySelector('#audiobookshelf_timeout'); + const timeout = parseNumber(timeoutInput?.value, 30); + return { + enabled: Boolean(form.querySelector('input[name="audiobookshelf_enabled"]')?.checked), + auto_send: Boolean(form.querySelector('input[name="audiobookshelf_auto_send"]')?.checked), + verify_ssl: Boolean(form.querySelector('input[name="audiobookshelf_verify_ssl"]')?.checked), + base_url: baseUrl, + library_id: libraryId, + collection_id: collectionId, + folder_id: folderId, + api_token: apiToken, + use_saved_token: useSavedToken, + clear_saved_token: clearToken, + timeout, + send_cover: Boolean(form.querySelector('input[name="audiobookshelf_send_cover"]')?.checked), + send_chapters: Boolean(form.querySelector('input[name="audiobookshelf_send_chapters"]')?.checked), + send_subtitles: Boolean(form.querySelector('input[name="audiobookshelf_send_subtitles"]')?.checked), + }; +} + +function updateLLMNavState() { + if (!llmNavButton) { + return; + } + const fields = collectLLMFields(); + if (fields.base_url && fields.api_key) { + llmNavButton.classList.remove('is-disabled'); + } else { + llmNavButton.classList.add('is-disabled'); + } +} + +async function testCalibre(button) { + const status = statusSelectors.calibre; + const fields = collectCalibreFields(); + if (!status) { + return; + } + if (!fields.base_url) { + setStatus(status, 'Enter a Calibre OPDS base URL to test.', 'error'); + return; + } + clearStatus(status); + setStatus(status, 'Testing connection…'); + button.disabled = true; + try { + const response = await fetch('/api/integrations/calibre-opds/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Calibre test failed.'); + } + setStatus(status, payload.message || 'Connection successful.', 'success'); + } catch (error) { + setStatus(status, error instanceof Error ? error.message : 'Calibre test failed.', 'error'); + } finally { + button.disabled = false; + } +} + +async function testAudiobookshelf(button) { + const status = statusSelectors.audiobookshelf; + const fields = collectAudiobookshelfFields(); + if (!status) { + return; + } + const hasToken = Boolean(fields.api_token) || Boolean(fields.use_saved_token); + if (!fields.base_url || !hasToken || !fields.library_id || !fields.folder_id) { + setStatus(status, 'Enter the base URL, API token, library ID, and folder name or ID to test.', 'error'); + return; + } + clearStatus(status); + setStatus(status, 'Testing connection…'); + button.disabled = true; + try { + const response = await fetch('/api/integrations/audiobookshelf/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Audiobookshelf test failed.'); + } + setStatus(status, payload.message || 'Connection successful.', 'success'); + } catch (error) { + setStatus(status, error instanceof Error ? error.message : 'Audiobookshelf test failed.', 'error'); + } finally { + button.disabled = false; + } +} + +async function browseAudiobookshelfFolders(button) { + const status = statusSelectors.audiobookshelf; + const fields = collectAudiobookshelfFields(); + if (!status) { + return; + } + const hasToken = Boolean(fields.api_token) || Boolean(fields.use_saved_token); + if (!fields.base_url || !hasToken || !fields.library_id) { + setStatus(status, 'Enter the base URL, API token, and library ID before browsing folders.', 'error'); + return; + } + clearStatus(status); + openFolderModal(button); + if (!folderModal) { + setStatus(status, 'Folder picker is unavailable in this view.', 'error'); + return; + } + button.disabled = true; + try { + const response = await fetch('/api/integrations/audiobookshelf/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Folder lookup failed.'); + } + const folders = Array.isArray(payload.folders) ? payload.folders : []; + const modalActive = folderModal && !folderModal.hidden; + if (!folders.length) { + const message = payload.message || 'No folders found for this library.'; + setStatus(status, message, 'info'); + if (modalActive) { + clearFolderModalContents(); + setFolderModalStatus(message, 'info'); + } + return; + } + if (!modalActive) { + setStatus(status, 'Folders loaded.', 'info'); + return; + } + populateFolderPicker(folders); + setStatus(status, 'Choose a folder below.', 'info'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Folder lookup failed.'; + setStatus(status, message, 'error'); + if (folderModal && !folderModal.hidden) { + clearFolderModalContents(); + setFolderModalStatus(message, 'error'); + } + } finally { + button.disabled = false; + } +} + +async function previewNormalization(button) { + const status = statusSelectors.normalization; + const output = outputAreas.normalization; + const textArea = document.querySelector('#normalization_sample_text'); + const voiceSelect = document.querySelector('#normalization_sample_voice'); + if (!textArea) { + return; + } + const sample = textArea.value.trim(); + if (!sample) { + setStatus(status, 'Enter some text to preview.', 'error'); + return; + } + clearStatus(status); + if (output) { + output.textContent = ''; + } + if (normalizationAudio) { + normalizationAudio.hidden = true; + normalizationAudio.removeAttribute('src'); + } + setStatus(status, 'Building preview…'); + const normalization = collectNormalizationSettings(); + if (!normalization) { + setStatus(status, 'Unable to gather normalization settings.', 'error'); + return; + } + const llmFields = collectLLMFields(); + try { + const response = await fetch('/api/normalization/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: sample, + voice: voiceSelect ? voiceSelect.value : undefined, + normalization, + llm: { + llm_base_url: llmFields.base_url, + llm_api_key: llmFields.api_key, + llm_model: llmFields.model, + llm_prompt: llmFields.prompt, + llm_context_mode: llmFields.context_mode, + llm_timeout: llmFields.timeout, + }, + max_seconds: 8, + }), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Preview failed.'); + } + if (output) { + output.textContent = payload.normalized_text || ''; + } + if (payload.audio_base64 && normalizationAudio) { + normalizationAudio.src = `data:audio/wav;base64,${payload.audio_base64}`; + normalizationAudio.hidden = false; + normalizationAudio.load(); + normalizationAudio.play().catch(() => { + /* autoplay can fail; ignore */ + }); + } + setStatus(status, 'Preview updated.', 'success'); + } catch (error) { + if (output) { + output.textContent = ''; + } + if (normalizationAudio) { + normalizationAudio.hidden = true; + normalizationAudio.removeAttribute('src'); + } + setStatus(status, error instanceof Error ? error.message : 'Preview failed.', 'error'); + } finally { + button.disabled = false; + } +} + +function initSampleSelector() { + const select = document.querySelector('#normalization_sample_select'); + const textArea = document.querySelector('#normalization_sample_text'); + if (!select || !textArea) { + return; + } + select.addEventListener('change', () => { + const option = select.selectedOptions[0]; + if (option) { + textArea.value = option.value; + } + }); +} + +function initActions() { + const refreshButton = document.querySelector('[data-action="llm-refresh-models"]'); + if (refreshButton) { + refreshButton.addEventListener('click', () => refreshModels(refreshButton)); + } + const llmPreviewButton = document.querySelector('[data-action="llm-preview"]'); + if (llmPreviewButton) { + llmPreviewButton.addEventListener('click', () => previewLLM(llmPreviewButton)); + } + const normalizationButton = document.querySelector('[data-action="normalization-preview"]'); + if (normalizationButton) { + normalizationButton.addEventListener('click', () => previewNormalization(normalizationButton)); + } + const calibreTestButton = document.querySelector('[data-action="calibre-test"]'); + if (calibreTestButton) { + calibreTestButton.addEventListener('click', () => testCalibre(calibreTestButton)); + } + const audiobookshelfTestButton = document.querySelector('[data-action="audiobookshelf-test"]'); + if (audiobookshelfTestButton) { + audiobookshelfTestButton.addEventListener('click', () => testAudiobookshelf(audiobookshelfTestButton)); + } + const audiobookshelfBrowseButton = document.querySelector('[data-action="audiobookshelf-list-folders"]'); + if (audiobookshelfBrowseButton) { + audiobookshelfBrowseButton.addEventListener('click', () => browseAudiobookshelfFolders(audiobookshelfBrowseButton)); + } +} + +function initLLMStateWatchers() { + const baseUrlInput = form.querySelector('#llm_base_url'); + const apiKeyInput = form.querySelector('#llm_api_key'); + if (!baseUrlInput || !apiKeyInput) { + return; + } + const handler = () => updateLLMNavState(); + baseUrlInput.addEventListener('input', handler); + apiKeyInput.addEventListener('input', handler); + updateLLMNavState(); +} + +if (form) { + initNavigation(); + initSampleSelector(); + initActions(); + initFolderPicker(); + initContractionModal(); + initLLMStateWatchers(); +} diff --git a/abogen/webui/static/speaker-configs.js b/abogen/webui/static/speaker-configs.js new file mode 100644 index 0000000..4185f21 --- /dev/null +++ b/abogen/webui/static/speaker-configs.js @@ -0,0 +1,97 @@ +const initSpeakerConfigsPage = () => { + const form = document.getElementById("speaker-config-form"); + if (!form) return; + + const rowsContainer = form.querySelector('[data-role="speaker-rows"]'); + const template = document.getElementById("speaker-row-template"); + const addButtons = form.querySelectorAll('[data-action="add-speaker"]'); + + const ensureEmptyState = () => { + if (!rowsContainer) return; + const hasRows = rowsContainer.querySelector('[data-role="speaker-row"]'); + let emptyState = rowsContainer.querySelector('[data-role="empty-state"]'); + if (hasRows && emptyState) { + emptyState.remove(); + emptyState = null; + } + if (!hasRows && !emptyState) { + const placeholder = document.createElement("div"); + placeholder.className = "speaker-config-rows__empty"; + placeholder.dataset.role = "empty-state"; + placeholder.textContent = "No speakers yet. Add your first character."; + rowsContainer.appendChild(placeholder); + } + }; + + const hydrateRow = (fragment, key) => { + const elements = fragment.querySelectorAll("[name], [id], label[for], [data-row-id]"); + elements.forEach((el) => { + if (el.name) { + el.name = el.name.replace(/__ROW__/g, key); + } + if (el.id) { + el.id = el.id.replace(/__ROW__/g, key); + } + if (el.tagName === "LABEL") { + const forValue = el.getAttribute("for"); + if (forValue) { + el.setAttribute("for", forValue.replace(/__ROW__/g, key)); + } + } + if (el.dataset && el.dataset.rowId) { + el.dataset.rowId = key; + } + }); + + const hiddenId = fragment.querySelector(`input[name="speaker-${key}-id"]`); + if (hiddenId && !hiddenId.value) { + hiddenId.value = key; + } + const rowMarkers = fragment.querySelectorAll('input[name="speaker_rows"]'); + rowMarkers.forEach((marker) => { + marker.value = key; + }); + }; + + const addRow = () => { + if (!template || !rowsContainer) return; + const key = `row-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + const fragment = template.content.cloneNode(true); + hydrateRow(fragment, key); + rowsContainer.appendChild(fragment); + ensureEmptyState(); + const newRow = rowsContainer.querySelector(`[data-row-id="${key}"]`); + if (newRow) { + const input = newRow.querySelector("input[type=text]"); + if (input) { + input.focus(); + } + } + }; + + addButtons.forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault(); + addRow(); + }); + }); + + rowsContainer?.addEventListener("click", (event) => { + const removeButton = event.target.closest('[data-action="remove-speaker"]'); + if (!removeButton) return; + event.preventDefault(); + const row = removeButton.closest('[data-role="speaker-row"]'); + if (row) { + row.remove(); + ensureEmptyState(); + } + }); + + ensureEmptyState(); +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initSpeakerConfigsPage, { once: true }); +} else { + initSpeakerConfigsPage(); +} diff --git a/abogen/webui/static/speakers.js b/abogen/webui/static/speakers.js new file mode 100644 index 0000000..23affcb --- /dev/null +++ b/abogen/webui/static/speakers.js @@ -0,0 +1,121 @@ +const audioElement = new Audio(); +let activeButton = null; +let activeUrl = null; + +const setLoadingState = (button, isLoading) => { + if (!button) return; + button.disabled = isLoading; + if (isLoading) { + button.setAttribute("data-loading", "true"); + } else { + button.removeAttribute("data-loading"); + } +}; + +const stopCurrentPlayback = () => { + if (audioElement && !audioElement.paused) { + audioElement.pause(); + } + if (activeUrl) { + URL.revokeObjectURL(activeUrl); + activeUrl = null; + } + if (activeButton) { + setLoadingState(activeButton, false); + activeButton = null; + } +}; + +const resolvePreviewText = (button) => { + const source = (button.dataset.previewSource || "").toLowerCase(); + if (source === "pronunciation") { + const container = button.closest(".speaker-list__item"); + if (container) { + const input = container.querySelector('[data-role="speaker-pronunciation"]'); + const fallback = (container.dataset.defaultPronunciation || "").trim(); + const value = (input?.value || "").trim() || fallback; + button.dataset.previewText = value; + return value; + } + } + return (button.dataset.previewText || "").trim(); +}; + +audioElement.addEventListener("ended", () => { + stopCurrentPlayback(); +}); + +audioElement.addEventListener("pause", () => { + if (audioElement.currentTime === 0 || audioElement.currentTime >= audioElement.duration) { + stopCurrentPlayback(); + } +}); + +const playPreview = async (button) => { + const text = resolvePreviewText(button); + const voice = (button.dataset.voice || "").trim(); + const language = (button.dataset.language || "a").trim() || "a"; + const speedRaw = button.dataset.speed || "1"; + const useGpu = (button.dataset.useGpu || "true") !== "false"; + const speed = Number.parseFloat(speedRaw); + + if (!text) { + console.warn("Skipping speaker preview: no text provided"); + return; + } + if (!voice) { + console.warn("Skipping speaker preview: no voice provided"); + return; + } + + const payload = { + text, + voice, + language, + speed: Number.isFinite(speed) ? speed : 1.0, + use_gpu: useGpu, + max_seconds: 8, + }; + + const pendingId = + button.dataset.pendingId || + button.closest("[data-pending-id]")?.dataset.pendingId || + document.querySelector('[data-role="prepare-form"]')?.dataset.pendingId || + ""; + if (pendingId) { + payload.pending_id = pendingId; + } + + stopCurrentPlayback(); + activeButton = button; + setLoadingState(button, true); + + try { + const response = await fetch("/api/speaker-preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Preview failed with status ${response.status}`); + } + const blob = await response.blob(); + activeUrl = URL.createObjectURL(blob); + audioElement.src = activeUrl; + await audioElement.play(); + } catch (error) { + console.error("Failed to play speaker preview", error); + stopCurrentPlayback(); + } finally { + setLoadingState(button, false); + } +}; + +document.addEventListener("click", (event) => { + const trigger = event.target.closest('[data-role="speaker-preview"]'); + if (!trigger) return; + event.preventDefault(); + if (trigger.disabled) return; + playPreview(trigger); +}); diff --git a/abogen/webui/static/styles.css b/abogen/webui/static/styles.css new file mode 100644 index 0000000..bc29982 --- /dev/null +++ b/abogen/webui/static/styles.css @@ -0,0 +1,4263 @@ +:root { + color-scheme: dark light; + --bg: #0f172a; + --bg-alt: #111c30; + --panel: rgba(255, 255, 255, 0.04); + --panel-border: rgba(148, 163, 184, 0.12); + --text: #e2e8f0; + --muted: #94a3b8; + --accent: #38bdf8; + --accent-strong: #0ea5e9; + --success: #34d399; + --danger: #f87171; + --warning: #fbbf24; + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +body { + margin: 0; + min-height: 100vh; + background: radial-gradient(circle at 0% 0%, rgba(56, 189, 248, 0.12), transparent 40%), + radial-gradient(circle at 100% 0%, rgba(244, 114, 182, 0.08), transparent 45%), + var(--bg); + color: var(--text); + display: flex; + flex-direction: column; +} + +.notice { + margin: 0 0 1.5rem; + padding: 0.9rem 1.1rem; + border-radius: 16px; + border: 1px solid rgba(56, 189, 248, 0.32); + background: rgba(14, 165, 233, 0.18); + color: var(--text); + font-size: 0.95rem; + box-shadow: 0 12px 30px rgba(8, 15, 32, 0.22); +} + +.notice--success { + border-color: rgba(52, 211, 153, 0.42); + background: rgba(34, 197, 94, 0.18); +} + +.notice--error { + border-color: rgba(248, 113, 113, 0.48); + background: rgba(248, 113, 113, 0.18); +} + +.top-bar { + backdrop-filter: blur(20px); + background: linear-gradient(90deg, rgba(15, 23, 42, 0.9), rgba(30, 41, 59, 0.7)); + padding: 1.25rem 3rem; + border-bottom: 1px solid var(--panel-border); + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; +} + +.brand { + display: flex; + align-items: center; + gap: 1rem; +} + +.brand__mark { + font-size: 1.75rem; +} + +.brand__name { + font-size: 1.5rem; + font-weight: 600; + letter-spacing: 0.04em; +} + +.brand__tagline { + color: var(--muted); + font-size: 0.9rem; + font-family: "Georgia", "Times New Roman", serif; + font-style: italic; +} + +.top-actions .btn { + color: var(--accent); + text-decoration: none; + border: 1px solid transparent; + padding: 0.5rem 1rem; + border-radius: 999px; + transition: all 0.2s ease; +} + +.top-actions .btn:hover { + border-color: var(--accent); + background: rgba(56, 189, 248, 0.1); +} + +.top-actions .btn.is-active { + border-color: var(--accent); + background: rgba(56, 189, 248, 0.18); + color: #fff; + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12); +} + +.main { + flex: 1; + padding: 2.5rem 3rem 3.5rem; + max-width: 1100px; + width: 100%; + margin: 0 auto; + display: grid; + gap: 2rem; +} + +.footer { + padding: 1.5rem 3rem; + display: flex; + justify-content: center; + gap: 0.75rem; + color: var(--muted); + border-top: 1px solid var(--panel-border); + background: rgba(15, 23, 42, 0.8); +} + +.footer a { + color: var(--accent); + text-decoration: none; +} + +.card { + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 28px; + padding: 2rem; + box-shadow: 0 20px 50px rgba(8, 15, 32, 0.35); + position: relative; + overflow: hidden; + z-index: 0; +} + +.card::before { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(circle at 0% 0%, rgba(56, 189, 248, 0.15), transparent 55%); + pointer-events: none; + z-index: -1; +} + +.card--workflow { + display: grid; + gap: 1.25rem; +} + +.upload-card__dropzone { + border: 1.5px dashed rgba(148, 163, 184, 0.3); + border-radius: 24px; + background: rgba(15, 23, 42, 0.28); + padding: 2.75rem 2rem; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; + outline: none; +} + +.upload-card__dropzone:hover, +.upload-card__dropzone:focus-visible { + border-color: rgba(56, 189, 248, 0.6); + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12); +} + +.upload-card__dropzone.is-dragging { + border-color: rgba(56, 189, 248, 0.85); + background: rgba(15, 23, 42, 0.45); + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.18); +} + +.upload-card__dropzone-content { + display: grid; + gap: 0.75rem; + justify-items: center; + max-width: 420px; +} + +.upload-card__icon { + font-size: 2rem; + line-height: 1; + padding: 0.55rem 0.7rem; + border-radius: 999px; + background: rgba(56, 189, 248, 0.12); + color: var(--accent); +} + +.upload-card__headline { + margin: 0; + font-size: 1.1rem; + font-weight: 600; +} + +.upload-card__hint { + margin: 0; + color: var(--muted); +} + +.upload-card__filename { + margin: 0; + color: var(--accent); + font-weight: 500; +} + +.upload-card__filename[data-state="error"] { + color: var(--danger); +} + +.upload-card__dropzone .button { + margin-top: 0.25rem; +} + +.card__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.card__actions .button { + padding: 0.7rem 1.1rem; + font-size: 0.95rem; + border-radius: 12px; + box-shadow: 0 8px 22px rgba(56, 189, 248, 0.18); +} + +.card__actions .button:hover { + box-shadow: 0 12px 26px rgba(14, 165, 233, 0.24); +} + +.card--modal { + padding: 0; + border-radius: 28px; + overflow: hidden; +} + +.modal { + position: fixed; + inset: 0; + padding: 2rem; + display: none; + align-items: center; + justify-content: center; + z-index: 300; + overflow-y: auto; +} + +.modal[data-open="true"] { + display: flex; +} + +.modal[hidden] { + display: none !important; +} + +/* Ensure the HTML hidden attribute always wins over component display rules. */ +[hidden] { + display: none !important; +} + +.modal__overlay { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.74); + backdrop-filter: blur(8px); +} + +.modal__content { + position: relative; + width: min(95vw, 1120px); + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 30px 70px rgba(7, 14, 32, 0.55); + border-radius: 24px; + overflow: hidden; +} + +.modal__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1.5rem; + padding: 1.75rem 2rem 0; +} + +.modal__heading { + display: grid; + gap: 0.5rem; +} + +.modal__eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.75rem; + color: var(--muted); +} + +.modal__title { + margin: 0; + font-size: 1.6rem; + font-weight: 600; +} + +.modal__body { + padding: 1.5rem 2rem 2rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1.5rem; + flex: 1 1 auto; + min-height: 0; +} + +.modal__footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1.25rem 2rem 2.75rem; + border-top: 1px solid rgba(148, 163, 184, 0.12); + background: linear-gradient(180deg, transparent, rgba(15, 23, 42, 0.3)); +} + +.modal-open { + overflow: hidden; +} + +.modal__content.voice-browser { + padding: 0; + border-radius: 28px; +} + +.modal__content.folder-picker-modal { + width: min(600px, 95vw); +} + +.folder-picker { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.folder-picker__controls { + display: flex; + align-items: center; +} + +.folder-picker__controls input { + width: 100%; +} + +.folder-picker__status { + margin: 0; + font-size: 0.9rem; + color: var(--muted); +} + +.folder-picker__status[data-state="loading"] { + color: var(--muted); +} + +.folder-picker__status[data-state="error"] { + color: var(--danger); +} + +.folder-picker__status[data-state="info"], +.folder-picker__status[data-state="success"] { + color: var(--accent); +} + +.folder-picker__list { + display: flex; + flex-direction: column; + gap: 0.65rem; + max-height: 20rem; + overflow-y: auto; + padding-right: 0.25rem; +} + +.folder-picker__item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + width: 100%; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 16px; + background: rgba(15, 23, 42, 0.6); + padding: 0.8rem 1rem; + color: var(--text); + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +.folder-picker__item:hover, +.folder-picker__item:focus-visible { + border-color: rgba(56, 189, 248, 0.5); + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12); + outline: none; + transform: translateY(-1px); +} + +.folder-picker__item-name { + font-weight: 600; + font-size: 1rem; +} + +.folder-picker__item-path { + font-size: 0.85rem; + color: var(--muted); +} + +.folder-picker__item-id { + font-size: 0.75rem; + color: var(--muted); + background: rgba(148, 163, 184, 0.18); + padding: 0.15rem 0.4rem; + border-radius: 999px; +} + +.folder-picker__empty { + margin: 0; + font-size: 0.9rem; + color: var(--muted); +} + +.reader-modal { + width: min(960px, 95vw); + height: min(90vh, 720px); + display: flex; + flex-direction: column; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.find-books__body { + display: grid; + gap: 1.75rem; +} + +@media (min-width: 960px) { + .find-books__body { + grid-template-columns: 1fr 320px; + align-items: start; + } +} + +.find-books__resources { + display: grid; + gap: 1.5rem; +} + +.resource-tile { + background: rgba(15, 23, 42, 0.32); + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 20px; + padding: 1.5rem; + display: grid; + gap: 0.85rem; + box-shadow: 0 18px 40px rgba(8, 15, 32, 0.28); +} + +.resource-tile h2 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; +} + +.resource-tile p { + margin: 0; + color: var(--muted); + line-height: 1.55; +} + +.resource-tile .button { + align-self: start; +} + +.resource-tile .hint { + font-size: 0.85rem; +} + +.find-books__opds { + border: 1px dashed rgba(148, 163, 184, 0.22); + border-radius: 20px; + padding: 1.5rem; + display: grid; + gap: 0.9rem; + background: rgba(15, 23, 42, 0.22); +} + +.find-books__opds h2 { + margin: 0; + font-size: 1.05rem; + font-weight: 600; +} + +.find-books__opds .hint { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.find-books__opds .button { + justify-self: start; +} + +.opds-modal { + width: min(1020px, 95vw); + max-height: 88vh; +} + +.opds-modal__header { + align-items: center; + flex-wrap: wrap; + gap: 1.25rem; +} + +.opds-modal__search { + flex: 1 1 auto; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +.opds-search__field { + position: relative; + flex: 1 1 280px; + display: flex; + align-items: center; + gap: 0.65rem; + background: rgba(15, 23, 42, 0.45); + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 14px; + padding: 0.65rem 0.85rem; +} + +.opds-search__field input[type="search"] { + flex: 1 1 auto; + background: transparent; + border: none; + color: var(--text); + font-size: 0.95rem; + outline: none; +} + +.opds-search__field input[type="search"]::placeholder { + color: rgba(148, 163, 184, 0.7); +} + +.opds-search__icon { + width: 18px; + height: 18px; + fill: rgba(148, 163, 184, 0.7); +} + +.opds-search__actions { + display: flex; + gap: 0.5rem; +} + +.opds-modal__close { + align-self: flex-start; +} + +.opds-modal__tabs { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0 2rem 0.75rem; +} + +.opds-modal__tabs.is-empty { + display: none; +} + +.opds-tab { + border: 1px solid transparent; + border-radius: 999px; + padding: 0.4rem 0.95rem; + background: rgba(148, 163, 184, 0.12); + color: var(--text); + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.opds-tab:hover { + border-color: rgba(56, 189, 248, 0.4); + background: rgba(56, 189, 248, 0.12); +} + +.opds-tab.is-active { + border-color: rgba(56, 189, 248, 0.6); + background: rgba(56, 189, 248, 0.18); + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12); +} + +.opds-tab--search { + background: rgba(79, 70, 229, 0.16); + border-color: rgba(129, 140, 248, 0.45); +} + +.opds-modal__body { + gap: 1rem; + padding-top: 0; +} + +.opds-modal__status { + font-size: 0.92rem; + min-height: 1.25rem; + color: var(--muted); +} + +.opds-modal__status[data-state="loading"] { + color: var(--accent); +} + +.opds-modal__status[data-state="success"] { + color: var(--success); +} + +.opds-modal__status[data-state="error"] { + color: var(--danger); +} + +.opds-modal__status[data-state="info"] { + color: var(--muted); +} + +.opds-modal__nav { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.opds-modal__alpha { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0; + padding: 0.75rem 0 1rem; + position: sticky; + top: 0; + z-index: 20; + background: var(--bg); + box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.2); + margin-bottom: 1rem; + border-bottom: 1px solid var(--panel-border); +} + +.opds-modal__nav--bottom { + margin-top: 1rem; + padding-top: 1.5rem; + border-top: 1px solid var(--panel-border); + justify-content: center; +} + +.opds-alpha-picker__button { + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 999px; + background: rgba(15, 23, 42, 0.35); + color: rgba(226, 232, 240, 0.88); + padding: 0.3rem 0.65rem; + font-size: 0.82rem; + letter-spacing: 0.02em; + text-transform: uppercase; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.opds-alpha-picker__button:hover { + border-color: rgba(148, 163, 184, 0.55); + color: #f8fafc; +} + +.opds-alpha-picker__button.is-active { + background: rgba(56, 189, 248, 0.2); + border-color: rgba(56, 189, 248, 0.65); + color: #f0f9ff; + box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.25); +} + +.opds-alpha-picker__button:disabled, +.opds-alpha-picker__button[aria-disabled="true"] { + opacity: 0.45; + cursor: default; +} + +.opds-alpha-picker__button[data-empty="true"] { + opacity: 0.6; +} + +.opds-modal__results-wrap { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} + +.opds-modal__results { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 1rem; +} + +.opds-browser__entry { + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 18px; + padding: 1.25rem 1.5rem; + background: rgba(15, 23, 42, 0.35); + display: grid; + gap: 0.85rem; + box-shadow: 0 14px 30px rgba(7, 14, 32, 0.32); +} + +.opds-browser__entry--navigation { + border-style: dashed; + background: rgba(56, 189, 248, 0.08); +} + +.opds-browser__entry-head { + display: grid; + gap: 0.35rem; +} + +.opds-browser__title { + margin: 0; + font-size: 1.05rem; + font-weight: 600; +} + +.opds-browser__meta { + margin: 0; + color: rgba(148, 163, 184, 0.85); + font-size: 0.9rem; +} + +.opds-browser__summary { + margin: 0; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.55; +} + +.opds-browser__actions { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; +} + +.opds-browser__hint { + color: rgba(148, 163, 184, 0.75); + font-size: 0.85rem; +} + +.opds-browser__empty { + border: 1px dashed rgba(148, 163, 184, 0.24); + border-radius: 16px; + padding: 1.2rem; + text-align: center; + color: rgba(148, 163, 184, 0.85); +} + +.reader-modal__body { + padding: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + background: rgba(15, 23, 42, 0.75); +} + +.reader-modal__frame { + width: 100%; + height: 100%; + border: none; + border-radius: 18px; + background: #0f172a; +} + +.voice-browser__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.75rem 1.75rem 1.25rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.16); +} + +.voice-browser__body { + display: grid; + grid-template-columns: 260px 1fr 320px; + gap: 1.5rem; + padding: 1.5rem 1.75rem 1.75rem; + overflow-y: auto; + max-height: calc(90vh - 150px); + flex: 1 1 auto; +} + +.voice-browser__filters, +.voice-browser__catalog, +.voice-browser__mix { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.voice-browser__filters .field { + gap: 0.35rem; +} + +.voice-browser__filters .button { + width: 100%; +} + +.voice-browser__gender { + display: grid; + gap: 0.5rem; +} + +.voice-browser__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.75rem; +} + +.voice-browser__entry { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 0.85rem 1rem; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(15, 23, 42, 0.35); + color: inherit; + text-align: left; +} + +.voice-browser__entry:hover, +.voice-browser__entry[aria-current="true"], +.voice-browser__entry[data-in-mix="true"] { + border-color: rgba(56, 189, 248, 0.55); + box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.18); +} + +.voice-browser__entry-name { + font-weight: 600; +} + +.voice-browser__entry-meta { + font-size: 0.85rem; + color: var(--muted); +} + +.voice-browser__mix { + border: 1px dashed rgba(148, 163, 184, 0.25); + border-radius: 18px; + padding: 1.25rem; + background: rgba(15, 23, 42, 0.25); + gap: 1.25rem; +} + +.voice-browser__mix-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.voice-browser__mix-list { + display: grid; + gap: 1rem; +} + +.voice-browser__mix-item { + display: grid; + gap: 0.75rem; + padding: 1rem; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.16); + background: rgba(15, 23, 42, 0.3); +} + +.voice-browser__mix-header h3, +.voice-browser__mix-name { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.voice-browser__mix-remove { + appearance: none; + border: none; + background: none; + color: var(--muted); + font-size: 1.15rem; + cursor: pointer; +} + +.voice-browser__mix-remove:hover { + color: var(--danger); +} + +.voice-browser__mix-weight { + font-size: 0.85rem; + color: var(--muted); +} + +.voice-browser__preview { + display: grid; + gap: 1rem; +} + +.voice-browser__samples { + display: grid; + gap: 0.75rem; +} + +.voice-browser__sample { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + padding: 0.85rem 1rem; + background: rgba(15, 23, 42, 0.28); +} + +.voice-browser__actions { + display: flex; + justify-content: flex-end; +} + +.voice-browser__empty { + margin: 0; + text-align: center; + color: var(--muted); +} + +@media (max-width: 1100px) { + .voice-browser__body { + grid-template-columns: 1fr; + } + + .voice-browser__mix { + order: 3; + } +} + +@media (max-width: 768px) { + .modal { + padding: 1rem; + } + + .modal__header, + .modal__body, + .modal__footer { + padding-left: 1.25rem; + padding-right: 1.25rem; + } +} + + .wizard-page { + display: flex; + justify-content: center; + padding: 3rem 0 5rem; + } + + .wizard-page--modal { + padding: 0; + min-height: 100vh; + } + + .wizard-card { + width: min(1100px, calc(100% - 2.5rem)); + margin: 0 auto; + } + + .wizard-card__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 2rem; + } + + .wizard-card__aside { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: flex-end; + text-align: right; + max-width: 320px; + } + + .wizard-card__links { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: flex-end; + } + + .wizard-card__filename { + font-size: 0.85rem; + color: var(--muted); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .wizard-card__body { + display: flex; + flex-direction: column; + gap: 2rem; + } + + .wizard-card__stage { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; + position: relative; + } + + .wizard-card__stage > form[data-wizard-form="true"] { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + } + + .wizard-card__footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + } + + .wizard-card__footer-actions { + display: flex; + gap: 0.75rem; + align-items: center; + } + + .wizard-hidden-inputs { + display: none; + } + + [data-role="new-job-modal"][data-submitting="true"] .wizard-card__stage::after, + [data-role="new-job-modal"][data-submitting="true"] .wizard-card__stage::before { + content: ""; + position: absolute; + } + + [data-role="new-job-modal"][data-submitting="true"] .wizard-card__stage::after { + inset: 0; + background: rgba(15, 23, 42, 0.55); + border-radius: 24px; + pointer-events: auto; + } + + [data-role="new-job-modal"][data-submitting="true"] .wizard-card__stage::before { + left: 50%; + top: 50%; + width: 2.75rem; + height: 2.75rem; + margin: -1.375rem; + border-radius: 50%; + border: 3px solid rgba(56, 189, 248, 0.25); + border-top-color: rgba(56, 189, 248, 0.85); + animation: spin 0.85s linear infinite; + pointer-events: none; + } + + [data-role="new-job-modal"][data-submitting="true"] .wizard-card__stage > * { + pointer-events: none; + user-select: none; + opacity: 0.75; + } + + @media (max-width: 900px) { + .wizard-card__header { + flex-direction: column; + align-items: stretch; + } + + .wizard-card__aside { + align-items: flex-start; + text-align: left; + } + + .wizard-card__links { + justify-content: flex-start; + } + } + +.step-indicator { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.5rem; + align-items: center; +} + +.step-indicator__item { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.85rem; + border-radius: 999px; + background: rgba(148, 163, 184, 0.12); + color: var(--muted); + font-size: 0.85rem; + border: 1px solid rgba(148, 163, 184, 0.18); +} + +button.step-indicator__item { + cursor: pointer; + font: inherit; +} + +button.step-indicator__item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.step-indicator__index { + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 600; + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(15, 23, 42, 0.65); +} + +.step-indicator__label { + font-weight: 500; + letter-spacing: 0.02em; +} + +.step-indicator__item.is-active { + background: rgba(56, 189, 248, 0.18); + color: #fff; + border-color: rgba(56, 189, 248, 0.35); +} + +.step-indicator__item.is-active .step-indicator__index { + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + border-color: transparent; + color: #fff; +} + +.step-indicator__item.is-complete { + background: rgba(56, 189, 248, 0.12); + color: var(--accent); + border-color: rgba(56, 189, 248, 0.25); +} + +.step-indicator__item.is-complete .step-indicator__index { + border-color: rgba(56, 189, 248, 0.35); +} + +.step-indicator__item[data-state="clickable"] { + cursor: pointer; + color: rgba(148, 163, 184, 0.95); +} + +.step-indicator__item[data-state="clickable"]:hover { + color: var(--accent); + background: rgba(56, 189, 248, 0.12); +} + +.card__title { + font-size: 1.4rem; + font-weight: 600; + margin-bottom: 1rem; +} + +.card__title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.card__title-row .card__title { + margin: 0; +} + +.card__title-row .button { + margin-left: auto; +} + +.grid { + display: grid; + gap: 1.5rem; +} + +.grid--two { + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + +.form-grid { + align-items: start; +} + +.form-grid { + grid-template-columns: minmax(0, 420px) minmax(0, 1fr); + align-items: start; + gap: 2rem; +} + +.form-grid > .grid { + align-items: start; + max-width: none; + width: 100%; + justify-self: stretch; +} + +.form-grid > .grid:last-child { + width: min(100%, 540px); +} + +.button { + appearance: none; + border: none; + border-radius: 16px; + padding: 0.85rem 1.2rem; + font-size: 1rem; + font-weight: 600; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + color: #fff; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + box-shadow: 0 12px 30px rgba(56, 189, 248, 0.2); +} + +.button:hover { + transform: translateY(-2px); + box-shadow: 0 16px 30px rgba(14, 165, 233, 0.28); +} + +.button--ghost { + background: transparent; + border: 1px solid var(--panel-border); + color: var(--muted); + box-shadow: none; +} + +.button--ghost:hover { + border-color: var(--accent); + color: var(--accent); +} + +.button[data-loading="true"]:not([data-role="preview-button"]) { + position: relative; + padding-left: 2.75rem; + pointer-events: none; + color: rgba(255, 255, 255, 0.88); + opacity: 0.92; +} + +.button[data-loading="true"]:not([data-role="preview-button"])::after { + content: ""; + position: absolute; + left: 1.15rem; + top: 50%; + width: 1.1rem; + height: 1.1rem; + margin-top: -0.55rem; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.3); + border-right-color: rgba(255, 255, 255, 0.9); + animation: spin 0.85s linear infinite; +} + +.log-copy { + margin-top: 1.5rem; +} + +.log-copy > summary { + cursor: pointer; + font-weight: 600; + margin-bottom: 0.75rem; +} + +.log-copy__textarea { + display: block; + width: 100%; + min-height: 240px; + padding: 1rem; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.32); + background: rgba(15, 23, 42, 0.35); + color: inherit; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.95rem; + line-height: 1.5; + resize: vertical; +} + +.log-copy__textarea:focus { + outline: 2px solid rgba(56, 189, 248, 0.55); + outline-offset: 2px; +} + +.upload-form { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; +} + +.upload-form__sections { + display: flex; + flex-direction: column; + gap: 1.5rem; + flex: 1 1 auto; + min-height: 0; +} + +.form-section { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + border-radius: 24px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(15, 23, 42, 0.45); +} + +.form-section__title { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--text); +} + +.form-section__layout { + display: grid; + gap: 1.25rem; +} + +.form-section__layout--split { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.form-section__group { + display: grid; + gap: 1.25rem; +} + +.field-grid { + display: grid; + gap: 1.25rem; +} + +.field-grid--compact { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + align-items: start; +} + +.field-grid--compact .field { + max-width: none; +} + +.field-grid--two { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.field-grid--two > .field { + max-width: none; +} + +.field--span-2 { + grid-column: span 2; +} + +.field-grid--two input[type="text"], +.field-grid--two input[type="number"], +.field-grid--two input[type="file"], +.field-grid--two input[type="range"], +.field-grid--two select, +.field-grid--two textarea { + max-width: 100%; +} + +.field--stack { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.field--with-action .field__label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.field__label-row .field__label, +.field__label-row label { + font-weight: 500; + color: var(--muted); + font-size: 0.9rem; +} + +.field__status { + margin: 0; + font-size: 0.85rem; + color: var(--muted); +} + +.field__status[data-state="loading"] { + color: var(--accent); +} + +.field__status[data-state="error"] { + color: var(--danger); +} + +.field__status[data-state="success"] { + color: var(--success); +} + +.voice-preview__audio { + width: min(100%, 360px); + margin-top: 0.75rem; + border-radius: 14px; + background: rgba(15, 23, 42, 0.4); +} + +.field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.field__caption { + font-weight: 500; + color: var(--muted); + font-size: 0.9rem; +} + +.field[hidden], +.field[data-state="hidden"] { + display: none !important; +} + +.field--full { + max-width: none; +} + +.field--full textarea { + min-height: 260px; +} + +.field--actions { + display: flex; + justify-content: flex-end; + align-items: center; +} + +.field label { + font-weight: 500; + color: var(--muted); + font-size: 0.9rem; +} + +.field input, +.field select, +.field textarea { + width: 100%; + box-sizing: border-box; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.2); + background: rgba(15, 23, 42, 0.45); + padding: 0.75rem 1rem; + color: var(--text); + font-size: 1rem; + transition: border 0.2s ease, box-shadow 0.2s ease; +} + +.field input[type="text"], +.field input[type="file"], +.field input[type="number"], +.field select { + max-width: 420px; + width: min(100%, 420px); +} + +.field input[type="range"] { + max-width: 420px; + width: min(100%, 420px); + padding: 0; + margin-inline: 0; + align-self: flex-start; +} + +.field input[type="number"] { + max-width: 200px; +} + +.field input:focus, +.field select:focus, +.field textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.15); +} + +.split { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; +} + +.tag { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + padding: 0.35rem 0.8rem; + font-size: 0.8rem; + background: rgba(148, 163, 184, 0.12); + color: var(--muted); +} + +.badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.85rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.badge--pending { + background: rgba(148, 163, 184, 0.12); + color: var(--muted); +} + +.badge--running { + background: rgba(56, 189, 248, 0.12); + color: var(--accent); +} + +.badge--completed { + background: rgba(52, 211, 153, 0.15); + color: var(--success); +} + +.badge--failed, +.badge--cancelled { + background: rgba(248, 113, 113, 0.15); + color: var(--danger); +} + +.badge--info { + background: rgba(56, 189, 248, 0.18); + color: var(--accent); +} + +.badge--muted { + background: rgba(148, 163, 184, 0.15); + color: var(--muted); +} + +.log-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.65rem; + max-height: 260px; + overflow-y: auto; +} + +.log-item { + padding: 0.75rem 0.95rem; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.12); + background: rgba(15, 23, 42, 0.3); + font-size: 0.9rem; + line-height: 1.4; +} + +.log-item--success { + border-color: rgba(52, 211, 153, 0.4); + color: var(--success); +} + +.log-item--error { + border-color: rgba(248, 113, 113, 0.45); + color: var(--danger); +} + +.log-item--warning { + border-color: rgba(251, 191, 36, 0.45); + color: var(--warning); +} + +.jobs-table { + width: 100%; + border-collapse: collapse; + overflow: hidden; + border-radius: 20px; +} + +.jobs-table thead { + background: rgba(148, 163, 184, 0.12); + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 0.75rem; +} + +.jobs-table th, +.jobs-table td { + padding: 0.9rem 1.15rem; + text-align: left; +} + +.jobs-table tbody tr { + border-bottom: 1px solid rgba(148, 163, 184, 0.12); +} + +.jobs-table tbody tr:hover { + background: rgba(56, 189, 248, 0.08); +} + + +.entity-table-tools { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; + margin-bottom: 1.5rem; +} + +.entity-table-tools__filter { + flex: 1 1 240px; + max-width: 360px; +} + +.entity-table-tools__filter input { + max-width: none; +} + +.entity-table-tools__clear { + align-self: center; +} + +.entity-create { + gap: 1.25rem; +} + +.entity-create__description { + margin: 0; + color: var(--muted); + font-size: 0.95rem; +} + +.entity-create__actions { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 0.75rem; +} + +.entity-preview { + display: grid; + gap: 0.6rem; + align-items: flex-start; +} + +.entity-preview__controls { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.entity-preview__audio { + width: min(100%, 220px); + border-radius: 14px; + background: rgba(15, 23, 42, 0.45); +} + +.entity-preview__status { + font-size: 0.85rem; + color: var(--muted); +} + +.entity-preview__status[data-state="success"] { + color: var(--success); +} + +.entity-preview__status[data-state="error"] { + color: var(--danger); +} + +.overrides-table__token { + font-weight: 600; + letter-spacing: 0.02em; +} + +.overrides-table__input, +.overrides-table__select { + width: 100%; + box-sizing: border-box; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.2); + background: rgba(15, 23, 42, 0.45); + color: var(--text); + padding: 0.6rem 0.75rem; + font-size: 0.95rem; + transition: border 0.2s ease, box-shadow 0.2s ease; +} + +.overrides-table__input:focus, +.overrides-table__select:focus { + outline: none; + border-color: rgba(56, 189, 248, 0.55); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.18); +} + +.overrides-table__select { + appearance: none; +} + +.overrides-table__actions-heading { + text-align: right; +} + +.overrides-table__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; +} + +.overrides-table__form { + margin: 0; +} + +.button[data-loading="true"][data-role="preview-button"] { + position: relative; + padding-left: 2.3rem; + pointer-events: none; +} + +.button[data-loading="true"][data-role="preview-button"]::after { + content: ""; + position: absolute; + left: 0.9rem; + top: 50%; + width: 1rem; + height: 1rem; + margin-top: -0.5rem; + border-radius: 50%; + border: 2px solid rgba(148, 163, 184, 0.4); + border-right-color: rgba(56, 189, 248, 0.8); + animation: spin 0.85s linear infinite; +} + +.progress-bar { + width: 100%; + height: 9px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.15); + overflow: hidden; + --progress: 0%; +} + +.progress-bar__fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-strong)); + border-radius: inherit; + transition: width 0.35s ease; + width: var(--progress); +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 1rem; +} + +.list__item { + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 18px; + padding: 1rem 1.25rem; + background: rgba(15, 23, 42, 0.35); + gap: 1rem; +} + +.queue-section { + display: grid; + gap: 1rem; + margin-bottom: 1.75rem; +} + +.queue-section:last-of-type { + margin-bottom: 0; +} + +.queue-section__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.queue-section__header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; +} + +.job-cards { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 1rem; +} + +.job-card { + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 20px; + padding: 1rem 1.25rem; + background: rgba(15, 23, 42, 0.35); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); + display: grid; + gap: 0.75rem; +} + +.job-card[data-status="paused"] { + border-color: rgba(251, 191, 36, 0.45); + background: rgba(251, 191, 36, 0.08); +} + +.job-card__title { + font-size: 1.05rem; + font-weight: 600; + margin: 0; +} + +.job-card__title a { + color: inherit; + text-decoration: none; +} + +.job-card__title a:hover { + color: var(--accent); +} + +.job-card__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.job-card__meta { + color: var(--muted); + font-size: 0.85rem; + margin-top: 0.35rem; +} + +.job-card__meta--warning { + color: var(--warning); +} + +.job-card__progress { + display: grid; + gap: 0.4rem; +} + +.job-card__progress small { + color: var(--muted); +} + +.job-card__progress-meta { + display: flex; + justify-content: space-between; + align-items: center; +} + +.job-card__eta { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.job-card__footer { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.job-card__footer .button--ghost { + padding: 0.6rem 0.95rem; +} + +.job-cards--compact .job-card { + background: rgba(15, 23, 42, 0.28); +} + +.job-cards--compact .job-card__meta { + margin-top: 0.25rem; +} + +.job-card--info { + border-style: dashed; + border-color: rgba(148, 163, 184, 0.25); + background: rgba(15, 23, 42, 0.2); + text-align: center; +} + +.job-card--info .job-card__meta { + margin-top: 0; +} + +.queue-empty { + color: var(--muted); +} + +.field--file input[type="file"] { + width: min(100%, 420px); + max-width: 420px; + align-self: flex-start; + border-radius: 14px; + border: 1px dashed rgba(148, 163, 184, 0.35); + background: rgba(15, 23, 42, 0.45); + padding: 0.85rem 1rem; + font-size: 0.95rem; + color: var(--muted); + cursor: pointer; + transition: border 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.field--file input[type="file"]:hover, +.field--file input[type="file"]:focus { + border-color: var(--accent); + background: rgba(56, 189, 248, 0.1); + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12); +} + +.field--file input[type="file"]::file-selector-button, +.field--file input[type="file"]::-webkit-file-upload-button { + border: none; + border-radius: 12px; + background: rgba(56, 189, 248, 0.2); + color: var(--accent); + font-weight: 600; + padding: 0.55rem 1.05rem; + margin-right: 1rem; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease; +} +.prepare-wizard[data-speaker-step="disabled"] [data-step-key="speakers"] { + display: none; +} +.prepare-wizard[data-speaker-step="disabled"] [data-speaker-panel="true"] { + display: none !important; +} +.prepare-wizard[data-speaker-step="enabled"] [data-chapter-action="finalize"] { + display: none; +} +.prepare-wizard[data-speaker-step="disabled"] [data-chapter-action="continue"] { + display: none; +} +.prepare-wizard[data-speaker-step="disabled"] [data-chapter-action="finalize"] { + display: inline-flex; +} + +.field--file input[type="file"]:hover::file-selector-button, +.field--file input[type="file"]:focus::file-selector-button, +.field--file input[type="file"]:hover::-webkit-file-upload-button, +.field--file input[type="file"]:focus::-webkit-file-upload-button { + background: rgba(56, 189, 248, 0.35); + color: #fff; +} + +.alert { + margin: 1rem 0; + padding: 0.75rem 1rem; + border-radius: 14px; + font-size: 0.95rem; +} + +.alert--success { + background: rgba(52, 211, 153, 0.12); + border: 1px solid rgba(52, 211, 153, 0.35); + color: var(--success); +} + +.alert--error { + background: rgba(248, 113, 113, 0.12); + border: 1px solid rgba(248, 113, 113, 0.35); + color: var(--danger); +} + +.button--danger { + background: rgba(248, 113, 113, 0.16); + color: var(--danger); + border: 1px solid rgba(248, 113, 113, 0.45); + box-shadow: 0 10px 24px rgba(248, 113, 113, 0.18); +} + +.button--danger:hover { + background: linear-gradient(135deg, rgba(248, 113, 113, 0.85), #ef4444); + color: #fff; + border-color: rgba(248, 113, 113, 0.65); + box-shadow: 0 16px 36px rgba(248, 113, 113, 0.32); +} + + +.settings-layout { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 2rem; + align-items: start; +} + +.settings-nav { + display: flex; + flex-direction: column; + gap: 0.5rem; + position: sticky; + top: 100px; +} + +.settings-nav__item { + display: inline-flex; + justify-content: flex-start; + align-items: center; + padding: 0.65rem 0.9rem; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(15, 23, 42, 0.55); + color: var(--muted); + font-size: 0.95rem; + cursor: pointer; + transition: border 0.2s ease, color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; +} + +.settings-nav__item:hover, +.settings-nav__item:focus-visible { + border-color: rgba(56, 189, 248, 0.5); + color: var(--accent); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.1); +} + +.settings-nav__item.is-active { + background: rgba(56, 189, 248, 0.18); + border-color: rgba(56, 189, 248, 0.28); + color: #fff; + box-shadow: 0 10px 20px rgba(56, 189, 248, 0.18); +} + +.settings-nav__item.is-disabled { + opacity: 0.6; + cursor: pointer; +} + +.settings__form { + display: grid; + gap: 1.75rem; +} + +.settings-panels { + display: grid; +} + +.settings-panel { + display: none; + gap: 1.75rem; +} + +.settings-panel.is-active { + display: grid; + gap: 1.75rem; +} + +.settings__section { + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 18px; + padding: 1.25rem 1.4rem; + display: grid; + gap: 1.1rem 1.4rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.settings__section legend { + font-weight: 600; + padding: 0 0.5rem; + color: var(--muted); + grid-column: 1 / -1; +} + +.settings__section .field { + align-items: flex-start; +} + +.settings__section .field input, +.settings__section .field select, +.settings__section .field textarea { + max-width: 100%; +} + +.settings__section .field input[type="text"], +.settings__section .field input[type="file"], +.settings__section .field select { + max-width: 100%; +} + +.settings__section .field--wide { + grid-column: 1 / -1; +} + +.settings__section .field--wide input, +.settings__section .field--wide select, +.settings__section .field--wide textarea { + max-width: 100%; +} + +.field--choices { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.85rem; +} + +.toggle-pill { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.65rem; + cursor: pointer; +} + +.toggle-pill input { + position: absolute; + opacity: 0; + inset: 0; + cursor: pointer; +} + +.toggle-pill span { + display: inline-flex; + align-items: center; + justify-content: flex-start; + width: 100%; + padding: 0.65rem 0.95rem; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(15, 23, 42, 0.45); + color: var(--muted); + transition: border 0.2s ease, background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; +} + +.toggle-pill:hover span { + border-color: var(--accent); + color: var(--accent); +} + +.toggle-pill input:focus-visible + span { + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.18); +} + +.choices { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.choices--inline { + display: inline-flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.radio-pill { + position: relative; + display: inline-flex; + align-items: center; + cursor: pointer; +} + +.radio-pill input { + position: absolute; + opacity: 0; + inset: 0; + cursor: pointer; +} + +.radio-pill span { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.55rem 0.9rem; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(15, 23, 42, 0.4); + color: var(--muted); + transition: border 0.2s ease, background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; +} + +.radio-pill:hover span { + border-color: rgba(56, 189, 248, 0.5); + color: var(--accent); +} + +.radio-pill input:checked + span { + border-color: rgba(56, 189, 248, 0.45); + background: rgba(56, 189, 248, 0.16); + color: #fff; + box-shadow: 0 8px 18px rgba(56, 189, 248, 0.18); +} + +.radio-pill input:focus-visible + span { + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.18); +} + +.field__group { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.field__input-with-button { + display: flex; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; +} + +.field__input-with-button > input { + flex: 1 1 16rem; + min-width: 12rem; +} + +.preview-card { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + padding: 1rem 1.1rem; + background: rgba(15, 23, 42, 0.52); + display: grid; + gap: 0.75rem; +} + +.preview-card__actions { + display: flex; + align-items: center; + gap: 0.85rem; + flex-wrap: wrap; +} + +.preview-card__status { + font-size: 0.85rem; + color: var(--muted); +} + +.preview-card__status[data-state="error"] { + color: var(--danger); +} + +.preview-card__status[data-state="success"] { + color: var(--success); +} + +.preview-card__output { + border-radius: 12px; + background: rgba(15, 23, 42, 0.7); + border: 1px solid rgba(148, 163, 184, 0.2); + padding: 0.9rem 1rem; + font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.9rem; + line-height: 1.5; + color: var(--text); + min-height: 3.5rem; + white-space: pre-wrap; +} + +.preview-card__output:empty { + display: none; +} + +.preview-card__audio { + width: 100%; + margin-top: 0.25rem; +} + +.hint--warning { + color: var(--warning); +} + +@media (max-width: 860px) { + .settings-layout { + grid-template-columns: 1fr; + } + + .settings-nav { + position: static; + flex-direction: row; + flex-wrap: wrap; + gap: 0.65rem; + } + + .settings-nav__item { + flex: 1 1 calc(50% - 0.65rem); + justify-content: center; + text-align: center; + } +} + +.prepare-summary { + display: grid; + grid-template-columns: minmax(0, 320px) minmax(0, 1fr); + gap: 2rem; + margin-bottom: 2rem; + align-items: start; +} +.form-section__title-row { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} +.form-section__title-row .form-section__title { + margin-bottom: 0; +} + +.prepare-summary__stats dl { + margin: 0; + display: grid; + gap: 0.6rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.prepare-summary__stats dt { + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} + +.prepare-summary__stats dd { + margin: 0; + font-weight: 500; +} + +.prepare-summary__insights { + display: grid; + gap: 1.25rem; + align-content: start; +} + +.prepare-summary__stats dl > div { + display: grid; + gap: 0.25rem; +} + +.prepare-summary__insights > .prepare-analysis, +.prepare-summary__insights > .prepare-metadata { + border: 1px solid rgba(148, 163, 184, 0.2); + background: rgba(15, 23, 42, 0.35); + border-radius: 18px; + padding: 1rem 1.25rem; +} + +.prepare-speakers { + margin-top: 2rem; + border-top: 1px solid var(--panel-border); + padding-top: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.prepare-speaker-config { + margin-top: 2rem; + border-top: 1px solid var(--panel-border); + padding-top: 1.5rem; + display: grid; + gap: 1rem; +} + +.prepare-speaker-config__header { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.prepare-speaker-config__grid { + display: grid; + gap: 1.25rem; + grid-template-columns: minmax(0, 360px) minmax(0, 1fr); + align-items: center; +} + +.prepare-speaker-config__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + justify-content: flex-end; +} + +.prepare-speaker-config__actions .button { + white-space: nowrap; +} + +.prepare-speaker-config__field .hint { + margin-top: 0.2rem; +} + +@media (max-width: 900px) { + .prepare-speaker-config__grid { + grid-template-columns: minmax(0, 1fr); + align-items: stretch; + } + .prepare-speaker-config__actions { + justify-content: flex-start; + } +} + +.entity-tabs { + display: flex; + flex-direction: column; + gap: 1.75rem; +} + +.entity-tabs__notice { + margin-bottom: 0.5rem; +} + +.entity-tabs__nav { + display: flex; + gap: 0.75rem; + padding: 0.45rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.62); + border: 1px solid rgba(148, 163, 184, 0.2); + box-shadow: 0 18px 38px rgba(15, 23, 42, 0.35); + backdrop-filter: blur(4px); + position: sticky; + top: max(env(safe-area-inset-top), 0.5rem); + z-index: 25; +} + +.entity-tabs__tab { + flex: 1 1 0; + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding: 0.65rem 1rem 0.65rem 2.45rem; + border-radius: 999px; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + font-weight: 600; + position: relative; + transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease, transform 0.2s ease; +} + +.entity-tabs__tab::before { + content: ""; + position: absolute; + left: 1.1rem; + width: 0.95rem; + height: 0.95rem; + border-radius: 50%; + background: rgba(148, 163, 184, 0.5); + box-shadow: 0 0 0 4px rgba(148, 163, 184, 0.16); + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.entity-tabs__tab[data-panel="people"]::before { + background: linear-gradient(135deg, rgba(37, 99, 235, 0.7), rgba(56, 189, 248, 0.85)); +} + +.entity-tabs__tab[data-panel="entities"]::before { + background: linear-gradient(135deg, rgba(236, 72, 153, 0.45), rgba(244, 114, 182, 0.75)); +} + +.entity-tabs__tab[data-panel="manual"]::before { + background: linear-gradient(135deg, rgba(248, 189, 32, 0.55), rgba(248, 113, 113, 0.7)); +} + +.entity-tabs__tab:hover, +.entity-tabs__tab:focus-visible { + color: #fff; + border-color: rgba(56, 189, 248, 0.45); +} + +.entity-tabs__tab:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.22); +} + +.entity-tabs__tab.is-active { + color: #fff; + border-color: rgba(56, 189, 248, 0.6); + background: linear-gradient(135deg, rgba(30, 64, 175, 0.3), rgba(56, 189, 248, 0.4)); + box-shadow: 0 20px 34px rgba(14, 165, 233, 0.35); +} + +.entity-tabs__tab.is-active::before { + transform: scale(1.15); + box-shadow: 0 0 0 6px rgba(56, 189, 248, 0.28); +} + +.entity-tabs__panels { + background: rgba(15, 23, 42, 0.55); + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 28px; + padding: 1.9rem; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02); + backdrop-filter: blur(6px); +} + +.entity-tabs__panel { + display: flex; + flex-direction: column; + gap: 1.75rem; +} + +.entity-summary { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.entity-summary__header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.entity-summary__filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +.entity-summary__filters .field, +.entity-summary__filters .button { + margin: 0; +} + +.field--inline { + display: flex; + flex-wrap: wrap; + gap: 0.85rem; + align-items: flex-end; +} + +.entity-summary__titles h2 { + margin: 0; +} + +.entity-summary__titles .hint { + margin-top: 0.35rem; +} + +.entity-summary__stats { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.entity-summary__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 1.1rem; +} + +.entity-summary__empty { + border: 1px dashed rgba(148, 163, 184, 0.3); + border-radius: 18px; + padding: 1.1rem; + text-align: center; + color: rgba(148, 163, 184, 0.9); + background: rgba(15, 23, 42, 0.4); +} + +.entity-summary__item { + background: rgba(15, 23, 42, 0.62); + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 20px; + padding: 1.25rem; + box-shadow: 0 22px 40px rgba(15, 23, 42, 0.4); +} + +.entity-summary__item-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.entity-summary__label { + margin: 0; + font-size: 1.05rem; + letter-spacing: 0.01em; +} + +.entity-summary__kind { + margin: 0.25rem 0 0; + font-size: 0.85rem; + color: var(--muted); +} + +.entity-summary__actions { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.entity-summary__samples { + margin-top: 1rem; + display: grid; + gap: 0.6rem; +} + +.entity-summary__samples p { + margin: 0; + color: rgba(226, 232, 240, 0.9); + line-height: 1.55; +} + +.manual-overrides__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.manual-overrides__copy { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.manual-overrides__header h2 { + margin: 0; +} + +.manual-overrides__header-actions { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.manual-overrides__status { + font-size: 0.85rem; + color: var(--muted); + min-height: 1.2em; +} + +.manual-overrides__status[data-state="success"] { + color: var(--success); +} + +.manual-overrides__status[data-state="error"] { + color: var(--danger); +} + +.manual-overrides__status[data-state="pending"] { + color: var(--accent); +} + +.manual-overrides__search { + display: grid; + gap: 1.25rem; +} + +.manual-overrides__search-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.manual-overrides__results, +.manual-overrides__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 1rem; +} + +.entity-tabs [hidden] { + display: none !important; +} + +@media (max-width: 820px) { + .field--span-2 { + grid-column: span 1; + } + + .manual-overrides__header { + flex-direction: column; + align-items: stretch; + } + + .manual-overrides__header-actions { + justify-content: flex-start; + flex-wrap: wrap; + } + + .entity-tabs__nav { + flex-direction: column; + padding: 0.35rem; + } + + .entity-tabs__tab { + width: 100%; + justify-content: flex-start; + padding: 0.6rem 1rem 0.6rem 2.3rem; + } +} + +.speaker-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.speaker-list__item { + background: rgba(148, 163, 184, 0.05); + border: 1px solid var(--panel-border); + border-radius: 18px; + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.speaker-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.speaker-list__name { + font-weight: 600; + font-size: 1rem; +} + +.speaker-list__preview { + font-size: 1.1rem; + line-height: 1; + width: 2.4rem; + height: 2.4rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid var(--panel-border); + color: var(--accent); + background: rgba(56, 189, 248, 0.08); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; +} + +.speaker-list__preview:hover { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15); + transform: translateY(-1px); +} + +.speaker-list__preview .spinner { + display: none; +} + +.speaker-list__preview[data-loading="true"] .spinner { + display: inline-block; +} + +.speaker-list__preview[data-loading="true"] .icon-button__glyph { + display: none; +} + +.speaker-list__preview[data-loading="true"] { + cursor: progress; + box-shadow: none; + color: transparent; +} + +.speaker-list__field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.speaker-list__field input { + border-radius: 12px; + border: 1px solid var(--panel-border); + background: rgba(15, 23, 42, 0.6); + padding: 0.6rem 0.8rem; + color: var(--text); + font-size: 0.95rem; +} + +.speaker-list__field input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.2); +} + +.speaker-list__field select { + border-radius: 12px; + border: 1px solid var(--panel-border); + background: rgba(15, 23, 42, 0.6); + padding: 0.55rem 0.75rem; + color: var(--text); +} + +.speaker-list__field select[multiple] { + min-height: 6rem; +} + +.speaker-list__meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + font-size: 0.8rem; + margin-bottom: 0.3rem; + align-items: center; +} + +.speaker-gender { + position: relative; + display: inline-flex; + align-items: center; +} + +.speaker-gender__pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.32rem 0.8rem; + min-width: 8.5rem; + border-radius: 999px; + border: 1px solid rgba(56, 189, 248, 0.35); + background: rgba(56, 189, 248, 0.12); + color: var(--accent); + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.speaker-gender__pill:hover, +.speaker-gender__pill.is-open { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.18); +} + +.speaker-gender__menu { + position: absolute; + top: calc(100% + 0.45rem); + left: 0; + z-index: 40; + min-width: 220px; + padding: 0.75rem; + border-radius: 16px; + border: 1px solid var(--panel-border); + background: rgba(15, 23, 42, 0.96); + box-shadow: 0 18px 40px rgba(7, 14, 32, 0.55); +} + +.speaker-gender__menu[hidden] { + display: none !important; +} + +.speaker-gender__options { + display: grid; + gap: 0.35rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.speaker-gender__options .chip { + width: 100%; + justify-content: center; +} + +.speaker-list__controls { + display: grid; + gap: 1rem; +} + +.speaker-list__inline { + display: flex; + align-items: center; +} + +.speaker-list__recommendations { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-top: 0.25rem; +} + +.chip { + border: 1px solid rgba(56, 189, 248, 0.35); + background: rgba(56, 189, 248, 0.12); + color: var(--accent); + border-radius: 999px; + padding: 0.3rem 0.7rem; + font-size: 0.8rem; + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease; +} + +.chip:hover { + border-color: var(--accent); + transform: translateY(-1px); +} + +.speaker-list__stats { + margin: 0; + color: var(--muted); + font-size: 0.85rem; +} + +.prepare-metadata h2 { + font-size: 1rem; + margin: 0 0 0.6rem; + color: var(--muted); +} + +.prepare-metadata ul { + margin: 0; + padding-left: 1.1rem; + display: grid; + gap: 0.35rem; + font-size: 0.95rem; +} + +.prepare-form { + display: grid; + gap: 2rem; +} + +.prepare-step { + display: grid; + gap: 2rem; +} + +.prepare-step__header { + display: grid; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.chapter-grid { + display: grid; + gap: 1.25rem; + margin-top: 1.5rem; +} + +.prepare-options { + display: grid; + gap: 1.5rem; + margin-top: 1.75rem; +} + +@media (min-width: 880px) { + .prepare-options { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .prepare-options .field { + max-width: none; + } +} + +.prepare-step__actions { + margin-top: 0; + padding-top: 1.5rem; + padding-bottom: 0.5rem; + display: flex; + justify-content: flex-end; + border-top: 1px solid rgba(148, 163, 184, 0.22); +} + +.chapter-card { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 22px; + padding: 1.25rem 1.5rem; + background: rgba(15, 23, 42, 0.32); + display: grid; + gap: 1.1rem; +} + +.chapter-card[data-disabled="true"] { + opacity: 0.5; +} + +.chapter-card__summary { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1.25rem; +} + +.chapter-card__summary:focus-within { + outline: none; +} + +.chapter-card__toggle { + border: none; + background: rgba(148, 163, 184, 0.1); + color: var(--muted); + width: 32px; + height: 32px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease; +} + +.chapter-card__toggle:hover, +.chapter-card__toggle:focus-visible { + background: rgba(148, 163, 184, 0.2); + color: #fff; +} + +.chapter-card__toggle[disabled] { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; +} + +.chapter-card__toggle:focus-visible { + outline: 2px solid rgba(56, 189, 248, 0.45); + outline-offset: 2px; +} + +.chapter-card__toggle-icon { + display: inline-flex; + font-size: 1rem; + transition: transform 0.2s ease; +} + +.chapter-card[data-expanded="true"] .chapter-card__toggle-icon { + transform: rotate(180deg); +} + +.chapter-card__details { + display: grid; + gap: 1rem; +} + +.chapter-card__snippet { + margin: 0; + font-size: 0.95rem; + line-height: 1.5; + color: var(--muted); +} + +.chapter-card__title { + font-weight: 600; + display: flex; + align-items: center; + gap: 0.6rem; +} + +.chapter-card__checkbox { + display: inline-flex; + align-items: center; + gap: 0.6rem; +} + +.chapter-card__checkbox input { + width: 1rem; + height: 1rem; + accent-color: var(--accent); +} + +.speaker-configs__layout { + display: grid; + gap: 2rem; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + align-items: start; +} + +.speaker-configs__sidebar { + display: grid; + gap: 1rem; +} + +.speaker-configs__sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.speaker-configs__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.75rem; +} + +.speaker-configs__entry { + border: 1px solid var(--panel-border); + border-radius: 18px; + padding: 0.9rem 1rem; + background: rgba(15, 23, 42, 0.35); + display: grid; + gap: 0.35rem; +} + +.speaker-configs__entry.is-active { + border-color: rgba(56, 189, 248, 0.45); + background: rgba(56, 189, 248, 0.12); +} + +.speaker-configs__entry a { + color: #fff; + font-weight: 600; + text-decoration: none; +} + +.speaker-configs__entry a:hover { + color: var(--accent); +} + +.speaker-configs__meta { + color: var(--muted); + font-size: 0.85rem; +} + +.speaker-configs__delete { + margin: 0; + justify-self: flex-start; +} + +.speaker-configs__empty { + padding: 0.75rem 0; + color: var(--muted); + font-style: italic; +} + +.speaker-config-form__grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.speaker-config-form .field--full { + grid-column: 1 / -1; +} + +.speaker-config-rows { + margin-top: 2rem; + display: grid; + gap: 1rem; +} + +.speaker-config-rows__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.speaker-config-rows__list { + display: grid; + gap: 1rem; +} + +.speaker-config-rows__empty { + border: 1px dashed rgba(148, 163, 184, 0.35); + border-radius: 16px; + padding: 1rem; + color: var(--muted); + font-style: italic; +} + +.speaker-config-row { + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 18px; + background: rgba(15, 23, 42, 0.4); + padding: 1rem 1.25rem; + display: grid; + gap: 1rem; +} + +.speaker-config-row__grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.speaker-config-row__footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.speaker-configs__actions { + margin-top: 2rem; + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; +} + +.button--small { + padding: 0.5rem 0.85rem; + font-size: 0.85rem; +} + +.chapter-card__field label { + font-size: 0.85rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.chapter-card__field input, +.chapter-card__field select { + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgba(15, 23, 42, 0.45); + padding: 0.7rem 0.9rem; + color: var(--text); + font-size: 0.95rem; +} + +.chapter-card__preview summary { + cursor: pointer; + color: var(--accent); + font-weight: 500; +} + +.chapter-card__preview pre { + margin: 0.35rem 0 0; + padding: 0.75rem 0.9rem; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(15, 23, 42, 0.55); + max-height: 200px; + overflow-y: auto; + font-size: 0.85rem; + line-height: 1.45; + white-space: pre-wrap; +} + +.chapter-card__formula { + max-width: 420px; +} + +.prepare-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 1rem; + padding-top: 1.25rem; +} + +.prepare-actions .button { + min-width: 200px; + padding-left: 1.75rem; + padding-right: 1.75rem; +} + +.prepare-actions .button--ghost { + border-color: rgba(148, 163, 184, 0.35); +} + +.toggle-pill input:checked + span { + background: rgba(56, 189, 248, 0.15); + border-color: rgba(56, 189, 248, 0.4); + color: var(--accent); +} + +@media (max-width: 1024px) { + .prepare-summary { + grid-template-columns: 1fr; + } + + .prepare-summary__stats dl { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + } +} + +@media (max-width: 880px) { + .form-grid { + grid-template-columns: 1fr; + } + + .voice-preview { + width: 100%; + } +} + +.hint { + margin: 0; + font-size: 0.8rem; + color: var(--muted); +} + +.settings__actions { + display: flex; + justify-content: flex-end; +} + +.pill-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.pill { + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.2); + padding: 0.35rem 0.75rem; + font-size: 0.8rem; + color: var(--muted); + background: rgba(15, 23, 42, 0.4); +} + +.text-preview { + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 18px; + background: rgba(15, 23, 42, 0.35); + padding: 1.1rem 1.25rem; + display: grid; + gap: 0.85rem; + min-height: 220px; +} + +.text-preview__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.text-preview__header h2 { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.text-preview__meta { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + color: var(--muted); +} + +.text-preview__body { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: inherit; + font-size: 0.95rem; + line-height: 1.5; + color: var(--text); +} + +.text-preview[data-state="empty"] .text-preview__body { + color: var(--muted); +} + +progress.progress { + width: 100%; + height: 0.6rem; + border-radius: 999px; + overflow: hidden; + background: rgba(148, 163, 184, 0.15); +} + +progress.progress::-webkit-progress-bar { + background: rgba(148, 163, 184, 0.15); + border-radius: 999px; +} + +progress.progress::-webkit-progress-value { + background: linear-gradient(90deg, var(--accent), var(--accent-strong)); + border-radius: 999px; +} + +progress.progress::-moz-progress-bar { + background: linear-gradient(90deg, var(--accent), var(--accent-strong)); + border-radius: 999px; +} + +.voice-mixer__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.voice-mixer__header-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.voice-mixer__layout { + display: grid; + grid-template-columns: minmax(260px, 300px) 1fr; + gap: 2.25rem; + align-items: start; +} + +.voice-mixer__profiles { + display: flex; + flex-direction: column; + gap: 1rem; + border-right: 1px solid var(--panel-border); + padding-right: 1.5rem; +} + +.voice-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.75rem; +} + +.voice-list__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.voice-list__item { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + padding: 0.75rem 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + background: rgba(15, 23, 42, 0.35); + transition: border 0.2s ease, background 0.2s ease; +} + +.voice-list__item.is-selected { + border-color: var(--accent); + background: rgba(56, 189, 248, 0.1); +} + +.voice-list__select { + all: unset; + display: flex; + flex-direction: column; + gap: 0.25rem; + cursor: pointer; +} + +.voice-list__select:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.voice-list__name { + font-weight: 600; +} + +.voice-list__meta { + font-size: 0.78rem; + color: var(--muted); +} + +.voice-list__actions { + display: flex; + gap: 0.5rem; +} + +.voice-list__action { + border: none; + border-radius: 999px; + padding: 0.35rem 0.65rem; + font-size: 0.75rem; + cursor: pointer; + background: rgba(148, 163, 184, 0.15); + color: var(--muted); + transition: background 0.2s ease, color 0.2s ease; +} + +.voice-list__action:hover { + background: rgba(56, 189, 248, 0.18); + color: var(--accent); +} + +.voice-list__action--danger:hover { + background: rgba(248, 113, 113, 0.2); + color: var(--danger); +} + +.voice-editor { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.voice-editor__meta { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.voice-editor__identity { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem 1rem; + align-items: end; +} + +.voice-editor__name-field { + min-width: 200px; +} + +.voice-editor__toolbar { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 0.65rem; + min-height: 2.6rem; +} + +.voice-editor__language { + max-width: 260px; + width: min(100%, 260px); +} + +.voice-editor__summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.voice-editor__canvas { + display: grid; + grid-template-columns: minmax(280px, 340px) minmax(320px, 1fr); + gap: 2rem; + align-items: start; +} + +.voice-available, +.voice-mix { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.voice-available__header, +.voice-mix__header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.voice-available__title { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.voice-filter { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 180px; +} + +.voice-filter__label { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} + +.voice-filter__controls { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.voice-filter select { + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.2); + background: rgba(15, 23, 42, 0.55); + padding: 0.55rem 0.75rem; + color: var(--text); + font-size: 0.95rem; + min-width: 0; + flex: 1 1 200px; +} + +.voice-filter select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15); +} + +.voice-gender-filter { + display: inline-flex; + align-items: center; + gap: 0.35rem; + flex: 0 0 auto; +} + +.voice-gender-filter__button { + border: 1px solid rgba(148, 163, 184, 0.3); + background: rgba(15, 23, 42, 0.45); + color: var(--muted); + font-size: 1.05rem; + line-height: 1; + width: 2.25rem; + height: 2.25rem; + border-radius: 999px; + cursor: pointer; + transition: border 0.2s ease, background 0.2s ease, color 0.2s ease; +} + +.voice-gender-filter__button:hover { + border-color: var(--accent); + color: var(--accent); +} + +.voice-gender-filter__button[aria-pressed="true"] { + background: rgba(56, 189, 248, 0.16); + border-color: var(--accent); + color: #fff; + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15); +} + +.voice-available__list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.85rem; + max-height: 55vh; + overflow-y: auto; + padding-right: 0.5rem; +} + +.voice-available__empty { + margin: 0; + padding: 1.25rem 1rem; + text-align: center; + border: 1px dashed rgba(148, 163, 184, 0.25); + border-radius: 16px; + color: var(--muted); + background: rgba(15, 23, 42, 0.3); +} + +.voice-available__card { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 18px; + background: rgba(15, 23, 42, 0.33); + padding: 0.9rem 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + cursor: grab; + transition: border 0.2s ease, transform 0.2s ease, background 0.2s ease; +} + +.voice-available__card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.voice-available__card:hover { + border-color: var(--accent); + transform: translateY(-1px); +} + +.voice-available__card.is-active { + border-color: rgba(56, 189, 248, 0.35); + background: rgba(56, 189, 248, 0.12); + cursor: default; +} + +.voice-available__info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.voice-available__name { + font-weight: 600; +} + +.voice-available__meta { + font-size: 0.78rem; + color: var(--muted); +} + +.voice-available__add { + border: none; + border-radius: 12px; + padding: 0.4rem 0.85rem; + cursor: pointer; + background: rgba(56, 189, 248, 0.15); + color: var(--accent); + font-weight: 600; + transition: background 0.2s ease, color 0.2s ease; +} + +.voice-available__add:hover:not(:disabled) { + background: rgba(56, 189, 248, 0.25); + color: #fff; +} + +.voice-available__add:disabled { + cursor: default; + background: rgba(148, 163, 184, 0.2); + color: var(--muted); +} + +.voice-mix__dropzone { + border: 2px dashed rgba(148, 163, 184, 0.25); + border-radius: 20px; + padding: 1.5rem; + min-height: 320px; + background: rgba(15, 23, 42, 0.25); + transition: border 0.2s ease, background 0.2s ease; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.voice-mix__dropzone.is-hovered { + border-color: var(--accent); + background: rgba(56, 189, 248, 0.08); +} + +.voice-mix__empty { + margin: 0; + text-align: center; + color: var(--muted); + font-size: 0.9rem; +} + +.voice-mix__list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.mix-voice { + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 18px; + background: rgba(15, 23, 42, 0.4); + padding: 1.1rem 1.2rem; + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.mix-voice__header { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.mix-voice__info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.mix-voice__title { + font-weight: 600; +} + +.mix-voice__meta { + font-size: 0.78rem; + color: var(--muted); +} + +.mix-voice__weight { + margin-left: auto; + font-size: 0.85rem; + font-weight: 600; + color: var(--accent); +} + +.mix-voice__remove { + border: none; + border-radius: 12px; + width: 28px; + height: 28px; + background: rgba(248, 113, 113, 0.2); + color: #fca5a5; + font-size: 1.1rem; + cursor: pointer; + line-height: 1; + display: grid; + place-items: center; + transition: background 0.2s ease, color 0.2s ease; +} + +.mix-voice__remove:hover { + background: rgba(248, 113, 113, 0.35); + color: #fff; +} + +.mix-slider { + width: 100%; + appearance: none; + height: 8px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.25); + outline: none; + cursor: pointer; + transition: box-shadow 0.2s ease; +} + +.mix-slider:focus-visible { + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.2); +} + +.mix-slider::-webkit-slider-thumb { + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + box-shadow: 0 6px 15px rgba(14, 165, 233, 0.4); + border: none; +} + +.mix-slider::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + border: none; + box-shadow: 0 6px 15px rgba(14, 165, 233, 0.4); +} + +.voice-editor__actions { + display: grid; + gap: 1.25rem; +} + +.voice-preview { + display: grid; + gap: 0.9rem; + padding: 1rem 1.25rem; + border-radius: 18px; + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgba(15, 23, 42, 0.6); + box-shadow: 0 16px 40px rgba(8, 15, 32, 0.45); + width: min(100%, 520px); +} + +.field--slider { + display: flex; + flex-direction: column; + gap: 0.65rem; + align-items: flex-start; + width: min(100%, 420px); +} + +.field--slider label { + display: flex; + align-items: center; + gap: 0.5rem; +} + +#preview-speed { + width: 100%; + appearance: none; + height: 8px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.25); + outline: none; + transition: box-shadow 0.2s ease; +} + +#preview-speed:focus-visible { + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.2); +} + +#preview-speed::-webkit-slider-thumb { + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + border: none; + box-shadow: 0 6px 15px rgba(14, 165, 233, 0.4); +} + +#preview-speed::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + border: none; + box-shadow: 0 6px 15px rgba(14, 165, 233, 0.4); +} + +.voice-preview__controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: flex-start; + width: min(100%, 420px); +} + +.voice-preview audio { + width: min(100%, 420px); + border-radius: 12px; + background: rgba(15, 23, 42, 0.45); + border: 1px solid rgba(148, 163, 184, 0.18); +} + +.voice-preview textarea { + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(11, 18, 34, 0.85); + min-height: 140px; + width: min(100%, 420px); + max-width: 420px; + align-self: flex-start; +} + +.voice-status { + min-height: 1.2rem; + font-size: 0.9rem; +} + +.voice-status--info { + color: var(--accent); +} + +.voice-status--success { + color: var(--success); +} + +.voice-status--warning { + color: var(--warning); +} + +.voice-status--danger { + color: var(--danger); +} + +.voice-mixer[data-state="loading"] .voice-editor { + opacity: 0.6; + pointer-events: none; +} + +[data-state="locked"] { + cursor: not-allowed; +} + +select[data-state="locked"], +input[data-state="locked"] { + background: rgba(15, 23, 42, 0.35); + color: rgba(148, 163, 184, 0.8); + border-color: rgba(148, 163, 184, 0.2); +} + +.icon-button { + appearance: none; + border-radius: 16px; + width: 2.6rem; + height: 2.6rem; + display: grid; + place-items: center; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(15, 23, 42, 0.4); + color: var(--muted); + cursor: pointer; + transition: color 0.2s ease, border 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; +} + +.voice-editor__identity > .voice-editor__toolbar { + align-self: end; +} + +.icon-button svg { + width: 1.15rem; + height: 1.15rem; +} + +.icon-button:hover { + border-color: var(--accent); + color: var(--accent); +} + +.icon-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.icon-button:disabled { + opacity: 0.45; + cursor: not-allowed; + box-shadow: none; +} + +.icon-button--borderless { + background: transparent; + border-color: transparent; +} + +.icon-button--borderless:hover { + background: rgba(148, 163, 184, 0.1); + border-color: rgba(148, 163, 184, 0.3); +} + +.icon-button--borderless:focus-visible { + border-color: var(--accent); +} + +.icon-button--primary { + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + border: none; + color: #fff; + box-shadow: 0 10px 25px rgba(56, 189, 248, 0.32); +} + +.icon-button--primary:hover { + box-shadow: 0 14px 28px rgba(14, 165, 233, 0.4); +} + +.icon-button--danger { + background: linear-gradient(135deg, var(--danger), #ef4444); + border: none; + color: #fff; + box-shadow: 0 10px 25px rgba(239, 68, 68, 0.28); +} + +.icon-button--danger:hover { + box-shadow: 0 14px 30px rgba(248, 113, 113, 0.35); +} + +.icon-button--danger:disabled { + background: rgba(248, 113, 113, 0.2); + color: rgba(248, 113, 113, 0.65); + border: 1px solid rgba(248, 113, 113, 0.25); +} + +.icon-button--primary:disabled { + background: rgba(56, 189, 248, 0.25); + border: 1px solid rgba(56, 189, 248, 0.2); + color: rgba(255, 255, 255, 0.8); + box-shadow: none; +} + +.spinner { + display: inline-block; + width: 1.1rem; + height: 1.1rem; + border-radius: 50%; + border: 2px solid rgba(148, 163, 184, 0.28); + border-top-color: var(--accent); + animation: spin 0.8s linear infinite; +} + +.spinner--sm { + width: 0.85rem; + height: 0.85rem; +} + +.spinner--lg { + width: 1.5rem; + height: 1.5rem; + border-width: 3px; +} + +.spinner--muted { + border-color: rgba(148, 163, 184, 0.2); + border-top-color: rgba(148, 163, 184, 0.6); +} + +.button[data-role="preview-button"] { + position: relative; +} + +.button[data-role="preview-button"][data-loading="true"] { + padding-left: 2.8rem; + color: rgba(255, 255, 255, 0.8); +} + +.button[data-role="preview-button"][data-loading="true"]::after { + content: ""; + position: absolute; + left: 1.1rem; + top: 50%; + width: 1rem; + height: 1rem; + margin-top: -0.5rem; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.35); + border-right-color: rgba(255, 255, 255, 0.85); + animation: spin 0.8s linear infinite; +} + +@media (prefers-reduced-motion: reduce) { + .button[data-role="preview-button"][data-loading="true"]::after, + .button[data-loading="true"]:not([data-role="preview-button"])::after, + [data-role="new-job-modal"][data-submitting="true"] .wizard-card__stage::before, + .spinner { + animation-duration: 1.6s; + } +} + +@media (max-width: 980px) { + .voice-mixer__layout { + grid-template-columns: 1fr; + } + + .voice-mixer__profiles { + border-right: none; + border-bottom: 1px solid var(--panel-border); + padding-right: 0; + padding-bottom: 1.5rem; + margin-bottom: 1.5rem; + } + + .voice-editor__canvas { + grid-template-columns: 1fr; + } + + .voice-available__list { + max-height: none; + padding-right: 0; + } + + .voice-available__header, + .voice-mix__header { + align-items: stretch; + } + + .voice-filter { + width: 100%; + } + + .voice-mix__dropzone { + min-height: 220px; + } + + .field--slider label { + flex-direction: column; + align-items: flex-start; + } +} + +@media (max-width: 720px) { + .voice-editor__identity { + grid-template-columns: 1fr; + } + + .voice-editor__toolbar { + justify-content: flex-start; + } + + .settings__section { + grid-template-columns: 1fr; + } + + .field--choices { + grid-template-columns: 1fr; + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.entity-tabs__status { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + color: var(--muted); + font-size: 0.875rem; + padding-right: 1rem; +} + +.entity-tabs__status-text { + font-weight: 500; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} diff --git a/abogen/webui/static/voices.js b/abogen/webui/static/voices.js new file mode 100644 index 0000000..5341c56 --- /dev/null +++ b/abogen/webui/static/voices.js @@ -0,0 +1,1206 @@ +const setupVoiceMixer = () => { + const data = window.ABOGEN_VOICE_MIXER_DATA || {}; + const languages = data.languages || {}; + const voiceCatalog = Array.isArray(data.voice_catalog) ? data.voice_catalog : []; + const samples = data.sample_voice_texts || {}; + let profiles = data.voice_profiles_data || {}; + + const app = document.getElementById("voice-mixer-app"); + if (!app) { + return; + } + + const profileListEl = app.querySelector('[data-role="profile-list"]'); + const statusEl = app.querySelector('[data-role="status"]'); + const saveBtn = app.querySelector('[data-role="save-profile"]'); + const duplicateBtn = app.querySelector('[data-role="duplicate-profile"]'); + const deleteBtn = app.querySelector('[data-role="delete-profile"]'); + const previewBtn = app.querySelector('[data-role="preview-button"]'); + const loadSampleBtn = app.querySelector('[data-role="load-sample"]'); + const previewTextEl = app.querySelector('[data-role="preview-text"]'); + const previewAudio = app.querySelector('[data-role="preview-audio"]'); + const previewSpeedLabel = app.querySelector('[data-role="preview-speed-display"]'); + const profileSummaryEl = app.querySelector('[data-role="profile-summary"]'); + const mixTotalEl = app.querySelector('[data-role="mix-total"]'); + const nameInput = document.getElementById("profile-name"); + const languageSelect = document.getElementById("profile-language"); + const languageField = app.querySelector(".voice-editor__language"); + const providerSelect = document.getElementById("profile-provider"); + const kokoroMixerEl = app.querySelector('[data-role="kokoro-mixer"]'); + const supertonicPanelEl = app.querySelector('[data-role="supertonic-panel"]'); + const supertonicVoiceSelect = app.querySelector('[data-role="supertonic-voice"]'); + const supertonicStepsInput = app.querySelector('[data-role="supertonic-steps"]'); + const supertonicSpeedInput = app.querySelector('[data-role="supertonic-speed"]'); + const supertonicStepsLabel = app.querySelector('[data-role="supertonic-steps-display"]'); + const supertonicSpeedLabel = app.querySelector('[data-role="supertonic-speed-display"]'); + const speedInput = document.getElementById("preview-speed"); + const importInput = document.getElementById("voice-import-input"); + const headerActions = document.querySelector(".voice-mixer__header-actions"); + const availableListEl = app.querySelector('[data-role="available-voices"]'); + const selectedListEl = app.querySelector('[data-role="selected-voices"]'); + const dropzoneEl = app.querySelector('[data-role="dropzone"]'); + const emptyStateEl = app.querySelector('[data-role="mix-empty"]'); + const voiceFilterSelect = app.querySelector('[data-role="voice-filter"]'); + const genderFilterEl = app.querySelector('[data-role="gender-filter"]'); + + const providerPickerModal = document.querySelector('[data-role="provider-picker-modal"]'); + const providerPickerOverlay = document.querySelector('[data-role="provider-picker-overlay"]'); + const providerPickerClose = document.querySelector('[data-role="provider-picker-close"]'); + const providerPickerCancel = document.querySelector('[data-role="provider-picker-cancel"]'); + const providerPickerConfirm = document.querySelector('[data-role="provider-picker-confirm"]'); + const providerPickerOptions = document.querySelector('[data-role="provider-picker-options"]'); + + if (previewBtn && !previewBtn.dataset.label) { + previewBtn.dataset.label = previewBtn.textContent.trim(); + } + + if (!profileListEl || !availableListEl || !selectedListEl) { + return; + } + + const voiceLookup = new Map(); + voiceCatalog.forEach((voice) => { + if (voice && voice.id) { + voiceLookup.set(voice.id, voice); + } + }); + + const availableCards = new Map(); + const selectedControls = new Map(); + + const state = { + selectedProfile: null, + originalName: null, + dirty: false, + previewUrl: null, + draft: { + name: "", + provider: "kokoro", + language: "a", + voices: new Map(), + supertonic: { + voice: "M1", + total_steps: 5, + speed: 1.0, + }, + }, + languageFilter: voiceFilterSelect ? voiceFilterSelect.value : "", + genderFilter: "", + }; + + let statusTimeout = null; + + const clamp = (value, min, max) => Math.min(Math.max(value, min), max); + const formatWeight = (value) => value.toFixed(2); + + const setSliderFill = (slider, weight) => { + const percent = Math.round(clamp(weight, 0, 1) * 100); + slider.style.background = `linear-gradient(90deg, var(--accent) 0%, var(--accent) ${percent}%, rgba(148, 163, 184, 0.25) ${percent}%, rgba(148, 163, 184, 0.25) 100%)`; + }; + + const setRangeFill = (slider) => { + if (!slider) return; + const min = parseFloat(slider.min || "0"); + const max = parseFloat(slider.max || "1"); + const value = parseFloat(slider.value || String(min)); + const percent = max === min ? 0 : Math.round(((value - min) / (max - min)) * 100); + slider.style.background = `linear-gradient(90deg, var(--accent) 0%, var(--accent) ${percent}%, rgba(148, 163, 184, 0.25) ${percent}%, rgba(148, 163, 184, 0.25) 100%)`; + }; + + const voiceGenderIcon = (gender) => { + if (!gender) return "•"; + const initial = gender[0].toLowerCase(); + if (initial === "f") return "♀"; + if (initial === "m") return "♂"; + return "•"; + }; + + const voiceLanguageLabel = (code) => languages[code] || code?.toUpperCase() || ""; + + const clearStatus = () => { + if (statusTimeout) { + clearTimeout(statusTimeout); + statusTimeout = null; + } + if (statusEl) { + statusEl.textContent = ""; + statusEl.className = "voice-status"; + } + }; + + const setStatus = (message, tone = "info", timeout = 4000) => { + if (!statusEl) return; + clearStatus(); + statusEl.textContent = message; + statusEl.className = `voice-status voice-status--${tone}`; + if (timeout > 0) { + statusTimeout = window.setTimeout(() => { + clearStatus(); + }, timeout); + } + }; + + const mixTotal = () => { + let total = 0; + state.draft.voices.forEach((weight) => { + total += weight; + }); + return total; + }; + + const normalizeProvider = (value) => { + const candidate = String(value || "").trim().toLowerCase(); + return candidate === "supertonic" ? "supertonic" : "kokoro"; + }; + + const getProviderCatalog = () => { + if (!providerSelect) { + return [ + { id: "kokoro", label: "Kokoro" }, + { id: "supertonic", label: "Supertonic" }, + ]; + } + return Array.from(providerSelect.options || []).map((option) => ({ + id: normalizeProvider(option.value), + label: option.textContent?.trim() || option.value, + })); + }; + + const providerDescription = (providerId) => { + const provider = normalizeProvider(providerId); + if (provider === "supertonic") { + return "Voice selection + quality/speed per speaker."; + } + return "Voice mixing supported via the Kokoro mixer."; + }; + + const openProviderPicker = (defaultProvider = "kokoro") => { + if (!providerPickerModal || !providerPickerOptions || !providerPickerConfirm) { + return Promise.resolve(normalizeProvider(defaultProvider)); + } + + providerPickerOptions.innerHTML = ""; + const catalog = getProviderCatalog(); + const normalizedDefault = normalizeProvider(defaultProvider); + + catalog.forEach((item) => { + const label = document.createElement("label"); + label.className = "toggle-pill"; + + const input = document.createElement("input"); + input.type = "radio"; + input.name = "provider-picker"; + input.value = item.id; + input.checked = item.id === normalizedDefault; + + const span = document.createElement("span"); + const title = document.createElement("strong"); + title.textContent = item.label; + const detail = document.createElement("span"); + detail.className = "muted"; + detail.textContent = ` — ${providerDescription(item.id)}`; + span.appendChild(title); + span.appendChild(detail); + + label.appendChild(input); + label.appendChild(span); + providerPickerOptions.appendChild(label); + }); + + const selectedRadio = providerPickerOptions.querySelector('input[name="provider-picker"]:checked'); + providerPickerConfirm.disabled = !selectedRadio; + + return new Promise((resolve) => { + let resolved = false; + const teardown = () => { + providerPickerModal.dataset.open = "false"; + providerPickerModal.hidden = true; + document.body.classList.remove("modal-open"); + document.removeEventListener("keydown", onKeydown); + providerPickerOptions.removeEventListener("change", onChange); + providerPickerOverlay?.removeEventListener("click", onCancel); + providerPickerClose?.removeEventListener("click", onCancel); + providerPickerCancel?.removeEventListener("click", onCancel); + providerPickerConfirm?.removeEventListener("click", onConfirm); + }; + + const finish = (value) => { + if (resolved) return; + resolved = true; + teardown(); + resolve(value); + }; + + const onCancel = () => finish(null); + const onConfirm = () => { + const selected = providerPickerOptions.querySelector('input[name="provider-picker"]:checked'); + finish(selected ? normalizeProvider(selected.value) : null); + }; + const onChange = () => { + const selected = providerPickerOptions.querySelector('input[name="provider-picker"]:checked'); + providerPickerConfirm.disabled = !selected; + }; + const onKeydown = (event) => { + if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + } + }; + + providerPickerModal.hidden = false; + providerPickerModal.dataset.open = "true"; + document.body.classList.add("modal-open"); + document.addEventListener("keydown", onKeydown); + providerPickerOptions.addEventListener("change", onChange); + providerPickerOverlay?.addEventListener("click", onCancel); + providerPickerClose?.addEventListener("click", onCancel); + providerPickerCancel?.addEventListener("click", onCancel); + providerPickerConfirm?.addEventListener("click", onConfirm); + + const focusTarget = providerPickerOptions.querySelector('input[name="provider-picker"]:checked') + || providerPickerOptions.querySelector('input[name="provider-picker"]'); + if (focusTarget instanceof HTMLElement) { + focusTarget.focus(); + } + }); + }; + + const applyProviderToUI = () => { + const provider = normalizeProvider(state.draft.provider); + const isSupertonic = provider === "supertonic"; + if (providerSelect) { + providerSelect.value = provider; + } + if (languageField) { + languageField.hidden = isSupertonic; + } + if (kokoroMixerEl) { + kokoroMixerEl.hidden = isSupertonic; + } + if (supertonicPanelEl) { + supertonicPanelEl.hidden = !isSupertonic; + } + if (mixTotalEl) { + mixTotalEl.hidden = isSupertonic; + } + if (previewBtn) { + previewBtn.dataset.label = isSupertonic ? "Preview speaker" : (previewBtn.dataset.label || "Preview speaker"); + } + + // Keep preview speed aligned with the Supertonic speaker speed. + if (isSupertonic && speedInput) { + const desired = Number(state.draft.supertonic?.speed ?? 1.0); + if (!Number.isNaN(desired)) { + speedInput.value = String(desired); + setRangeFill(speedInput); + } + } + }; + + const updateMixSummary = () => { + const provider = normalizeProvider(state.draft.provider); + const isSupertonic = provider === "supertonic"; + if (mixTotalEl && !isSupertonic) { + mixTotalEl.textContent = `Total weight: ${formatWeight(mixTotal())}`; + } + if (profileSummaryEl) { + const voiceCount = state.draft.voices.size; + if (!state.draft.name && !voiceCount) { + profileSummaryEl.textContent = "Select or create a speaker to begin."; + } else { + const profileLabel = state.draft.name ? `Editing: ${state.draft.name}` : "Unsaved speaker"; + if (isSupertonic) { + profileSummaryEl.textContent = `${profileLabel} · Supertonic`; + } else { + profileSummaryEl.textContent = `${profileLabel} · ${voiceCount} voice${voiceCount === 1 ? "" : "s"}`; + } + } + } + }; + + const markDirty = () => { + state.dirty = true; + if (saveBtn) { + saveBtn.disabled = false; + } + }; + + const resetDirty = () => { + state.dirty = false; + if (saveBtn) { + saveBtn.disabled = true; + } + }; + + const ensureEmptyState = () => { + if (!emptyStateEl) return; + emptyStateEl.hidden = state.draft.voices.size > 0; + }; + + const updateAvailableState = () => { + availableCards.forEach(({ card, addButton }, voiceId) => { + const isActive = state.draft.voices.has(voiceId); + card.classList.toggle("is-active", isActive); + if (addButton) { + addButton.disabled = isActive; + addButton.textContent = isActive ? "Added" : "Add"; + } + }); + }; + + const updateGenderFilterButtons = () => { + if (!genderFilterEl) return; + const buttons = genderFilterEl.querySelectorAll("[data-value]"); + buttons.forEach((button) => { + const value = button.getAttribute("data-value") || ""; + const pressed = value === state.genderFilter; + button.setAttribute("aria-pressed", pressed ? "true" : "false"); + }); + }; + + const setSliderFocus = (voiceId) => { + const control = selectedControls.get(voiceId); + if (control?.slider) { + control.slider.focus({ preventScroll: false }); + } + }; + + const renderSelectedVoices = () => { + selectedControls.clear(); + selectedListEl.innerHTML = ""; + + state.draft.voices.forEach((weight, voiceId) => { + const meta = voiceLookup.get(voiceId) || {}; + const card = document.createElement("div"); + card.className = "mix-voice"; + card.dataset.voiceId = voiceId; + + const header = document.createElement("div"); + header.className = "mix-voice__header"; + + const titleWrap = document.createElement("div"); + titleWrap.className = "mix-voice__info"; + + const title = document.createElement("div"); + title.className = "mix-voice__title"; + title.textContent = meta.display_name || meta.name || voiceId; + + const metaLabel = document.createElement("div"); + metaLabel.className = "mix-voice__meta"; + const languageCode = meta.language || voiceId.charAt(0) || "a"; + metaLabel.textContent = `${voiceLanguageLabel(languageCode)} · ${voiceGenderIcon(meta.gender)}`; + + titleWrap.appendChild(title); + titleWrap.appendChild(metaLabel); + + const weightLabel = document.createElement("span"); + weightLabel.className = "mix-voice__weight"; + weightLabel.textContent = formatWeight(weight); + + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "mix-voice__remove"; + removeBtn.setAttribute("aria-label", `Remove ${title.textContent} from mix`); + removeBtn.innerHTML = "×"; + removeBtn.addEventListener("click", () => { + state.draft.voices.delete(voiceId); + renderSelectedVoices(); + updateAvailableState(); + updateMixSummary(); + markDirty(); + }); + + header.appendChild(titleWrap); + header.appendChild(weightLabel); + header.appendChild(removeBtn); + + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = "5"; + slider.max = "100"; + slider.step = "1"; + slider.className = "mix-slider"; + const normalizedWeight = clamp(weight, 0.05, 1); + slider.value = String(Math.round(normalizedWeight * 100)); + setSliderFill(slider, normalizedWeight); + slider.addEventListener("input", () => { + const value = clamp(Number(slider.value) / 100, 0.05, 1); + slider.value = String(Math.round(value * 100)); + state.draft.voices.set(voiceId, value); + weightLabel.textContent = formatWeight(value); + setSliderFill(slider, value); + updateMixSummary(); + markDirty(); + }); + + card.appendChild(header); + card.appendChild(slider); + selectedListEl.appendChild(card); + + selectedControls.set(voiceId, { slider, weightLabel }); + }); + + ensureEmptyState(); + }; + + const renderAvailableVoices = () => { + availableCards.clear(); + availableListEl.innerHTML = ""; + + const sortedVoices = voiceCatalog + .slice() + .sort((a, b) => (a.display_name || a.id).localeCompare(b.display_name || b.id)); + + const filteredVoices = sortedVoices.filter((voice) => { + const languageCode = voice.language || voice.id?.charAt(0) || "a"; + const languageMatch = !state.languageFilter || state.languageFilter === languageCode; + const genderCode = (voice.gender || "").charAt(0).toLowerCase(); + const genderMatch = !state.genderFilter || state.genderFilter === genderCode; + return languageMatch && genderMatch; + }); + + if (!filteredVoices.length) { + const empty = document.createElement("p"); + empty.className = "voice-available__empty"; + const filters = []; + if (state.languageFilter) { + filters.push(voiceLanguageLabel(state.languageFilter) || state.languageFilter.toUpperCase()); + } + if (state.genderFilter) { + filters.push(state.genderFilter === "f" ? "♀ Female" : "♂ Male"); + } + if (filters.length) { + empty.innerHTML = `No voices match ${filters.join(" · ")}.`; + } else { + empty.textContent = "No voices available."; + } + availableListEl.appendChild(empty); + updateAvailableState(); + updateGenderFilterButtons(); + return; + } + + filteredVoices.forEach((voice) => { + if (!voice?.id) { + return; + } + const card = document.createElement("article"); + card.className = "voice-available__card"; + card.draggable = true; + card.dataset.voiceId = voice.id; + card.tabIndex = 0; + + card.addEventListener("dragstart", (event) => { + card.classList.add("is-dragging"); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "copy"; + event.dataTransfer.setData("text/plain", voice.id); + } + }); + + card.addEventListener("dragend", () => { + card.classList.remove("is-dragging"); + }); + + card.addEventListener("dblclick", () => { + addVoiceToDraft(voice.id); + }); + + card.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + addVoiceToDraft(voice.id); + } + }); + + const info = document.createElement("div"); + info.className = "voice-available__info"; + + const name = document.createElement("div"); + name.className = "voice-available__name"; + name.textContent = voice.display_name || voice.id; + + const meta = document.createElement("div"); + meta.className = "voice-available__meta"; + const languageCode = voice.language || voice.id.charAt(0) || "a"; + meta.textContent = `${voiceLanguageLabel(languageCode)} · ${voiceGenderIcon(voice.gender)}`; + + info.appendChild(name); + info.appendChild(meta); + + const addButton = document.createElement("button"); + addButton.type = "button"; + addButton.className = "voice-available__add"; + addButton.textContent = "Add"; + addButton.addEventListener("click", (event) => { + event.stopPropagation(); + addVoiceToDraft(voice.id); + }); + + card.appendChild(info); + card.appendChild(addButton); + availableListEl.appendChild(card); + + availableCards.set(voice.id, { card, addButton }); + }); + + updateAvailableState(); + updateGenderFilterButtons(); + availableListEl.scrollTop = 0; + }; + + const addVoiceToDraft = (voiceId, weight = 0.6) => { + if (!voiceLookup.has(voiceId)) { + return; + } + if (state.draft.voices.has(voiceId)) { + setSliderFocus(voiceId); + return; + } + state.draft.voices.set(voiceId, clamp(weight, 0.05, 1)); + renderSelectedVoices(); + updateAvailableState(); + updateMixSummary(); + markDirty(); + setSliderFocus(voiceId); + }; + + const buildProfilePayload = () => + Array.from(state.draft.voices.entries()).map(([voiceId, weight]) => ({ + id: voiceId, + weight, + enabled: weight > 0, + })); + + const updateActionButtons = () => { + const hasSelection = Boolean(state.selectedProfile && profiles[state.selectedProfile]); + if (duplicateBtn) { + duplicateBtn.disabled = !hasSelection; + } + if (deleteBtn) { + deleteBtn.disabled = !hasSelection; + } + }; + + const applyDraftToControls = () => { + if (nameInput) { + nameInput.value = state.draft.name || ""; + } + if (languageSelect) { + languageSelect.value = state.draft.language || "a"; + } + if (supertonicVoiceSelect) { + supertonicVoiceSelect.value = state.draft.supertonic?.voice || "M1"; + } + if (supertonicStepsInput) { + supertonicStepsInput.value = String(state.draft.supertonic?.total_steps ?? 5); + setRangeFill(supertonicStepsInput); + } + if (supertonicSpeedInput) { + supertonicSpeedInput.value = String(state.draft.supertonic?.speed ?? 1.0); + setRangeFill(supertonicSpeedInput); + } + if (supertonicStepsLabel) { + supertonicStepsLabel.textContent = String(state.draft.supertonic?.total_steps ?? 5); + } + if (supertonicSpeedLabel) { + const speed = Number(state.draft.supertonic?.speed ?? 1.0); + supertonicSpeedLabel.textContent = `${(Number.isFinite(speed) ? speed : 1.0).toFixed(2)}×`; + } + applyProviderToUI(); + renderSelectedVoices(); + updateMixSummary(); + updateAvailableState(); + updateActionButtons(); + resetDirty(); + }; + + const renderProfileList = () => { + profileListEl.innerHTML = ""; + + const header = document.createElement("div"); + header.className = "voice-list__header"; + const heading = document.createElement("h2"); + heading.textContent = "Saved speakers"; + header.appendChild(heading); + profileListEl.appendChild(header); + + const names = Object.keys(profiles).sort((a, b) => a.localeCompare(b)); + if (!names.length) { + const empty = document.createElement("p"); + empty.className = "tag"; + empty.textContent = "No speakers yet. Create one on the right."; + profileListEl.appendChild(empty); + return; + } + + const list = document.createElement("ul"); + list.className = "voice-list"; + + names.forEach((name) => { + const li = document.createElement("li"); + li.className = "voice-list__item"; + if (state.selectedProfile === name) { + li.classList.add("is-selected"); + } + + const selectBtn = document.createElement("button"); + selectBtn.type = "button"; + selectBtn.className = "voice-list__select"; + selectBtn.dataset.name = name; + const profile = profiles[name] || {}; + const provider = normalizeProvider(profile.provider); + const providerLabel = provider === "supertonic" ? "Supertonic" : "Kokoro"; + selectBtn.innerHTML = ` + ${name} + ${providerLabel} ${voiceLanguageLabel(profile.language || "a")} + `; + selectBtn.addEventListener("click", () => selectProfile(name)); + + const actions = document.createElement("div"); + actions.className = "voice-list__actions"; + + const duplicateAction = document.createElement("button"); + duplicateAction.type = "button"; + duplicateAction.className = "voice-list__action"; + duplicateAction.textContent = "Duplicate"; + duplicateAction.addEventListener("click", (event) => { + event.stopPropagation(); + runDuplicate(name); + }); + + const deleteAction = document.createElement("button"); + deleteAction.type = "button"; + deleteAction.className = "voice-list__action voice-list__action--danger"; + deleteAction.textContent = "Delete"; + deleteAction.addEventListener("click", (event) => { + event.stopPropagation(); + runDelete(name); + }); + + actions.appendChild(duplicateAction); + actions.appendChild(deleteAction); + + li.appendChild(selectBtn); + li.appendChild(actions); + list.appendChild(li); + }); + + profileListEl.appendChild(list); + }; + + const selectProfile = (name) => { + state.selectedProfile = name; + state.originalName = name; + const profile = profiles[name]; + const provider = normalizeProvider(profile?.provider); + state.draft = { + name, + provider, + language: profile?.language || "a", + voices: new Map(), + supertonic: { + voice: profile?.voice || "M1", + total_steps: Number(profile?.total_steps ?? 5), + speed: Number(profile?.speed ?? 1.0), + }, + }; + if (provider === "kokoro" && Array.isArray(profile?.voices)) { + profile.voices.forEach((entry) => { + if (Array.isArray(entry) && entry.length >= 2) { + const [voiceId, weight] = entry; + const value = clamp(parseFloat(weight), 0, 1); + if (!Number.isNaN(value) && value > 0) { + const normalized = clamp(value, 0.05, 1); + state.draft.voices.set(String(voiceId), normalized); + } + } + }); + } + applyDraftToControls(); + renderProfileList(); + loadSampleText(); + setStatus(`Loaded speaker “${name}”.`, "info", 2500); + }; + + const startNewProfile = (provider = "kokoro") => { + state.selectedProfile = null; + state.originalName = null; + state.draft = { + name: "", + provider: normalizeProvider(provider), + language: languageSelect ? languageSelect.value || "a" : "a", + voices: new Map(), + supertonic: { + voice: "M1", + total_steps: 5, + speed: 1.0, + }, + }; + applyDraftToControls(); + renderProfileList(); + loadSampleText(); + }; + + const requestNewProfile = async () => { + const chosen = await openProviderPicker(normalizeProvider(state.draft.provider)); + if (!chosen) { + return; + } + startNewProfile(chosen); + setStatus("New speaker ready.", "info"); + }; + + const refreshProfiles = (nextProfiles, selectedName = null) => { + profiles = nextProfiles || {}; + renderProfileList(); + if (selectedName && profiles[selectedName]) { + selectProfile(selectedName); + } else if (state.selectedProfile && profiles[state.selectedProfile]) { + selectProfile(state.selectedProfile); + } else { + const names = Object.keys(profiles); + if (names.length) { + selectProfile(names[0]); + } else { + startNewProfile("kokoro"); + } + } + updateActionButtons(); + }; + + const loadSampleText = () => { + if (!previewTextEl || !languageSelect) return; + const lang = languageSelect.value || "a"; + previewTextEl.value = samples[lang] || samples.a || "This is a sample of the selected voice."; + }; + + const withJson = async (response) => { + if (response.ok) { + return response.json(); + } + let message = "Unexpected error"; + try { + const data = await response.json(); + message = data.error || data.message || message; + } catch (err) { + message = await response.text(); + } + throw new Error(message); + }; + + const runSave = async () => { + if (!nameInput) return; + const name = nameInput.value.trim(); + if (!name) { + setStatus("Give your profile a name first.", "warning"); + return; + } + const payload = { + name, + originalName: state.originalName, + provider: normalizeProvider(state.draft.provider), + language: normalizeProvider(state.draft.provider) === "kokoro" ? (languageSelect ? languageSelect.value : "a") : "a", + voices: normalizeProvider(state.draft.provider) === "kokoro" ? buildProfilePayload() : [], + voice: state.draft.supertonic?.voice, + total_steps: state.draft.supertonic?.total_steps, + speed: state.draft.supertonic?.speed, + }; + try { + const response = await fetch("/api/voice-profiles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const result = await withJson(response); + refreshProfiles(result.profiles, result.profile); + resetDirty(); + setStatus(`Saved speaker “${result.profile}”.`, "success"); + } catch (error) { + setStatus(error.message || "Failed to save profile", "danger", 7000); + } + }; + + const runDelete = async (targetName = null) => { + const name = targetName || state.selectedProfile; + if (!name) { + setStatus("Select a profile to delete.", "warning"); + return; + } + const confirmed = window.confirm(`Delete speaker “${name}”?`); + if (!confirmed) return; + try { + const response = await fetch(`/api/voice-profiles/${encodeURIComponent(name)}`, { + method: "DELETE", + }); + const result = await withJson(response); + refreshProfiles(result.profiles); + setStatus(`Deleted speaker “${name}”.`, "info"); + } catch (error) { + setStatus(error.message || "Failed to delete profile", "danger", 7000); + } + }; + + const runDuplicate = async (targetName = null) => { + const name = targetName || state.selectedProfile; + if (!name) { + setStatus("Select a profile to duplicate.", "warning"); + return; + } + const newName = window.prompt("Duplicate speaker as…", `${name} copy`); + if (!newName) return; + try { + const response = await fetch(`/api/voice-profiles/${encodeURIComponent(name)}/duplicate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newName }), + }); + const result = await withJson(response); + refreshProfiles(result.profiles, result.profile); + setStatus(`Duplicated to “${result.profile}”.`, "success"); + } catch (error) { + setStatus(error.message || "Failed to duplicate profile", "danger", 7000); + } + }; + + const runImport = async (file) => { + try { + const text = await file.text(); + const parsed = JSON.parse(text); + const replace = window.confirm("Replace existing speakers if duplicates are found?"); + const response = await fetch("/api/voice-profiles/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: parsed, replace_existing: replace }), + }); + const result = await withJson(response); + refreshProfiles(result.profiles); + setStatus(`Imported ${result.imported.length} speaker${result.imported.length === 1 ? "" : "s"}.`, "success"); + } catch (error) { + setStatus(error.message || "Import failed", "danger", 7000); + } finally { + importInput.value = ""; + } + }; + + const runExport = async () => { + const name = state.selectedProfile; + const query = name ? `?names=${encodeURIComponent(name)}` : ""; + try { + const response = await fetch(`/api/voice-profiles/export${query}`); + if (!response.ok) { + throw new Error("Export failed"); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = name ? `${name}.json` : "voice_profiles.json"; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + setStatus("Export complete.", "success"); + } catch (error) { + setStatus(error.message || "Export failed", "danger", 7000); + } + }; + + const runPreview = async () => { + if (!previewBtn) return; + const provider = normalizeProvider(state.draft.provider); + const payload = { + provider, + language: languageSelect ? languageSelect.value : "a", + voices: provider === "kokoro" ? buildProfilePayload() : [], + voice: state.draft.supertonic?.voice, + total_steps: state.draft.supertonic?.total_steps, + text: previewTextEl ? previewTextEl.value : "", + speed: speedInput ? parseFloat(speedInput.value || "1") : 1, + max_seconds: 8, + }; + if (provider === "kokoro") { + const enabledVoices = payload.voices.filter((entry) => entry.enabled && entry.weight > 0); + if (!enabledVoices.length) { + setStatus("Enable at least one voice to preview.", "warning"); + return; + } + } else { + if (!payload.voice) { + setStatus("Select a Supertonic voice to preview.", "warning"); + return; + } + payload.supertonic_total_steps = payload.total_steps; + payload.tts_provider = "supertonic"; + } + previewBtn.disabled = true; + previewBtn.dataset.loading = "true"; + previewBtn.setAttribute("aria-busy", "true"); + previewBtn.textContent = "Previewing…"; + setStatus("Generating preview…", "info", 0); + try { + const response = await fetch("/api/voice-profiles/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + const blob = await response.blob(); + if (state.previewUrl) { + URL.revokeObjectURL(state.previewUrl); + } + state.previewUrl = URL.createObjectURL(blob); + if (previewAudio) { + previewAudio.src = state.previewUrl; + previewAudio.play().catch(() => {}); + } + setStatus("Preview ready.", "success"); + } catch (error) { + setStatus(error.message || "Preview failed", "danger", 7000); + } finally { + previewBtn.disabled = false; + previewBtn.dataset.loading = "false"; + previewBtn.textContent = previewBtn.dataset.label || "Preview speaker"; + previewBtn.removeAttribute("aria-busy"); + } + }; + + if (saveBtn) { + const form = saveBtn.closest("form"); + if (form) { + form.addEventListener("submit", (event) => { + event.preventDefault(); + runSave(); + }); + } + } + + if (duplicateBtn) { + duplicateBtn.addEventListener("click", () => runDuplicate()); + } + + if (deleteBtn) { + deleteBtn.addEventListener("click", () => runDelete()); + } + + if (previewBtn) { + previewBtn.addEventListener("click", () => runPreview()); + } + + if (loadSampleBtn) { + loadSampleBtn.addEventListener("click", loadSampleText); + } + + if (languageSelect) { + languageSelect.addEventListener("change", () => { + state.draft.language = languageSelect.value; + markDirty(); + loadSampleText(); + }); + } + + if (providerSelect) { + providerSelect.addEventListener("change", () => { + state.draft.provider = normalizeProvider(providerSelect.value); + // When switching to Supertonic, clear Kokoro mix. + if (state.draft.provider === "supertonic") { + state.draft.voices = new Map(); + } + applyDraftToControls(); + markDirty(); + loadSampleText(); + setStatus("Provider updated.", "info", 1500); + }); + } + + if (supertonicVoiceSelect) { + supertonicVoiceSelect.addEventListener("change", () => { + state.draft.supertonic.voice = supertonicVoiceSelect.value; + markDirty(); + updateMixSummary(); + }); + } + + if (supertonicStepsInput) { + supertonicStepsInput.addEventListener("input", () => { + const value = Number(supertonicStepsInput.value || "5"); + state.draft.supertonic.total_steps = clamp(value, 2, 15); + supertonicStepsInput.value = String(Math.round(state.draft.supertonic.total_steps)); + if (supertonicStepsLabel) { + supertonicStepsLabel.textContent = supertonicStepsInput.value; + } + setRangeFill(supertonicStepsInput); + markDirty(); + }); + setRangeFill(supertonicStepsInput); + } + + if (supertonicSpeedInput) { + supertonicSpeedInput.addEventListener("input", () => { + const value = parseFloat(supertonicSpeedInput.value || "1"); + const normalized = clamp(value, 0.7, 2.0); + state.draft.supertonic.speed = normalized; + supertonicSpeedInput.value = normalized.toFixed(2); + if (supertonicSpeedLabel) { + supertonicSpeedLabel.textContent = `${normalized.toFixed(2)}×`; + } + setRangeFill(supertonicSpeedInput); + if (speedInput) { + speedInput.value = String(normalized); + setRangeFill(speedInput); + } + markDirty(); + }); + setRangeFill(supertonicSpeedInput); + } + + if (voiceFilterSelect) { + voiceFilterSelect.addEventListener("change", () => { + state.languageFilter = voiceFilterSelect.value; + renderAvailableVoices(); + }); + } + + if (speedInput) { + const updatePreviewSpeedLabel = () => { + const speed = parseFloat(speedInput.value || "1"); + if (previewSpeedLabel) { + previewSpeedLabel.textContent = `${speed.toFixed(2)}×`; + } + setRangeFill(speedInput); + + if (normalizeProvider(state.draft.provider) === "supertonic") { + state.draft.supertonic.speed = clamp(speed, 0.7, 2.0); + if (supertonicSpeedInput) { + supertonicSpeedInput.value = state.draft.supertonic.speed.toFixed(2); + setRangeFill(supertonicSpeedInput); + } + if (supertonicSpeedLabel) { + supertonicSpeedLabel.textContent = `${state.draft.supertonic.speed.toFixed(2)}×`; + } + } + }; + speedInput.addEventListener("input", updatePreviewSpeedLabel); + updatePreviewSpeedLabel(); + } + + if (genderFilterEl) { + genderFilterEl.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLButtonElement)) return; + const value = (target.getAttribute("data-value") || "").toLowerCase(); + if (!value) return; + state.genderFilter = state.genderFilter === value ? "" : value; + renderAvailableVoices(); + }); + updateGenderFilterButtons(); + } + + if (nameInput) { + nameInput.addEventListener("input", () => { + state.draft.name = nameInput.value; + markDirty(); + updateMixSummary(); + }); + } + + if (importInput) { + importInput.addEventListener("change", () => { + const [file] = importInput.files || []; + if (file) { + runImport(file); + } + }); + } + + if (headerActions) { + headerActions.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + const actionEl = target.closest("[data-action]"); + const action = actionEl instanceof HTMLElement ? actionEl.dataset.action : null; + if (!action) return; + if (action === "new-profile") { + requestNewProfile(); + } else if (action === "import-profiles") { + importInput?.click(); + } else if (action === "export-profiles") { + runExport(); + } + }); + } + + if (dropzoneEl) { + const setHover = (hovered) => { + dropzoneEl.classList.toggle("is-hovered", hovered); + }; + [dropzoneEl, selectedListEl].forEach((target) => { + target.addEventListener("dragover", (event) => { + event.preventDefault(); + setHover(true); + }); + target.addEventListener("dragenter", (event) => { + event.preventDefault(); + setHover(true); + }); + target.addEventListener("dragleave", (event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + setHover(false); + } + }); + target.addEventListener("drop", (event) => { + event.preventDefault(); + const voiceId = event.dataTransfer?.getData("text/plain"); + if (voiceId) { + addVoiceToDraft(voiceId); + } + setHover(false); + }); + }); + + dropzoneEl.addEventListener("click", (event) => { + if (!(event.target instanceof HTMLElement)) { + return; + } + if (event.target.closest(".mix-voice")) { + return; + } + if (event.target.closest(".mix-slider")) { + return; + } + const firstInactive = Array.from(availableCards.entries()).find( + ([voiceId]) => !state.draft.voices.has(voiceId), + ); + if (firstInactive) { + addVoiceToDraft(firstInactive[0]); + } + }); + } + + renderAvailableVoices(); + renderProfileList(); + startNewProfile("kokoro"); + + if (Object.keys(profiles).length) { + const first = Object.keys(profiles).sort((a, b) => a.localeCompare(b))[0]; + selectProfile(first); + } + + loadSampleText(); + updateActionButtons(); + app.dataset.state = "ready"; + + window.addEventListener("beforeunload", () => { + if (state.previewUrl) { + URL.revokeObjectURL(state.previewUrl); + } + }); +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", setupVoiceMixer, { once: true }); +} else { + setupVoiceMixer(); +} diff --git a/abogen/webui/static/wizard.js b/abogen/webui/static/wizard.js new file mode 100644 index 0000000..eae3a2f --- /dev/null +++ b/abogen/webui/static/wizard.js @@ -0,0 +1,514 @@ +const STEP_ORDER = ["book", "chapters", "entities"]; +const STEP_META = { + book: { + index: 1, + title: "Book parameters", + hint: "Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.", + }, + chapters: { + index: 2, + title: "Select chapters", + hint: "Choose which chapters to convert. We'll analyse entities automatically when you continue.", + }, + entities: { + index: 3, + title: "Review entities", + hint: "Assign pronunciations, voices, and manual overrides before queueing the conversion.", + }, +}; + +const wizardState = (window.AbogenWizardState = window.AbogenWizardState || { + initialized: false, + modal: null, + stage: null, + submitting: false, + initialStep: "book", + initialStageMarkup: "", +}); + +const normalizeStep = (step) => { + let value = (step || "book").toLowerCase(); + if (value === "speakers") { + value = "entities"; + } + if (value === "settings" || value === "upload" || value === "") { + value = "book"; + } + if (!STEP_ORDER.includes(value)) { + return "book"; + } + return value; +}; + +const setButtonLoading = (button, isLoading) => { + if (!button) { + return; + } + if (isLoading) { + if (!button.dataset.originalDisabled) { + button.dataset.originalDisabled = button.disabled ? "true" : "false"; + } + button.disabled = true; + button.dataset.loading = "true"; + button.setAttribute("aria-busy", "true"); + } else { + if (button.dataset.loading) { + delete button.dataset.loading; + } + button.removeAttribute("aria-busy"); + const original = button.dataset.originalDisabled; + if (original !== undefined) { + button.disabled = original === "true"; + delete button.dataset.originalDisabled; + } else { + button.disabled = false; + } + } +}; + +const setSubmitting = (modal, isSubmitting, button) => { + if (!modal) return; + wizardState.submitting = isSubmitting; + if (isSubmitting) { + modal.dataset.submitting = "true"; + modal.setAttribute("aria-busy", "true"); + } else { + delete modal.dataset.submitting; + modal.removeAttribute("aria-busy"); + } + setButtonLoading(button, isSubmitting); +}; + +const resetWizardToInitial = () => { + const modal = ensureModalRef(); + if (!modal) return; + wizardState.submitting = false; + delete modal.dataset.submitting; + modal.removeAttribute("aria-busy"); + modal.dataset.pendingId = ""; + const step = normalizeStep(wizardState.initialStep || modal.dataset.step || "book"); + modal.dataset.step = step; + updateHeaderCopy(modal, step); + updateFilenameLabel(modal, ""); + const stage = modal.querySelector('[data-role="wizard-stage"]'); + if (stage) { + wizardState.stage = stage; + destroyTransientAlerts(stage); + if (typeof wizardState.initialStageMarkup === "string" && wizardState.initialStageMarkup) { + stage.innerHTML = wizardState.initialStageMarkup; + reinitializeStageModules(stage); + } + } +}; + +const findModal = () => document.querySelector('[data-role="new-job-modal"]'); + +const ensureModalRef = () => { + if (wizardState.modal && wizardState.modal.isConnected) { + return wizardState.modal; + } + wizardState.modal = findModal(); + return wizardState.modal; +}; + +const dispatchWizardEvent = (modal, type, detail = {}) => { + if (!modal) return; + const event = new CustomEvent(`wizard:${type}`, { bubbles: true, detail }); + modal.dispatchEvent(event); +}; + +const destroyTransientAlerts = (stage) => { + if (!stage) { + return; + } + const alerts = stage.querySelectorAll('[data-role="wizard-error"]'); + alerts.forEach((alert) => alert.remove()); +}; + +const displayTransientError = (modal, message) => { + if (!modal) return; + const stage = modal.querySelector('[data-role="wizard-stage"]'); + if (!stage) return; + const existing = stage.querySelector('[data-role="wizard-error"]'); + if (existing) { + existing.textContent = message; + return; + } + const alert = document.createElement("div"); + alert.className = "alert alert--error"; + alert.dataset.role = "wizard-error"; + alert.textContent = message; + stage.prepend(alert); +}; + +const updateStepIndicators = (modal, activeStep, payload) => { + const indicators = modal.querySelectorAll('[data-role="wizard-step-indicator"]'); + const activeIndex = STEP_ORDER.indexOf(activeStep); + const completedList = Array.isArray(payload?.completed_steps) ? payload.completed_steps : []; + const completedSet = new Set(completedList.map((step) => normalizeStep(step))); + indicators.forEach((indicator) => { + const step = normalizeStep(indicator.dataset.step || "book"); + indicator.classList.remove("is-active", "is-complete"); + const index = STEP_ORDER.indexOf(step); + const isActive = index === activeIndex; + const visited = completedSet.has(step); + const isComplete = !isActive && (visited || (index > -1 && index < activeIndex)); + indicator.classList.toggle("is-complete", isComplete); + indicator.classList.toggle("is-active", isActive); + if (indicator instanceof HTMLButtonElement) { + const clickable = isComplete && !isActive; + indicator.disabled = !clickable; + indicator.setAttribute("aria-disabled", clickable ? "false" : "true"); + indicator.setAttribute("aria-current", isActive ? "step" : "false"); + if (clickable) { + indicator.dataset.state = "clickable"; + } else { + delete indicator.dataset.state; + } + } + }); +}; + +const updateHeaderCopy = (modal, step, payload) => { + const meta = STEP_META[step]; + if (!meta) { + return; + } + const titleEl = modal.querySelector("#new-job-modal-title"); + const hintEl = modal.querySelector('[data-role="wizard-hint"]'); + if (titleEl) { + titleEl.textContent = payload?.title || meta.title; + } + if (hintEl) { + hintEl.textContent = payload?.hint || meta.hint; + } + updateStepIndicators(modal, step, payload); +}; + +const updateFilenameLabel = (modal, filename) => { + const label = modal.querySelector(".wizard-card__filename"); + if (!label) return; + if (filename) { + label.hidden = false; + label.textContent = filename; + label.setAttribute("title", filename); + } else { + label.hidden = true; + label.textContent = ""; + label.removeAttribute("title"); + } +}; + +const reinitializeStageModules = (stage) => { + if (!stage) return; + if (window.AbogenDashboard?.init) { + window.AbogenDashboard.init(); + } + if (window.AbogenPrepare?.init) { + window.AbogenPrepare.init(stage); + } +}; + +const focusFirstInteractive = (stage) => { + if (!stage) return; + const focusable = stage.querySelector( + 'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled])' + ); + if (focusable instanceof HTMLElement) { + try { + focusable.focus({ preventScroll: true }); + } catch (error) { + // Ignore focus errors, browser may block programmatic focus + } + } +}; + +const applyWizardPayload = (payload) => { + const modal = ensureModalRef(); + if (!modal) { + return; + } + if (payload.pending_id !== undefined) { + modal.dataset.pendingId = payload.pending_id || ""; + } + const step = normalizeStep(payload.step || modal.dataset.step || "book"); + modal.dataset.step = step; + modal.hidden = false; + modal.dataset.open = "true"; + document.body.classList.add("modal-open"); + updateHeaderCopy(modal, step, payload); + updateFilenameLabel(modal, payload.filename); + + const stage = modal.querySelector('[data-role="wizard-stage"]'); + if (stage) { + destroyTransientAlerts(stage); + stage.innerHTML = payload.html || ""; + wizardState.stage = stage; + reinitializeStageModules(stage); + focusFirstInteractive(stage); + } + + const stepDetail = { + step, + index: STEP_META[step]?.index || STEP_ORDER.indexOf(step) + 1, + total: STEP_ORDER.length, + pendingId: modal.dataset.pendingId || "", + notice: payload.notice || "", + error: payload.error || "", + }; + dispatchWizardEvent(modal, "step", stepDetail); +}; + +const handleWizardRedirect = (payload) => { + const modal = ensureModalRef(); + if (!modal) return; + modal.hidden = true; + delete modal.dataset.open; + document.body.classList.remove("modal-open"); + resetWizardToInitial(); + dispatchWizardEvent(modal, "done", { redirectUrl: payload.redirect_url }); + if (payload.redirect_url) { + window.location.assign(payload.redirect_url); + } +}; + +const processResponsePayload = (payload, responseOk) => { + if (!payload) { + return; + } + if (payload.redirect_url) { + handleWizardRedirect(payload); + return; + } + if (!payload.html && !responseOk) { + const modal = ensureModalRef(); + displayTransientError(modal, payload.error || "Something went wrong. Try again."); + return; + } + applyWizardPayload(payload); +}; + +const requestWizardStep = async (url, { method = "GET", body = undefined } = {}) => { + const modal = ensureModalRef(); + if (!modal) return; + try { + const response = await fetch(url, { + method, + body, + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + const text = await response.text(); + const payload = text ? JSON.parse(text) : null; + processResponsePayload(payload, response.ok); + } catch (error) { + console.error("Wizard request failed", error); + displayTransientError(modal, error?.message || "Unable to update the wizard. Try again."); + } +}; + +const submitWizardForm = async (form, submitter) => { + const modal = ensureModalRef(); + if (!modal) return; + if (wizardState.submitting) { + return; + } + const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || window.location.href; + const method = (submitter?.getAttribute("formmethod") || form.getAttribute("method") || "GET").toUpperCase(); + const stepTarget = submitter?.dataset?.stepTarget || ""; + const normalizedStepTarget = stepTarget ? stepTarget.toLowerCase() : ""; + if (normalizedStepTarget) { + const activeInput = form.querySelector('[data-role="active-step-input"]'); + if (activeInput) { + activeInput.value = normalizedStepTarget; + } + } + const formData = new FormData(form); + if (normalizedStepTarget) { + formData.set("active_step", normalizedStepTarget); + formData.set("next_step", normalizedStepTarget); + } + if (submitter && submitter.name && !formData.has(submitter.name)) { + formData.append(submitter.name, submitter.value ?? ""); + } + + // Ensure pending_id is included if available in modal state but missing from form + if (!formData.get("pending_id") && modal && modal.dataset.pendingId) { + formData.set("pending_id", modal.dataset.pendingId); + } + + const allowValidation = !submitter?.hasAttribute("formnovalidate") && !form.noValidate; + if (allowValidation && typeof form.reportValidity === "function" && !form.reportValidity()) { + return; + } + + destroyTransientAlerts(modal.querySelector('[data-role="wizard-stage"]')); + setSubmitting(modal, true, submitter); + try { + const response = await fetch(action, { + method, + body: formData, + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch (parseError) { + console.error("Failed to parse wizard response", parseError); + displayTransientError(modal, "Received an invalid response. Try again."); + return; + } + processResponsePayload(payload, response.ok); + if (!response.ok && (!payload || !payload.html)) { + displayTransientError(modal, payload?.error || `Request failed (${response.status})`); + } + } catch (networkError) { + console.error("Wizard submission failed", networkError); + displayTransientError(modal, networkError?.message || "Unable to submit form. Check your connection and try again."); + } finally { + setSubmitting(modal, false, submitter); + } +}; + +const handleCancel = async (button) => { + const modal = ensureModalRef(); + if (!modal) return; + const pendingId = button?.dataset.pendingId || modal.dataset.pendingId; + const template = modal.dataset.cancelUrlTemplate || ""; + if (!pendingId || !template) { + modal.hidden = true; + delete modal.dataset.open; + document.body.classList.remove("modal-open"); + dispatchWizardEvent(modal, "cancel", { pendingId }); + return; + } + const url = template.replace("__pending__", encodeURIComponent(pendingId)); + try { + const response = await fetch(url, { + method: "POST", + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + if (response.ok) { + const text = await response.text(); + const payload = text ? JSON.parse(text) : null; + if (payload?.redirect_url) { + handleWizardRedirect(payload); + return; + } + } + } catch (error) { + console.error("Cancel request failed", error); + } + modal.hidden = true; + delete modal.dataset.open; + document.body.classList.remove("modal-open"); + dispatchWizardEvent(modal, "cancel", { pendingId }); + resetWizardToInitial(); +}; + +const navigateToWizardStep = (targetStep, pendingOverride) => { + const modal = ensureModalRef(); + if (!modal || wizardState.submitting) { + return; + } + const normalizedTarget = normalizeStep(targetStep || "book"); + const currentStep = modal.dataset.step ? normalizeStep(modal.dataset.step) : "book"; + if (normalizedTarget === currentStep) { + return; + } + const pendingId = pendingOverride || modal.dataset.pendingId || ""; + const template = modal.dataset.prepareUrlTemplate || ""; + if (!pendingId || !template) { + return; + } + const url = new URL(template.replace("__pending__", encodeURIComponent(pendingId)), window.location.origin); + url.searchParams.set("step", normalizedTarget); + url.searchParams.set("format", "json"); + requestWizardStep(url.toString(), { method: "GET" }); +}; + +const handleBackToStep = (button) => { + const targetStep = normalizeStep(button.dataset.targetStep || "book"); + navigateToWizardStep(targetStep, button.dataset.pendingId); +}; + +const handleWizardClick = (event) => { + const modal = ensureModalRef(); + if (!modal) return; + const closeTarget = event.target.closest('[data-role="new-job-modal-close"]'); + if (closeTarget) { + event.preventDefault(); + event.stopPropagation(); + handleCancel(closeTarget); + return; + } + const cancelButton = event.target.closest('[data-role="wizard-cancel"]'); + if (cancelButton) { + event.preventDefault(); + event.stopPropagation(); + handleCancel(cancelButton); + return; + } + const backButton = event.target.closest('[data-role="wizard-back"]'); + if (backButton) { + const targetStep = normalizeStep(backButton.dataset.targetStep || "book"); + event.preventDefault(); + event.stopPropagation(); + handleBackToStep(backButton); + return; + } + const indicator = event.target.closest('[data-role="wizard-step-indicator"]'); + if (indicator instanceof HTMLButtonElement) { + if (indicator.classList.contains("is-complete")) { + event.preventDefault(); + event.stopPropagation(); + navigateToWizardStep(indicator.dataset.step || "book"); + } + } +}; + +const handleWizardSubmit = (event) => { + const form = event.target; + if (!(form instanceof HTMLFormElement)) { + return; + } + if (form.dataset.wizardForm !== "true") { + return; + } + const submitter = event.submitter || form.querySelector('button[type="submit"]'); + if (!submitter) { + return; + } + event.preventDefault(); + submitWizardForm(form, submitter); +}; + +const initWizard = () => { + if (wizardState.initialized) { + return; + } + const modal = ensureModalRef(); + if (!modal) { + return; + } + wizardState.initialized = true; + wizardState.modal = modal; + wizardState.stage = modal.querySelector('[data-role="wizard-stage"]'); + const initialStep = normalizeStep(modal.dataset.step || "book"); + if (!wizardState.initialStageMarkup && wizardState.stage) { + wizardState.initialStageMarkup = wizardState.stage.innerHTML; + wizardState.initialStep = initialStep; + } + modal.addEventListener("submit", handleWizardSubmit, true); + modal.addEventListener("click", handleWizardClick); +}; + +window.AbogenWizard = window.AbogenWizard || {}; +window.AbogenWizard.init = initWizard; +window.AbogenWizard.requestStep = requestWizardStep; +window.AbogenWizard.applyPayload = applyWizardPayload; + +export { initWizard }; diff --git a/abogen/webui/templates/base.html b/abogen/webui/templates/base.html new file mode 100644 index 0000000..194f394 --- /dev/null +++ b/abogen/webui/templates/base.html @@ -0,0 +1,37 @@ + + + + + + {% block title %}abogen{% endblock %} + + + + + +
      +
      + 🔊 + abogen + Verba audiuntur +
      + +
      +
      + {% block content %}{% endblock %} +
      + + {% block scripts %}{% endblock %} + + diff --git a/abogen/webui/templates/debug_wavs.html b/abogen/webui/templates/debug_wavs.html new file mode 100644 index 0000000..2b2e451 --- /dev/null +++ b/abogen/webui/templates/debug_wavs.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block title %}abogen · Debug WAVs{% endblock %} + +{% block content %} +
      +

      Debug WAVs

      +

      Run ID: {{ run_id }}

      + +

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

      + + {% if artifacts %} +
      + +
        + {% for item in artifacts %} +
      • +
        +
        + {{ item.label }} + {% if item.text %} + — {{ item.text }} + {% endif %} +
        + +
        +
      • + {% endfor %} +
      +
      + {% endif %} + + +
      +{% endblock %} diff --git a/abogen/webui/templates/entities.html b/abogen/webui/templates/entities.html new file mode 100644 index 0000000..ea4f3a9 --- /dev/null +++ b/abogen/webui/templates/entities.html @@ -0,0 +1,275 @@ +{% extends "base.html" %} + +{% block title %}abogen · TTS Overrides{% endblock %} + +{% block content %} +
      +

      TTS Overrides & Pronunciation

      +

      Review and refine stored pronunciations so recurring names sound right in every project.

      +
      +
      + + + + +
      +
      + +
      + + Reset +
      +
      +
      +

      Looking for saved speaker rosters instead? Manage speaker presets.

      +
      + +
      +
      +

      Overrides for {{ language_label }}

      +

      + Showing {{ stats.filtered }} of {{ stats.total }} stored overrides · + {{ stats.with_pronunciation }} with pronunciations · {{ stats.with_voice }} with assigned voices +

      +
      + {% if status_message or status_error %} +
      + {{ status_error or status_message }} +
      + {% endif %} +
      + + +
      +
      +

      Add manual override

      +

      Create pronunciations or assign default voices without preparing a job.

      + + + + + + +
      + + + +
      +
      + + +
      +
      +
      + +
      +
      + {% if overrides %} +
      + + + + + + + + + + + + + + {% for override in overrides %} + {% set form_id = "override-form-" ~ loop.index %} + + + + + + + + + + {% endfor %} + +
      TokenPronunciationVoiceUsageLast updatedPreviewActions
      {{ override.token }} + + + {% set current_voice = override.voice or '' %} + {% set known_voice = namespace(value=False) %} + {% if current_voice %} + {% if current_voice in options.voice_catalog_map %} + {% set known_voice.value = True %} + {% else %} + {% for profile in options.voice_profile_options %} + {% if current_voice == 'profile:' ~ profile.name %} + {% set known_voice.value = True %} + {% endif %} + {% endfor %} + {% endif %} + {% endif %} + + {{ override.usage_count }}{{ override.updated_at_label }} +
      +
      + + +
      + +
      +
      +
      + + + + + + +
      + + +
      +
      +
      +
      + + {% else %} +

      No overrides matched your filters. Try adjusting the search or create overrides from the Entities step while preparing a job.

      + {% endif %} +
      +{% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %} diff --git a/abogen/webui/templates/find_books.html b/abogen/webui/templates/find_books.html new file mode 100644 index 0000000..1e79651 --- /dev/null +++ b/abogen/webui/templates/find_books.html @@ -0,0 +1,100 @@ +{% extends "base.html" %} + +{% block title %}abogen · Find Books{% endblock %} + +{% block content %} +
      +
      Find Books
      +

      Browse trusted public-domain libraries or drill into your Calibre catalog without leaving abogen.

      +
      +
      +
      +

      Standard Ebooks

      +

      Discover meticulously produced public-domain ebooks with consistent typography, metadata, and clean EPUB sources.

      +

      + + Visit standardebooks.org + +

      +

      Opens in a new tab so you can grab EPUB downloads and drag them into abogen.

      +
      + +
      +

      Project Gutenberg

      +

      The world’s largest public-domain ebook library, offering thousands of EPUB and plain-text editions sourced from volunteer transcriptions.

      +

      + + Visit gutenberg.org + +

      +

      Download the EPUB version for best results, then import it into abogen.

      +
      +
      + +
      +

      Calibre catalog

      +

      Wire abogen to your Calibre OPDS server to browse, search, and import titles without leaving the app.

      + {% if not opds_available %} +
      Enable the Calibre OPDS integration in settings to browse your library here.
      + {% else %} + + {% endif %} +
      +
      +
      + +{% if opds_available %} + +{% endif %} +{% with pending=None, readonly=False, active_step='settings' %} + {% include "partials/upload_modal.html" %} +{% endwith %} +{% include "partials/reader_modal.html" %} +{% endblock %} + +{% block scripts %} + {{ super() }} + + + + + {% if opds_available %} + + {% endif %} +{% endblock %} diff --git a/abogen/webui/templates/index.html b/abogen/webui/templates/index.html new file mode 100644 index 0000000..b32c5ba --- /dev/null +++ b/abogen/webui/templates/index.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} + +{% block title %}abogen · Dashboard{% endblock %} + +{% block content %} + + +
      +
      + {{ stats.total }} + Total Jobs +
      +
      + {{ stats.completed }} + Converted +
      +
      + {{ stats.running }} + Converting +
      +
      + {{ stats.pending }} + Pending +
      +
      + {{ stats.failed }} + Failed +
      +
      + +
      +

      Create a New Audiobook

      +

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

      +
      +
      + +

      Drop your manuscript to begin

      +

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

      + + + {% if opds_available %} +
      +

      Upload an EPUB or text file to begin.

      + Browse Calibre library +
      + {% endif %} +
      +
      +
      + +{% with pending=None, readonly=False, active_step='settings' %} + {% include "partials/upload_modal.html" %} +{% endwith %} + +{% include "partials/reader_modal.html" %} + +{% endblock %} + +{% block scripts %} + {{ super() }} + + + + +{% endblock %} diff --git a/abogen/webui/templates/job_detail.html b/abogen/webui/templates/job_detail.html new file mode 100644 index 0000000..6c45261 --- /dev/null +++ b/abogen/webui/templates/job_detail.html @@ -0,0 +1,158 @@ +{% extends "base.html" %} + +{% block title %}Job · {{ job.original_filename }}{% endblock %} + +{% block content %} +
      +
      {{ job.original_filename }}
      +
      +
      +

      Settings

      +
        +
      • Voice: {{ job.voice }}
      • +
      • Language: {{ options.languages.get(job.language, job.language) }}
      • + {% if job.voice_profile %} +
      • Voice profile: {{ job.voice_profile }}
      • + {% endif %} +
      • Speed: {{ '%.2f' | format(job.speed) }}×
      • +
      • Subtitle mode: {{ job.subtitle_mode }}
      • +
      • Audio format: {{ job.output_format }}
      • +
      • Subtitle format: {{ job.subtitle_format }}
      • +
      • GPU: {{ 'Yes' if job.use_gpu else 'No' }}
      • +
      • Save chapters separately: {{ 'Yes' if job.save_chapters_separately else 'No' }}
      • +
      • Merge at end: {{ 'Yes' if job.merge_chapters_at_end else 'No' }}
      • +
      • Separate chapter format: {{ job.separate_chapters_format|upper }}
      • +
      • Silence between chapters: {{ '%.1f'|format(job.silence_between_chapters) }}s
      • +
      • Chapter intro delay: {{ '%.1f'|format(job.chapter_intro_delay) }}s
      • +
      • Title intro: {{ 'Yes' if job.read_title_intro else 'No' }}
      • +
      • Closing outro: {{ 'Yes' if job.read_closing_outro else 'No' }}
      • +
      • Normalize chapter openings: {{ 'Yes' if job.normalize_chapter_opening_caps else 'No' }}
      • +
      • Prefix chapter titles: {{ 'Yes' if job.auto_prefix_chapter_titles else 'No' }}
      • +
      • Max words per subtitle: {{ job.max_subtitle_words }}
      • +
      • Project folder: {{ 'Yes' if job.save_as_project else 'No' }}
      • +
      • Chunk granularity: {{ job.chunk_level|replace('_', ' ')|title }}
      • +
      • Speaker analysis threshold: {{ job.speaker_analysis_threshold }}
      • +
      • Generate EPUB 3: {{ 'Yes' if job.generate_epub3 else 'No' }}
      • +
      +
      +
      +

      Status

      +

      {{ job.status.value|title }}

      +

      Created: {{ job.created_at|datetimeformat }}

      +

      Started: {{ job.started_at|datetimeformat }}

      +

      Finished: {{ job.finished_at|datetimeformat }}

      +

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

      + {% set flags = downloads or {} %} + {% if flags.get('m4b') %} +

      Download M4B

      + {% elif flags.get('audio') %} +

      Download audio

      + {% endif %} + {% if flags.get('epub3') %} +

      Download EPUB 3

      + {% endif %} + {% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %} +
      + +
      + {% elif job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED] %} +
      + +
      + {% endif %} +
      +
      +
      + +{% set analysis = job.speaker_analysis or {} %} +{% if analysis %} +{% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} +
      +
      Speaker analysis
      +
      +
      +

      Summary

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

      Detected speakers

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

      No additional speakers detected.

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

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

      + {% endif %} +
      +
      +
      +{% endif %} + +{% include "partials/logs_section.html" %} +{% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %} diff --git a/abogen/webui/templates/job_logs_missing.html b/abogen/webui/templates/job_logs_missing.html new file mode 100644 index 0000000..ad21254 --- /dev/null +++ b/abogen/webui/templates/job_logs_missing.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Job Not Found{% endblock %} + +{% block content %} +
      +
      +
      Job Not Found
      +
      +

      This job no longer exists. It may have been deleted or the server was restarted.

      +

      Return to Dashboard

      +
      +{% endblock %} diff --git a/abogen/webui/templates/job_logs_static.html b/abogen/webui/templates/job_logs_static.html new file mode 100644 index 0000000..a4a500c --- /dev/null +++ b/abogen/webui/templates/job_logs_static.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Logs · {{ job.original_filename }}{% endblock %} + +{% block content %} +
      + {% include "partials/logs.html" %} + {% if job.logs %} +
      + Copy raw log output + +
      + {% endif %} +
      +{% endblock %} diff --git a/abogen/webui/templates/job_not_found.html b/abogen/webui/templates/job_not_found.html new file mode 100644 index 0000000..2c4afb6 --- /dev/null +++ b/abogen/webui/templates/job_not_found.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Job Not Found{% endblock %} + +{% block content %} +
      +
      +
      Job Not Found
      +
      +

      This job no longer exists. It may have been deleted or the server was restarted.

      +

      Return to Dashboard

      +
      +{% endblock %} diff --git a/abogen/webui/templates/partials/jobs.html b/abogen/webui/templates/partials/jobs.html new file mode 100644 index 0000000..a24a77c --- /dev/null +++ b/abogen/webui/templates/partials/jobs.html @@ -0,0 +1,134 @@ +
      Queue
      + +
      +
      +

      Active jobs

      +
      + {% if active_jobs %} +
        + {% for job in active_jobs %} + {% set progress_value = ((job.progress or 0) * 100)|round(1) %} +
      • +
        +
        + {{ job.original_filename }} +
        + {% if job.queue_position %}Position #{{ job.queue_position }} · {% endif %} + {% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }} +
        + {% if job.pause_requested and job.status == JobStatus.RUNNING %} +
        Pause requested — waiting for a safe point…
        + {% endif %} +
        + {{ job.status.value|title }} +
        +
        +
        +
        +
        +
        + {{ progress_value }}% · {{ job.processed_characters }} / {{ job.total_characters or '—' }} + {% if job.estimated_time_remaining %} + ~{{ job.estimated_time_remaining | durationformat }} remaining + {% endif %} +
        +
        + +
      • + {% endfor %} +
      + {% else %} +

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

      + {% endif %} +
      + +
      +
      +

      Recent results

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

      Completed jobs will appear here.

      + {% endif %} +
      diff --git a/abogen/webui/templates/partials/logs.html b/abogen/webui/templates/partials/logs.html new file mode 100644 index 0000000..cd8f983 --- /dev/null +++ b/abogen/webui/templates/partials/logs.html @@ -0,0 +1,21 @@ +{% set is_static = static_view if static_view is defined and static_view else False %} +
      +
      Live log
      + {% if not is_static %} + Open static view + {% else %} + Back to job + {% endif %} +
      +{% if job.logs %} +
        + {% for entry in job.logs|reverse %} +
      • + {{ entry.timestamp|datetimeformat('%H:%M:%S') }} +
        {{ entry.message }}
        +
      • + {% endfor %} +
      +{% else %} +

      No log entries yet. Check back soon!

      +{% endif %} diff --git a/abogen/webui/templates/partials/logs_section.html b/abogen/webui/templates/partials/logs_section.html new file mode 100644 index 0000000..fe68e90 --- /dev/null +++ b/abogen/webui/templates/partials/logs_section.html @@ -0,0 +1,7 @@ +
      + {% include "partials/logs.html" %} +
      diff --git a/abogen/webui/templates/partials/logs_section_missing.html b/abogen/webui/templates/partials/logs_section_missing.html new file mode 100644 index 0000000..612014c --- /dev/null +++ b/abogen/webui/templates/partials/logs_section_missing.html @@ -0,0 +1,6 @@ +
      +
      +
      Live log
      +
      +

      Job not found (it may have completed, been removed, or the server restarted). Refresh the page to load an active job.

      +
      diff --git a/abogen/webui/templates/partials/new_job_step_book.html b/abogen/webui/templates/partials/new_job_step_book.html new file mode 100644 index 0000000..5bf14b4 --- /dev/null +++ b/abogen/webui/templates/partials/new_job_step_book.html @@ -0,0 +1,425 @@ +{% set pending = pending if pending is defined else None %} +{% set metadata = pending.metadata_tags if pending else {} %} +{% set readonly = readonly if readonly is defined else False %} +{% set settings_dict = settings if settings is defined else {} %} +{% set options = options if options is defined else {} %} +{% set form_values = form_values if form_values is defined and form_values else {} %} +{% set language_value = form_values.get('language') if form_values else None %} +{% if not language_value %} + {% if pending and pending.language %} + {% set language_value = pending.language %} + {% else %} + {% set language_value = settings_dict.get('language', '') %} + {% endif %} +{% endif %} +{% if not language_value and options.languages %} + {% set sorted_languages = options.languages|dictsort %} + {% if sorted_languages %} + {% set language_value = sorted_languages[0][0] %} + {% endif %} +{% endif %} +{% set subtitle_value = form_values.get('subtitle_mode') if form_values else None %} +{% if not subtitle_value %} + {% if pending and pending.subtitle_mode %} + {% set subtitle_value = pending.subtitle_mode %} + {% else %} + {% set subtitle_value = settings_dict.get('subtitle_mode', 'Disabled') %} + {% endif %} +{% endif %} +{% set generate_flag = form_values.get('generate_epub3') if form_values else None %} +{% if generate_flag is not none %} + {% set generate_epub3 = True %} +{% else %} + {% set generate_epub3 = pending.generate_epub3 if pending else settings_dict.get('generate_epub3', False) %} +{% endif %} +{% set chunk_level_value = form_values.get('chunk_level') if form_values else None %} +{% if not chunk_level_value %} + {% if pending and pending.chunk_level %} + {% set chunk_level_value = pending.chunk_level %} + {% else %} + {% set chunk_level_value = settings_dict.get('chunk_level', 'paragraph') %} + {% endif %} +{% endif %} +{% set analysis_threshold_value = form_values.get('speaker_analysis_threshold') if form_values else None %} +{% if not analysis_threshold_value %} + {% if pending and pending.speaker_analysis_threshold %} + {% set analysis_threshold_value = pending.speaker_analysis_threshold %} + {% else %} + {% set analysis_threshold_value = settings_dict.get('speaker_analysis_threshold', 3) %} + {% endif %} +{% endif %} +{% set chapter_delay_value = form_values.get('chapter_intro_delay') if form_values else None %} +{% if not chapter_delay_value %} + {% if pending and pending.chapter_intro_delay is not none %} + {% set chapter_delay_value = pending.chapter_intro_delay %} + {% else %} + {% set chapter_delay_value = settings_dict.get('chapter_intro_delay', 0.5) %} + {% endif %} +{% endif %} +{% set read_intro_value = form_values.get('read_title_intro') if form_values else None %} +{% if read_intro_value is not none %} + {% set read_title_intro = ((read_intro_value|string)|lower) in ['true', '1', 'yes', 'on'] %} +{% else %} + {% if pending is not none %} + {% set read_title_intro = pending.read_title_intro %} + {% else %} + {% set read_title_intro = settings_dict.get('read_title_intro', False) %} + {% endif %} +{% endif %} +{% set read_outro_value = form_values.get('read_closing_outro') if form_values else None %} +{% if read_outro_value is not none %} + {% set read_closing_outro = ((read_outro_value|string)|lower) in ['true', '1', 'yes', 'on'] %} +{% else %} + {% if pending is not none %} + {% set read_closing_outro = pending.read_closing_outro %} + {% else %} + {% set read_closing_outro = settings_dict.get('read_closing_outro', True) %} + {% endif %} +{% endif %} +{% set normalize_caps_value = form_values.get('normalize_chapter_opening_caps') if form_values else None %} +{% if normalize_caps_value is not none %} + {% set normalize_chapter_opening_caps = ((normalize_caps_value|string)|lower) in ['true', '1', 'yes', 'on'] %} +{% else %} + {% if pending is not none %} + {% set normalize_chapter_opening_caps = pending.normalize_chapter_opening_caps %} + {% else %} + {% set normalize_chapter_opening_caps = settings_dict.get('normalize_chapter_opening_caps', True) %} + {% endif %} +{% endif %} +{% set selected_config = form_values.get('speaker_config') if form_values else None %} +{% if selected_config is none %} + {% if pending and pending.applied_speaker_config %} + {% set selected_config = pending.applied_speaker_config %} + {% else %} + {% set selected_config = '' %} + {% endif %} +{% endif %} +{% set narrator_speed_value = form_values.get('speed') if form_values else None %} +{% if narrator_speed_value is none %} + {% if pending and pending.speed %} + {% set narrator_speed_value = pending.speed %} + {% else %} + {% set narrator_speed_value = settings_dict.get('default_speed', 1.0) %} + {% endif %} +{% endif %} +{% set narrator_speed = narrator_speed_value|float if narrator_speed_value is not none else 1.0 %} +{% set speed_display = '%.2f'|format(narrator_speed if narrator_speed else 1.0) %} +{% set form_profile = form_values.get('voice_profile') if form_values else None %} +{% set form_voice = form_values.get('voice') if form_values else None %} +{% set form_formula = form_values.get('voice_formula') if form_values else None %} +{% set narrator_profile = None %} +{% if form_profile is not none and form_profile != '' %} + {% set narrator_profile = form_profile %} +{% elif pending and pending.voice_profile %} + {% set narrator_profile = pending.voice_profile %} +{% else %} + {% set narrator_profile = '' %} +{% endif %} +{% set narrator_voice = None %} +{% if form_voice %} + {% set narrator_voice = form_voice %} +{% elif pending and pending.voice %} + {% set narrator_voice = pending.voice %} +{% else %} + {% set narrator_voice = settings_dict.get('default_voice', options.voices[0] if options.voices else '') %} +{% endif %} +{% if (not narrator_profile) and narrator_voice and narrator_voice[:8]|lower == 'profile:' %} + {% set narrator_profile = narrator_voice[8:]|trim %} + {% set narrator_voice = '' %} +{% endif %} +{% set normalization_overrides = pending.normalization_overrides if pending and pending.normalization_overrides else {} %} +{% set voice_formula_value = '' %} +{% set profile_value = narrator_profile if narrator_profile else '__standard' %} +{% if profile_value == '__formula' %} + {% if form_formula %} + {% set voice_formula_value = form_formula %} + {% elif pending and pending.voice %} + {% set voice_formula_value = pending.voice %} + {% endif %} +{% elif profile_value not in ['__standard', '', None] %} + {% set voice_formula_value = '' %} +{% else %} + {% if form_formula %} + {% set profile_value = '__formula' %} + {% set voice_formula_value = form_formula %} + {% elif narrator_voice and ('+' in narrator_voice or '*' in narrator_voice) %} + {% set profile_value = '__formula' %} + {% set voice_formula_value = narrator_voice %} + {% else %} + {% set profile_value = '__standard' %} + {% set voice_formula_value = '' %} + {% endif %} +{% endif %} +{% if profile_value == '__formula' and not voice_formula_value %} + {% if pending and pending.voice %} + {% set voice_formula_value = pending.voice %} + {% endif %} +{% endif %} +{% if profile_value != '__standard' and profile_value != '__formula' %} + {% set narrator_voice = '' %} +{% endif %} +{% if not narrator_voice and options.voices %} + {% set narrator_voice = options.voices[0] %} +{% endif %} + +{% if error %} +
      {{ error }}
      +{% endif %} +{% if notice %} +
      {{ notice }}
      +{% endif %} +
      + + +
      + + +
      +
      diff --git a/abogen/webui/templates/partials/new_job_step_chapters.html b/abogen/webui/templates/partials/new_job_step_chapters.html new file mode 100644 index 0000000..ad3f2aa --- /dev/null +++ b/abogen/webui/templates/partials/new_job_step_chapters.html @@ -0,0 +1,126 @@ +
      + + + + + +
      + + +
      +
      diff --git a/abogen/webui/templates/partials/new_job_step_entities.html b/abogen/webui/templates/partials/new_job_step_entities.html new file mode 100644 index 0000000..1177401 --- /dev/null +++ b/abogen/webui/templates/partials/new_job_step_entities.html @@ -0,0 +1,610 @@ +{% set embed_scripts = embed_scripts if embed_scripts is defined else True %} +{% set recognition_enabled = settings.enable_entity_recognition if settings is defined and settings.enable_entity_recognition is not none else True %} +
      + + + + + + +
      + + +
      +
      + + + + + + + + + + +{% if embed_scripts %} + + + +{% endif %} diff --git a/abogen/webui/templates/partials/reader_modal.html b/abogen/webui/templates/partials/reader_modal.html new file mode 100644 index 0000000..9421bd9 --- /dev/null +++ b/abogen/webui/templates/partials/reader_modal.html @@ -0,0 +1,19 @@ + diff --git a/abogen/webui/templates/partials/upload_modal.html b/abogen/webui/templates/partials/upload_modal.html new file mode 100644 index 0000000..e4f05d1 --- /dev/null +++ b/abogen/webui/templates/partials/upload_modal.html @@ -0,0 +1,76 @@ +{% set pending = pending if pending is defined else None %} +{% set readonly = readonly if readonly is defined else False %} +{% set settings_dict = settings if settings is defined else {} %} +{% set provided_step = step if step is defined else (active_step if active_step is defined else 'book') %} +{% set current_step = provided_step %} +{% if current_step in ['settings', 'upload', ''] %}{% set current_step = 'book' %}{% endif %} +{% if current_step not in ['book', 'chapters', 'entities'] %}{% set current_step = 'book' %}{% endif %} +{% set step_number = {'book': 1, 'chapters': 2, 'entities': 3} %} +{% set step_titles = { + 'book': 'Book parameters', + 'chapters': 'Select chapters', + 'entities': 'Review entities' +} %} +{% set step_hints = { + 'book': 'Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.', + 'chapters': "Choose which chapters to convert. We'll analyse entities automatically when you continue.", + 'entities': 'Assign pronunciations, voices, and manual overrides before queueing the conversion.' +} %} +{% set navigation_labels = { + 'book': 'Book parameters', + 'chapters': 'Chapters', + 'entities': 'Entities' +} %} +{% set total_steps = 3 %} +{% set current_index = step_number[current_step] %} +{% set is_open = open if open is defined else False %} +{% set prepare_url_template = url_for('main.wizard_start', pending_id='__pending__') %} +{% set cancel_url_template = url_for('main.wizard_cancel', pending_id='__pending__') %} +{% set analyze_url_template = url_for('main.wizard_update', pending_id='__pending__') %} + diff --git a/abogen/webui/templates/prepare_chapters.html b/abogen/webui/templates/prepare_chapters.html new file mode 100644 index 0000000..fc11972 --- /dev/null +++ b/abogen/webui/templates/prepare_chapters.html @@ -0,0 +1,190 @@ +{% extends "base.html" %} + +{% block title %}Prepare · {{ pending.original_filename }}{% endblock %} + +{% set is_multi_speaker = pending.speaker_mode == 'multi' %} +{% set total_steps = 3 if is_multi_speaker else 2 %} + +{% block content %} +
      + +
      +{% with pending=pending, readonly=True, active_step='chapters' %} + {% include "partials/upload_modal.html" %} +{% endwith %} +{% endblock %} + +{% block scripts %} + {{ super() }} + + + + + + +{% endblock %} diff --git a/abogen/webui/templates/prepare_job.html b/abogen/webui/templates/prepare_job.html new file mode 100644 index 0000000..a349ad4 --- /dev/null +++ b/abogen/webui/templates/prepare_job.html @@ -0,0 +1,3 @@ +{# This template is intentionally left empty. + Step-specific templates now live in `prepare_chapters.html` and `prepare_entities.html`. + The file is kept as a placeholder to avoid breaking documentation references. #} diff --git a/abogen/webui/templates/prepare_speakers.html b/abogen/webui/templates/prepare_speakers.html new file mode 100644 index 0000000..bd6988f --- /dev/null +++ b/abogen/webui/templates/prepare_speakers.html @@ -0,0 +1,172 @@ + data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}" + data-speed="{{ '%.2f'|format(pending.speed) }}" + data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}" + {% if not speaker.voice_formula %}hidden{% endif %}> + Preview generated + +
    +
    + Custom mix + {{ speaker.voice_formula or '' }} +
    + + +
    +
    + + +
    + Sample paragraphs + {% if sample_quotes %} + {% set first_sample = sample_quotes[0] if sample_quotes|length > 0 else None %} + {% set first_excerpt = first_sample.excerpt if first_sample is mapping else first_sample %} + {% set first_hint = first_sample.gender_hint if first_sample is mapping else '' %} +
    +

    {{ first_excerpt }}

    +

    {{ first_hint }}

    +
    + + + {% if sample_quotes|length > 1 %} + + {% endif %} +
    +
    + {% else %} +

    No paragraphs captured yet. Continue from Step 2 to gather dialogue samples automatically.

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

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

    + {% endif %} + + +
    + + +
    + +
    + + + + +{% with pending=pending, readonly=True, active_step='speakers' %} + {% include "partials/upload_modal.html" %} +{% endwith %} +{% endblock %} + +{% block scripts %} + {{ super() }} + + + + + + +{% endblock %} diff --git a/abogen/webui/templates/queue.html b/abogen/webui/templates/queue.html new file mode 100644 index 0000000..0d358da --- /dev/null +++ b/abogen/webui/templates/queue.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}abogen · Queue{% endblock %} + +{% block content %} +
    +
    + {{ jobs_panel|safe }} +
    +
    +{% include "partials/reader_modal.html" %} +{% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %} diff --git a/abogen/webui/templates/reader_embed.html b/abogen/webui/templates/reader_embed.html new file mode 100644 index 0000000..2bee49c --- /dev/null +++ b/abogen/webui/templates/reader_embed.html @@ -0,0 +1,1955 @@ + + + + + + {{ display_title or job.original_filename }} · Reader + + + +
    +
    +
    + + +
    +
    {{ display_title or job.original_filename }}
    +
    + + +
    +
    +
    +
    +
    + +
    + + + + + + + diff --git a/abogen/webui/templates/settings.html b/abogen/webui/templates/settings.html new file mode 100644 index 0000000..7981834 --- /dev/null +++ b/abogen/webui/templates/settings.html @@ -0,0 +1,646 @@ +{% extends "base.html" %} + +{% block title %}abogen · Settings{% endblock %} + +{% block content %} +
    +

    Application Settings

    +

    Settings apply to new jobs you queue from the dashboard.

    + + {% if saved %} +
    Settings saved successfully.
    + {% endif %} + + {% with messages = get_flashed_messages(with_categories=True) %} + {% if messages %} + {% for category, message in messages %} +
    {{ message }}
    + {% endfor %} + {% endif %} + {% endwith %} + +
    + +
    +
    +
    +
    + Narration Defaults +
    + + +

    Pick a saved speaker from Speaker Studio to use by default for new jobs.

    +
    + +
    +

    Kokoro settings

    +
    +
    + + +

    Used when no default speaker is selected, and as a fallback when speaker analysis cannot resolve a speaker.

    +
    + +
    +

    Supertonic settings

    +

    These defaults apply when a Supertonic speaker does not override them.

    +
    +
    + + +

    2 = fastest/lowest quality, 15 = slowest/highest quality.

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

    Speakers detected fewer times fall back to the narrator voice.

    +
    +
    + + +

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

    +
    +
    + +

    Disable if you prefer to skip entity extraction in the job wizard.

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

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

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

    Default output: {{ default_output_dir }}

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

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

    +
    +
    + +

    When enabled, the narrator speaks the title, optional subtitle, and author names before chapter one.

    +
    +
    + +

    Adds a brief "The end" line after the final chapter, optionally including series information.

    +
    +
    + +

    Converts screaming uppercase openings to sentence case while preserving acronyms.

    +
    +
    + +

    Ensures the spoken chapter heading starts with "Chapter" when source titles begin with only a number or numeral.

    +
    +
    +
    + +
    +
    + Subtitles & Text +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + +
    +
    + Performance +
    + +
    +
    +
    + +
    +
    + Endpoint +
    + + +

    Point to an OpenAI-compatible endpoint such as Ollama or a proxy.

    +
    +
    + + +

    Leave blank or use ollama for local servers that do not require keys.

    +
    +
    +
    + + +
    +
    + + +
    + +
    +
    +
    + Normalization Prompt +
    + + +

    Use {{ '{{sentence}}' }} for the active sentence. {{ '{{paragraph}}' }} remains available for legacy prompts.

    +
    +
    + Context Mode +
    + {% for option in llm_context_options %} + + {% endfor %} +
    +
    +
    + + +
    + + +
    +
    +
    +
    +
    + +
    + {% for group in options.normalization_groups %} +
    + {{ group.label }} + {% if group.label == "Apostrophes & Contractions" %} +
    + Strategy +
    + {% for option in apostrophe_modes %} + + {% endfor %} +
    + {% if settings.normalization_apostrophe_mode == 'llm' and not llm_ready %} +

    Configure the LLM connection before using it for audiobook runs.

    + {% endif %} +
    + {% endif %} +
    + {% for option in group.options %} + + {% endfor %} +
    + {% if group.label == "Apostrophes & Contractions" %} +
    + +

    Choose which contraction families are expanded.

    +
    + {% endif %} +
    + {% endfor %} + +
    + Sample & Preview +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    
    +              
    +            
    +
    +
    + +
    +
    + Calibre OPDS +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +

    Leave blank to keep the stored password.

    +
    +
    + +
    +
    +
    + + +
    +
    + +
    + Audiobookshelf +
    + + + +
    +
    + + +

    Use the server root (no trailing /api); the upload requests add it automatically.

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

    Enter the folder exactly as it appears in Audiobookshelf, paste the folder ID, or browse the available folders.

    +
    +
    +
    +
    + + +

    Leave blank to keep the stored token.

    +
    +
    + +
    +
    +
    +
    + + +
    +
    +
    + + + +
    +
    + + +
    +
    +
    + +
    +
    + Debug · TTS transformations +

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

    + +
    + +

    Uses your current Settings defaults (voice, language, speed, GPU). If generation fails, an error will appear at the top of this page.

    +
    + + {% if debug_manifest and debug_manifest.artifacts %} +
    + + +
    + {% endif %} + + {% if debug_samples %} +
    + +
      + {% for sample in debug_samples %} +
    • {{ sample.code }} — {{ sample.label }}: {{ sample.text }}
    • + {% endfor %} +
    +
    + {% endif %} +
    +
    +
    + + + + + +
    + +
    +
    + +
    +
    +
    +{% endblock %} + +{% block scripts %} +{{ super() }} + +{% endblock %} + diff --git a/abogen/webui/templates/speakers.html b/abogen/webui/templates/speakers.html new file mode 100644 index 0000000..cf59b11 --- /dev/null +++ b/abogen/webui/templates/speakers.html @@ -0,0 +1,155 @@ +{% extends "base.html" %} + +{% macro speaker_row(row_key, speaker, options) %} + {% set gender = (speaker.gender or 'unknown') %} +
    + + +
    + + + +
    + +
    +{% endmacro %} + +{% block title %}Speaker presets{% endblock %} + +{% block content %} +
    +
    + + 1 + Upload + + + 2 + Speakers + + + 3 + Queue + +
    +
    Speaker presets
    +

    Store recurring casts and keep voices consistent between books.

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

    Speakers

    + +
    +

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

    +
    + {% set speakers_map = editing.speakers or {} %} + {% if speakers_map %} + {% for speaker_id, speaker in speakers_map|dictsort(attribute='1.label') %} + {{ speaker_row(speaker_id, speaker, options) }} + {% endfor %} + {% else %} +
    No speakers yet. Add your first character.
    + {% endif %} +
    +
    + +
    + + +
    +
    +
    +
    + + +
    +{% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %} diff --git a/abogen/webui/templates/voices.html b/abogen/webui/templates/voices.html new file mode 100644 index 0000000..9e16b3b --- /dev/null +++ b/abogen/webui/templates/voices.html @@ -0,0 +1,177 @@ +{% extends "base.html" %} + +{% block title %}abogen · Speaker Studio{% endblock %} + +{% block content %} +
    +
    +
    +

    Speaker Studio

    +

    Create and manage speakers for Kokoro and Supertonic.

    +
    +
    + + + +
    +
    +
    + +
    + +
    +
    +
    +
    +
    + + +
    +
    + + + +
    +
    +
    + + +
    +
    + + +
    + +
    +
    + Select or create a profile to begin. + Total weight: 0.00 +
    +
    +
    +
    +
    +

    Available voices

    +

    Drag into the mixer or click Add.

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

    Your mix

    +

    Drop voices below to set levels.

    +
    +
    +

    Drag voices here or tap “Add”.

    +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    +
    +
    + + + +
    +{% endblock %} + +{% block scripts %} + + + +{% endblock %} 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..bcfdacd 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,6 +83,12 @@ 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.+)$" diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..c6c023a 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,46 @@ +"""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 + + +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..1491829 --- /dev/null +++ b/tests/test_audiobookshelf_client.py @@ -0,0 +1,84 @@ +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_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..82f6bf4 --- /dev/null +++ b/tests/test_calibre_opds.py @@ -0,0 +1,617 @@ +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..27f942d --- /dev/null +++ b/tests/test_chapter_overrides.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import sys +import types + + +def _install_dependency_stubs() -> None: + if "ebooklib" not in sys.modules: + ebooklib_stub = types.ModuleType("ebooklib") + epub_stub = types.ModuleType("ebooklib.epub") + setattr(ebooklib_stub, "epub", epub_stub) + sys.modules["ebooklib"] = ebooklib_stub + sys.modules["ebooklib.epub"] = epub_stub + + if "dotenv" not in sys.modules: + dotenv_stub = types.ModuleType("dotenv") + + def _noop(*_, **__): + return None + + setattr(dotenv_stub, "load_dotenv", _noop) + setattr(dotenv_stub, "find_dotenv", lambda *_, **__: "") + sys.modules["dotenv"] = dotenv_stub + + if "numpy" not in sys.modules: + numpy_stub = types.ModuleType("numpy") + + class _DummyArray(list): + pass + + def _zeros(shape, dtype=None): + size = 1 + if isinstance(shape, int): + size = shape + elif shape: + size = 1 + for dimension in shape: + size *= int(dimension) + return [0.0] * size + + setattr(numpy_stub, "ndarray", _DummyArray) + setattr(numpy_stub, "zeros", _zeros) + setattr(numpy_stub, "float32", "float32") + setattr(numpy_stub, "array", lambda data, dtype=None: data) + setattr(numpy_stub, "asarray", lambda data, dtype=None: data) + setattr(numpy_stub, "concatenate", lambda seq, axis=0: sum((list(item) for item in seq), [])) + sys.modules["numpy"] = numpy_stub + + if "soundfile" not in sys.modules: + soundfile_stub = types.ModuleType("soundfile") + + class _DummySoundFile: + def __init__(self, *_, **__): + pass + + def write(self, *_args, **_kwargs): + return None + + def close(self): + return None + + setattr(soundfile_stub, "SoundFile", _DummySoundFile) + setattr(soundfile_stub, "write", lambda *_args, **_kwargs: None) + sys.modules["soundfile"] = soundfile_stub + + if "fitz" not in sys.modules: + sys.modules["fitz"] = types.ModuleType("fitz") + + if "markdown" not in sys.modules: + markdown_stub = types.ModuleType("markdown") + + class _DummyMarkdown: + def __init__(self, *_, **__): + pass + + def convert(self, text: str) -> str: + return text + + setattr(markdown_stub, "Markdown", _DummyMarkdown) + sys.modules["markdown"] = markdown_stub + + if "bs4" not in sys.modules: + bs4_stub = types.ModuleType("bs4") + + class _DummySoup: + def __init__(self, *_, **__): + pass + + def select(self, *_, **__): + return [] + + def find_all(self, *_, **__): + return [] + + setattr(bs4_stub, "BeautifulSoup", _DummySoup) + setattr(bs4_stub, "NavigableString", str) + sys.modules["bs4"] = bs4_stub + + +_install_dependency_stubs() + +from abogen.text_extractor import ExtractedChapter +from abogen.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..b937358 --- /dev/null +++ b/tests/test_chunk_helpers.py @@ -0,0 +1,83 @@ +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..76805c6 --- /dev/null +++ b/tests/test_conversion_chapter_titles.py @@ -0,0 +1,119 @@ +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..fb85138 --- /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 \ No newline at end of file diff --git a/tests/test_conversion_voice_resolution.py b/tests/test_conversion_voice_resolution.py new file mode 100644 index 0000000..4987c5d --- /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..393feee --- /dev/null +++ b/tests/test_date_normalization_comprehensive.py @@ -0,0 +1,116 @@ +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..6f0a088 --- /dev/null +++ b/tests/test_debug_tts_samples.py @@ -0,0 +1,155 @@ +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_exporter.py b/tests/test_epub_exporter.py new file mode 100644 index 0000000..4b5f847 --- /dev/null +++ b/tests/test_epub_exporter.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import html +import re +import zipfile + +from abogen.epub3.exporter import build_epub3_package +from abogen.text_extractor import ExtractedChapter, ExtractionResult + + +def _make_sample_extraction() -> ExtractionResult: + return ExtractionResult( + chapters=[ + ExtractedChapter(title="Chapter 1", text="Hello world."), + ExtractedChapter(title="Chapter 2", text="Another passage."), + ], + metadata={"title": "Sample Book", "artist": "Test Author", "language": "en"}, + ) + + +def test_build_epub3_package_creates_expected_structure(tmp_path) -> None: + extraction = _make_sample_extraction() + chunks = [ + { + "id": "chap0000_p0000", + "chapter_index": 0, + "chunk_index": 0, + "text": "Hello world.", + "speaker_id": "narrator", + }, + { + "id": "chap0001_p0000", + "chapter_index": 1, + "chunk_index": 0, + "text": "Another passage.", + "speaker_id": "narrator", + }, + ] + chunk_markers = [ + {"id": "chap0000_p0000", "chapter_index": 0, "chunk_index": 0, "start": 0.0, "end": 1.2}, + {"id": "chap0001_p0000", "chapter_index": 1, "chunk_index": 0, "start": 1.2, "end": 2.4}, + ] + chapter_markers = [ + {"index": 1, "title": "Chapter 1", "start": 0.0, "end": 1.2}, + {"index": 2, "title": "Chapter 2", "start": 1.2, "end": 2.4}, + ] + metadata_tags = {"title": "Sample Book", "artist": "Test Author", "language": "en"} + + audio_path = tmp_path / "sample.mp3" + audio_path.write_bytes(b"ID3 test audio") + + output_path = tmp_path / "output.epub" + result_path = build_epub3_package( + output_path=output_path, + book_id="job-123", + extraction=extraction, + metadata_tags=metadata_tags, + chapter_markers=chapter_markers, + chunk_markers=chunk_markers, + chunks=chunks, + audio_path=audio_path, + speaker_mode="single", + ) + + assert result_path == output_path + assert output_path.exists() + + with zipfile.ZipFile(output_path) as archive: + names = set(archive.namelist()) + assert "mimetype" in names + assert archive.read("mimetype") == b"application/epub+zip" + assert "META-INF/container.xml" in names + assert "OEBPS/content.opf" in names + assert "OEBPS/nav.xhtml" in names + assert "OEBPS/audio/sample.mp3" in names + chapter_doc = archive.read("OEBPS/text/chapter_0001.xhtml").decode("utf-8") + assert "Hello world." in chapter_doc + smil_doc = archive.read("OEBPS/smil/chapter_0001.smil").decode("utf-8") + assert "clipBegin=\"00:00:00.000\"" in smil_doc + opf_doc = archive.read("OEBPS/content.opf").decode("utf-8") + assert "media-overlay" in opf_doc + assert "media:duration" in opf_doc + assert "abogen:speakerMode" in opf_doc + + +def test_build_epub3_package_handles_missing_markers(tmp_path) -> None: + extraction = _make_sample_extraction() + metadata_tags = {"title": "Sample Book", "artist": "Test Author", "language": "en"} + audio_path = tmp_path / "audio.mp3" + audio_path.write_bytes(b"ID3 audio") + output_path = tmp_path / "output.epub" + + result_path = build_epub3_package( + output_path=output_path, + book_id="job-456", + extraction=extraction, + metadata_tags=metadata_tags, + chapter_markers=[], + chunk_markers=[], + chunks=[], + audio_path=audio_path, + speaker_mode="single", + ) + + with zipfile.ZipFile(result_path) as archive: + nav_doc = archive.read("OEBPS/nav.xhtml").decode("utf-8") + assert "Chapter 1" in nav_doc + chapter_doc = archive.read("OEBPS/text/chapter_0001.xhtml").decode("utf-8") + assert "Hello world." in chapter_doc + + +def test_epub3_preserves_original_whitespace(tmp_path) -> None: + extraction = ExtractionResult( + chapters=[ + ExtractedChapter( + title="Intro", + text="Line one with double spaces.\nSecond line\n\nThird paragraph.", + ) + ], + metadata={"title": "Sample", "artist": "Author", "language": "en"}, + ) + + chunks = [ + { + "id": "chap0000_p0000", + "chapter_index": 0, + "chunk_index": 0, + "text": "Line one with double spaces.", + "speaker_id": "narrator", + }, + { + "id": "chap0000_p0001", + "chapter_index": 0, + "chunk_index": 1, + "text": "Second line", + "speaker_id": "narrator", + }, + { + "id": "chap0000_p0002", + "chapter_index": 0, + "chunk_index": 2, + "text": "Third paragraph.", + "speaker_id": "narrator", + }, + ] + + chunk_markers = [ + {"id": chunk["id"], "chapter_index": 0, "chunk_index": chunk["chunk_index"], "start": None, "end": None} + for chunk in chunks + ] + + metadata_tags = {"title": "Sample", "artist": "Author", "language": "en"} + audio_path = tmp_path / "audio.mp3" + audio_path.write_bytes(b"ID3 audio") + output_path = tmp_path / "output.epub" + + build_epub3_package( + output_path=output_path, + book_id="job-whitespace", + extraction=extraction, + metadata_tags=metadata_tags, + chapter_markers=[], + chunk_markers=chunk_markers, + chunks=chunks, + audio_path=audio_path, + speaker_mode="single", + ) + + with zipfile.ZipFile(output_path) as archive: + chapter_doc = archive.read("OEBPS/text/chapter_0001.xhtml").decode("utf-8") + assert "Line one with double spaces." in chapter_doc + + chunk_section = chapter_doc.replace(" ", "") + assert "Second line" in chunk_section + assert "Third paragraph." in chunk_section + + match = re.search(r"
    ]*>(.*?)
    ", chapter_doc, re.DOTALL) + assert match is not None + original_text = html.unescape(match.group(1)) + assert "Second line\n\nThird paragraph." in original_text + + +def test_epub3_sentence_chunks_render_as_paragraphs(tmp_path) -> None: + extraction = ExtractionResult( + chapters=[ + ExtractedChapter( + title="Chapter 1", + text="First sentence. Second sentence in same paragraph.\n\nNew paragraph starts here.", + ) + ], + metadata={"title": "Sample", "artist": "Author", "language": "en"}, + ) + + chunks = [ + { + "id": "chap0000_p0000_s0000", + "chapter_index": 0, + "chunk_index": 0, + "text": "First sentence.", + "level": "sentence", + "speaker_id": "narrator", + }, + { + "id": "chap0000_p0000_s0001", + "chapter_index": 0, + "chunk_index": 1, + "text": "Second sentence in same paragraph.", + "level": "sentence", + "speaker_id": "narrator", + }, + { + "id": "chap0000_p0001_s0000", + "chapter_index": 0, + "chunk_index": 2, + "text": "New paragraph starts here.", + "level": "sentence", + "speaker_id": "narrator", + }, + ] + + chunk_markers = [ + {"id": chunk["id"], "chapter_index": 0, "chunk_index": chunk["chunk_index"], "start": None, "end": None} + for chunk in chunks + ] + + audio_path = tmp_path / "audio.mp3" + audio_path.write_bytes(b"ID3 audio") + output_path = tmp_path / "output.epub" + + build_epub3_package( + output_path=output_path, + book_id="job-paragraphs", + extraction=extraction, + metadata_tags={"title": "Sample", "artist": "Author", "language": "en"}, + chapter_markers=[], + chunk_markers=chunk_markers, + chunks=chunks, + audio_path=audio_path, + speaker_mode="single", + ) + + with zipfile.ZipFile(output_path) as archive: + chapter_doc = archive.read("OEBPS/text/chapter_0001.xhtml").decode("utf-8") + + assert '
    ', 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 \ No newline at end of file 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..a1d14a8 --- /dev/null +++ b/tests/test_output_paths.py @@ -0,0 +1,69 @@ +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_prepare_form.py b/tests/test_prepare_form.py new file mode 100644 index 0000000..4ea05eb --- /dev/null +++ b/tests/test_prepare_form.py @@ -0,0 +1,121 @@ +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..76998c4 --- /dev/null +++ b/tests/test_preview_applies_manual_overrides.py @@ -0,0 +1,49 @@ +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..7bf0396 --- /dev/null +++ b/tests/test_regression_fixes.py @@ -0,0 +1,81 @@ +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..07c61f1 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,290 @@ +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" \ No newline at end of file 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..88885a5 --- /dev/null +++ b/tests/test_speaker_analysis.py @@ -0,0 +1,93 @@ +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..f63b67f --- /dev/null +++ b/tests/test_text_extractor.py @@ -0,0 +1,56 @@ +from pathlib import Path + +from ebooklib import epub + +from abogen.text_extractor import extract_from_path +from abogen.utils import calculate_text_length + + +ASSET = Path("test_assets/alexandre-dumas_the-count-of-monte-cristo_chapman-and-hall.epub") + + +def test_epub_character_counts_align_with_calculated_total(): + result = extract_from_path(ASSET) + + combined_total = calculate_text_length(result.combined_text) + chapter_total = sum(chapter.characters for chapter in result.chapters) + + assert result.total_characters == combined_total == chapter_total + + +def test_epub_metadata_composer_matches_artist(): + result = extract_from_path(ASSET) + + composer = result.metadata.get("composer") or result.metadata.get("COMPOSER") + artist = result.metadata.get("artist") or result.metadata.get("ARTIST") + + assert composer + assert composer == artist + assert composer != "Narrator" + + +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 + book.add_metadata("OPF", "meta", "", {"name": "calibre:series", "content": "Example Saga"}) + book.add_metadata("OPF", "meta", "", {"name": "calibre:series_index", "content": "2"}) + + chapter = epub.EpubHtml(title="Chapter 1", file_name="chap_01.xhtml", lang="en") + chapter.content = "

    Chapter 1

    Hello

    " + book.add_item(chapter) + book.spine = ["nav", chapter] + book.add_item(epub.EpubNcx()) + book.add_item(epub.EpubNav()) + + path = tmp_path / "example.epub" + epub.write_epub(str(path), 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..156fccf --- /dev/null +++ b/tests/test_text_normalization.py @@ -0,0 +1,343 @@ +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..b0aa2ca --- /dev/null +++ b/tests/test_voice_cache.py @@ -0,0 +1,65 @@ +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