Compare commits

...
425 Commits
Author SHA1 Message Date
Deniz ŞafakandGitHub cbc05ead42 Merge pull request #173 from k0sm0naft/refactor/tts-backend-interface
refactor: introduce TTS backend abstraction
2026-07-03 21:04:47 +03:00
Artem Akymenko 50b4d6872a feat: Add minimal TTSBackend interface for future extensibility
- Create TTSBackend abstract base class with minimal contract
- Implement KokoroTTSBackend that maintains existing behavior
- Update conversion_runner.py to use new interface
- No behavioral changes, GUI unchanged, no new features
2026-07-03 01:25:41 +03:00
Deniz ŞafakandGitHub 9fa81fbe1e Merge pull request #160 from JoaGamo/main
Fix #152 : preview buttons on webUI
2026-04-30 13:05:44 +03:00
JoaGamo 9fd9fad238 Fall-back to CPU if no compatible device is available 2026-04-21 22:50:59 -03:00
Deniz ŞafakandGitHub ca5c5ee62d Merge pull request #146 from olandir/131wVoiceTags
Voice Tags and Word Substitution Added to Main Script
2026-03-07 01:48:59 +03:00
olandir e51be95bc1 Update .gitignore 2026-02-28 21:26:57 -05:00
olandirandClaude Sonnet 4.6 2223f46c9e Port voice marker and word substitution features to upstream refactored structure
The upstream project moved PyQt code to abogen/pyqt/ subdirectory, making the
original feature commits non-mergeable. This commit re-applies both features
to the new file locations.

Voice Marker feature (<<VOICE:voice_name>> syntax):
- subtitle_utils.py: Added _VOICE_MARKER_PATTERN, _VOICE_MARKER_SEARCH_PATTERN,
  validate_voice_name(), split_text_by_voice_markers() (with valid/invalid counts)
- pyqt/conversion.py: Added load_voice_cached(), voice marker pre-processing before
  chapter loop, inner voice segment loop wrapping spaCy+TTS block, updated imports
- pyqt/gui.py: Added Insert Voice Marker button and insert_voice_marker() to TextboxDialog

Word Substitution feature (text preprocessing before TTS):
- word_substitution.py: New module (word replacements, ALL CAPS, numerals, punctuation)
- pyqt/conversion.py: apply_word_substitutions() call after clean_text()
- pyqt/gui.py: WordSubstitutionsDialog, word_sub_combo, Settings button,
  on_word_sub_changed(), show_word_sub_dialog(), config persistence, queue restore
- pyqt/queued_item.py: 6 new word substitution fields
- pyqt/queue_manager_gui.py: 6 fields added to OVERRIDE_FIELDS and get_current_attributes()

Note: num2words>=0.5.13 was already added to pyproject.toml by upstream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:19:39 -05:00
Deniz ŞafakandGitHub 8322f7f416 Merge pull request #128 from vladimir-sol/fix-add-missing-pauses
Ensure appropriate speech pauses by adding newlines at epub processing
2026-02-19 14:57:06 +03:00
Vladimir Sol 2c15d2f78a Ensure appropriate speech pauses by adding newlines at epub processing 2026-02-17 19:59:03 -08:00
Deniz ŞafakandGitHub cc9c2a22ba Update GitHub Sponsors usernames in FUNDING.yml 2026-02-10 16:14:05 +03:00
Deniz ŞafakandGitHub d1366b445d Merge pull request #139 from abenea/crash
Fix importing chapters in the PyQt UI.
2026-02-08 17:51:56 +03:00
Andrei Benea c224cdbb56 Fix importing chapters in the PyQt UI.
The app was crashing after importing a .txt and clicking convert because of a missing import. Fixed the imports and removed the legacy abogen.conversion module which doesn't seem necessary anymore.
2026-02-08 11:01:31 +01:00
Deniz Şafak d30415ffe7 Update project version from 1.3.0 to 1.3.1. 2026-02-07 00:23:08 +03:00
Deniz ŞafakandGitHub 083f1eb09b Update CHANGELOG for version 1.3.0
Removed unreleased section and updated version 1.3.0 details.
2026-02-07 00:04:03 +03:00
Deniz ŞafakandGitHub 30929e8f4e Format pyproject.toml 2026-02-07 00:03:20 +03:00
Deniz ŞafakandGitHub ded73843c9 Merge pull request #136 from denizsafak/webui
Merge webui with main

Huge thanks to @jeremiahsb!!
2026-02-06 23:46:03 +03:00
7b1f4f54ee Update README.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 23:45:13 +03:00
8cbfc15028 Update CHANGELOG.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 23:44:52 +03:00
Deniz Şafak 79957d09ed Fix c10.dll issue in installer script 2026-02-06 23:36:32 +03:00
Deniz Şafak 925ef51edc Add webui photo 2026-02-06 23:26:16 +03:00
Deniz Şafak be7c7baa75 Update readme 2026-02-06 23:20:04 +03:00
Deniz Şafak 6ab863e65f Update README and version 2026-02-06 23:09:49 +03:00
Deniz Şafak 950eb317f0 Fixed all pytest errors, enhanced book parser with context management and resource cleanup, update tests for proper parser closure 2026-01-10 00:09:04 +03:00
Deniz Şafak bd30939d27 Fix "span_text not defined" warning 2026-01-09 22:39:49 +03:00
Deniz ŞafakandGitHub ace1022da4 Merge pull request #126 from mohangk/webui
Further refactoring of book_handler logic into PDFParser
2026-01-09 20:50:33 +03:00
Mohan Krishnan eb7d135bc3 Further refactoring of book_handler logic into PDFParser
Also deletes the unnencessary abogen/book_handler and just use pyqt/book_hanlder
2026-01-09 08:44:33 +08:00
Deniz Şafak 5ae153f841 Reformat using black 2026-01-09 01:36:14 +03:00
Deniz Şafak 3c800df450 Performance improvements for voice mixer 2026-01-09 01:30:38 +03:00
Deniz Şafak fc0af420c6 Handle empty voice list initially 2026-01-09 01:19:39 +03:00
Deniz Şafak 7677f5a1e2 Update installation instructions in README to use editable mode for development 2026-01-08 01:30:43 +03:00
Deniz ŞafakandGitHub 0e726b97c4 Merge pull request #120 from jeremiahsb/main
feat: Supertonic TTS integration, Docker/GPU enhancements, PyQt support, and comprehensive test suite
2026-01-08 01:03:35 +03:00
JB b5b157879f Resolve book_handler conflict: keep PyQt in pyqt/ 2026-01-06 16:40:22 -08:00
JB 27041c6475 Merge branch 'feature/additive-merge-webui' into integrate/webui-into-main-20260106-1629 2026-01-06 16:32:03 -08:00
JB 7df4345486 Merge upstream/main into feature branch
Resolved conflicts:
- CHANGELOG.md: Kept Unreleased section + upstream version history
- README.md: Kept web-first documentation rewrite
- abogen/Dockerfile: Removed (replaced by webui/Dockerfile)
- abogen/{book_handler,conversion,gui,queue_manager_gui,voice_formula_gui}.py: Kept stubs
- abogen/main.py: Kept web UI launcher
- abogen/utils.py: Kept optional chardet imports
- pyproject.toml: Merged dependencies (Flask, gpustat, httpx + pip from upstream)
2025-12-22 12:39:05 -08:00
JB 5b174737ad Merge upstream/main into main
Resolved conflicts:
- CHANGELOG.md: Kept Unreleased section + upstream version history
- README.md: Kept web-first documentation rewrite
- abogen/Dockerfile: Removed (replaced by webui/Dockerfile)
- abogen/{book_handler,conversion,gui,queue_manager_gui,voice_formula_gui}.py: Kept stubs (implementations in pyqt/)
- abogen/main.py: Kept web UI launcher
- abogen/utils.py: Kept version with optional chardet imports
- pyproject.toml: Merged dependencies (kept Flask, gpustat, httpx for web UI + pip from upstream)
2025-12-22 11:50:47 -08:00
JB 1219b408b5 feat: Update Dockerfile and entrypoint script for CUDA diagnostics and web server startup; adjust PyTorch version and URL handling 2025-12-22 08:54:03 -08:00
JB b2058ef3ee fix: Adjust URL handling in CalibreOPDSClient to support base URLs without trailing slashes 2025-12-22 07:27:17 -08:00
JB d1e0e0a536 feat: Refactor Audiobookshelf and Calibre OPDS integration routes to use helper functions for settings extraction 2025-12-22 06:56:25 -08:00
JB 4272c847f7 feat: Add network mode configuration for Docker in .env and docker-compose 2025-12-22 06:42:00 -08:00
JB 89645682f1 fix: Use abogen-web command in Docker for headless web UI
The 'abogen' command now launches the PyQt6 desktop GUI which requires
display libraries not available in Docker containers. Changed the Docker
CMD to use 'abogen-web' which launches the Flask web UI.
2025-12-22 06:17:40 -08:00
JB e2b2f610a6 feat: Additive merge of webui branch with PyQt GUI support
- Add abogen/pyqt/ package with full PyQt6 desktop GUI
- Restore PyQt GUI files (gui.py, book_handler.py, queue_manager_gui.py, voice_formula_gui.py, conversion.py)
- Add new shared modules from webui: book_parser.py, subtitle_utils.py, spacy_utils.py
- Add Linux libraries (libxcb-cursor) for Qt platform plugin support
- Add new epub parsing tests from webui branch
- Update pyproject.toml with dual entry points:
  - abogen: PyQt6 desktop GUI
  - abogen-web: Flask Web UI
- Add PyQt6 to dependencies
- Re-export PyQt classes from root modules for backwards compatibility
- Merge CHANGELOG.md entries (1.2.0-1.2.5 from webui)
- Update README.md with dual interface documentation

Implements #26 - shared core with separate UI folders
2025-12-22 05:51:21 -08:00
Deniz ŞafakandGitHub 76cab1192c Merge pull request #119 from mohangk/main
Extracts book_parser.py from book_handler.py
2025-12-22 11:23:24 +03:00
Mohan Krishnan 1fdd1c8540 Extracts book_parser.py from book_handler.py
Extracts out a BaseBookParser, PDFParser and MarkdownParser class from book_handler logic

The basic contract is that the parser classes populate 4 attributes that are used by the book_handler logic
- content_texts
- content_lengths
- book_metatdata
- processed_nav_structures (used by the method get_chapters on the BaseBookParser class)

Adds tests to validate the changes as well in tests/
Run tests from the abogen dir as follows

```bash
python -m unittest discover
```
2025-12-22 13:32:40 +08:00
JB ede5343e0c feat: Update job detail and logs routes to return friendly pages instead of 404 errors 2025-12-21 16:21:45 -08:00
JB 63d179ba19 feat: Update GPU configuration to prioritize CUDA and remove TensorRT dependency 2025-12-21 09:04:19 -08:00
JB 90786d2bed feat: Update Supertonic GPU configuration to include loader patching for ONNX providers 2025-12-21 08:58:51 -08:00
JB c2209eeb2a feat: Add GPU configuration for Supertonic to enable acceleration support 2025-12-21 08:51:05 -08:00
JB 899c9f5aa5 feat: Add USE_GPU argument to Dockerfile and docker-compose for GPU support 2025-12-21 08:31:51 -08:00
JB 938e122166 feat: Refactor code structure to move web-related components to the webui module and update references accordingly 2025-12-21 08:12:48 -08:00
JB 5303dcf681 Refactored code to move into the webui folder in order to prep for merging the branch. 2025-12-21 08:06:15 -08:00
JB b47952b857 feat: Enhance series metadata handling in Calibre OPDS integration and Epub extraction 2025-12-20 20:22:05 -08:00
JB 0fe8ee0ad4 feat: Add pending ID handling and pronunciation/manual overrides to audio preview generation 2025-12-20 17:40:05 -08:00
JB 5dd53354a1 feat: Add _chunk_text_for_tts function to prioritize raw text for TTS synthesis and implement related tests 2025-12-20 17:18:46 -08:00
JB eb57744533 feat: Implement unsupported character handling in Supertonic pipeline and add related tests 2025-12-20 12:18:27 -08:00
JB de8debb6b1 feat: Add support for saved speaker references in voice selection and enhance related tests 2025-12-20 09:11:35 -08:00
JB e47536e2ab feat: Enhance voice resolution handling and support mixed-provider conversions 2025-12-20 09:09:37 -08:00
JB 32f616e90b feat: Improve SuperTonic voice resolution and add tests for voice formula handling 2025-12-20 08:35:33 -08:00
JB 19e98c3ad6 feat: Add subtitle support in OPDSEntry and enhance metadata handling in API routes 2025-12-20 08:27:47 -08:00
JB eabfe87ffb feat: Enhance Supertonic voice mixer controls with range inputs and display labels 2025-12-20 07:36:34 -08:00
JB 9a72c209b3 feat: Enhance provider picker modal handling and improve action event delegation 2025-12-20 07:19:02 -08:00
JB 888c737293 feat: Add provider selection modal and enhance voice profile handling for Supertonic integration 2025-12-20 07:12:19 -08:00
JB 77eb58bdff Refactor voice profiles to support Supertonic integration
- Introduced normalization functions for Supertonic voice profiles.
- Updated profile serialization to include provider information.
- Enhanced API endpoints to handle Supertonic profiles, including import/export functionality.
- Modified settings to allow selection of default speakers and removed deprecated options.
- Updated front-end to manage speaker profiles, including UI changes for Supertonic settings.
- Improved handling of voice mixing and preview functionalities for both Kokoro and Supertonic providers.
2025-12-20 06:49:13 -08:00
JB 95d5921e67 feat: Integrate Supertonic TTS provider with configuration options and UI updates; enhance voice handling and settings management 2025-12-20 06:20:33 -08:00
JB 08ebedc177 feat: Update year normalization logic to reflect US-style pronunciation for years 1100-1999; adjust related tests for consistency 2025-12-16 08:52:03 -08:00
Deniz ŞafakandGitHub 1c33a99554 Merge pull request #115 from cedarhillgoods/patch-1
Fixed typo (termminal > terminal)
2025-12-16 02:46:29 -08:00
JB 015435adb6 feat: Enhance text normalization with support for internet slang expansion, currency formatting, and date handling; update debug WAVs interface and settings 2025-12-15 17:27:08 -08:00
JB 0afaaf561b feat: Implement voice profile resolution in debug TTS and add corresponding tests 2025-12-15 16:28:30 -08:00
JB fdf95dbae7 feat: Implement language aliasing in debug TTS and add debug WAVs page with artifact handling 2025-12-15 16:17:55 -08:00
JB 05d7c28128 feat: Expand debug TTS samples with additional cases and implement validation test for minimum sample counts 2025-12-15 15:41:41 -08:00
JB aa71783e5a feat: Add debug TTS functionality with EPUB generation, WAV artifact creation, and settings integration 2025-12-15 13:46:13 -08:00
cedarhillgoodsandGitHub 0c8a7cdf81 Fixed typo (termminal > terminal) 2025-12-15 12:05:10 -08:00
JB daf4d78766 feat: Implement heteronym handling with extraction, UI integration, and processing logic 2025-12-15 06:41:13 -08:00
Deniz Şafak 66a97835f0 Update README.md and pyproject.toml for improved installation instructions and development dependencies 2025-12-13 23:03:51 +03:00
Deniz Şafak 0d097f562a Update installation instructions for AMD GPUs and CUDA versions in README.md 2025-12-13 21:02:29 +03:00
Deniz Şafak a7ae13558c Add missing load_config import to subtitle_utils.py 2025-12-13 20:32:33 +03:00
Deniz Şafak 5735f05be0 Improve WINDOWS_INSTALL.bat to support version selection and install dependencies using uv 2025-12-13 20:27:48 +03:00
Deniz ŞafakandGitHub c3673b6dc7 Merge pull request #113 from mohangk/refactor
Extracts out subtitle_utils from conversion.py
2025-12-13 08:19:10 -08:00
Your Name 675ed437c8 Extracts out subtitle_utils from conversion.py
The goal of this change is to move any non PyQt
related logic from conversion.py into its own
subtitle_utils.py. Together with this change there
was an opportunity to pull together some duplicate
text processing that was also in utils.py
2025-12-13 17:08:22 +08:00
Deniz ŞafakandGitHub 5010616e85 Add PyPI total downloads badge to README
Added a badge for total downloads from PyPI.
2025-12-12 04:33:39 +03:00
Deniz Şafak 65eecbcfee Update installation instructions in README for Silicon and Intel Mac support 2025-12-10 23:50:43 +03:00
Deniz Şafak c9b17b6f49 Update installation commands in README to include index strategy for CUDA and ROCm 2025-12-10 05:59:58 +03:00
Deniz Şafak 7722863435 Update installation commands in README to include extra index URLs for CUDA and ROCm 2025-12-10 05:40:16 +03:00
Deniz Şafak 6a1928483b Fix numpy error 2025-12-10 04:22:30 +03:00
Deniz Şafak 2a20dfab6f Update readme 2025-12-10 04:19:12 +03:00
Deniz Şafak a2f186e7a1 Update installation command in README to include Kokoro dependency 2025-12-10 04:17:18 +03:00
Deniz Şafak 56f81ef892 v1.2.5 2025-12-10 03:35:00 +03:00
Deniz Şafak 968c673e58 Fixed incorrect sentence segmentation when using spaCy 2025-12-10 03:20:59 +03:00
Deniz Şafak 174ff4232f Update installation instructions in README for uv 2025-12-10 03:03:49 +03:00
Deniz Şafak c43659005e Added new option: Override item settings with current selection 2025-12-10 01:27:00 +03:00
Deniz Şafak e3cdd1a9ca Fixed Error "Could not load the Qt platform plugin "xcb" mentioned in #101 2025-12-09 04:24:38 +03:00
Deniz Şafak ed31aaf632 Implement PyPI package builder script with version handling and build module installation 2025-12-09 01:00:35 +03:00
Deniz Şafak 0820c40b14 uv integration: add optional dependencies and index definitions for CUDA and ROCm configurations 2025-12-08 23:00:57 +03:00
Deniz Şafak 0ac2810515 update changelog 2025-12-03 21:36:47 +03:00
Deniz Şafak 1c3fd9e4cf Fix #109 2025-12-03 21:34:35 +03:00
JB ef2b045b69 feat: Enhance number normalization logic to distinguish between addresses and years 2025-12-02 12:21:46 -08:00
JB 196e2cdf2e feat: Update metadata handling in new job step book template for improved data access 2025-12-02 11:33:33 -08:00
JB a501e96b12 feat: Add metadata fields for title, subtitle, author, series, publisher, and description in book form 2025-12-02 10:51:51 -08:00
JB 1e13c901fe feat: Implement normalization settings UI with dynamic groups and options 2025-12-02 09:58:47 -08:00
JB 609af66748 feat: Enhance currency normalization to support magnitude and fractional amounts 2025-12-02 07:43:08 -08:00
JB 2e1e9af995 feat: Implement migration from legacy SQLite database to JSON for pronunciation overrides 2025-12-02 06:55:24 -08:00
JB 40eb294fec feat: Refactor pronunciation storage from SQLite to JSON for improved simplicity and performance 2025-12-02 06:43:08 -08:00
JB b502ff9068 feat: Update entities route to use '/overrides' prefix and enhance filtering options for pronunciation overrides 2025-12-02 05:15:24 -08:00
JB a14469c1d0 feat: Add get_override_stats function to retrieve statistics for pronunciation overrides 2025-12-01 20:08:01 -08:00
JB aa53438acf feat: Add normalization settings for currency conversion and footnote removal in UI 2025-12-01 20:00:56 -08:00
JB baef01ad45 feat: Add analysis and override fields to Job class for enhanced processing capabilities 2025-12-01 19:27:49 -08:00
JB 7eceb601f8 feat: Implement footnote removal and URL normalization in text processing; enhance manual override token normalization 2025-12-01 06:23:30 -08:00
JB b3aaa94831 feat: Add estimated time remaining display and duration formatting to job progress cards 2025-12-01 05:26:50 -08:00
JB c7dd8cf13a feat: Add job statistics overview to dashboard with styling enhancements 2025-11-30 15:28:37 -08:00
JB 7db1779ca5 fix: Enhance author handling in metadata payload and add currency conversion support in normalization 2025-11-30 15:13:51 -08:00
JB 76b3aae341 fix: Update _normalize_metadata_casefold and _first_nonempty to handle various data types 2025-11-30 12:36:58 -08:00
JB 07c78255e8 feat: Enhance entity tabs with global loading spinner and status text for improved user feedback 2025-11-30 11:55:32 -08:00
JB 040bce1bc1 fix: Implement garbage collection in run_conversion_job to prevent memory accumulation and add resource limits in docker-compose for better performance 2025-11-30 05:37:40 -08:00
JB 540b191d5b fix: Simplify audiobookshelf availability check and improve error handling in prepare.js 2025-11-30 05:31:25 -08:00
JB 079c36702e fix: Update redirect keys in wizard functions and add next_step to form data 2025-11-29 19:25:19 -08:00
JB 59c8568348 fix: Include pending_id in redirect URL and form data when available 2025-11-29 12:47:51 -08:00
JB f2ba8f692c fix: Enhance wizard_upload to handle pending jobs and new file uploads 2025-11-29 12:17:56 -08:00
JB 2e1f38a98b fix: Update redirect step in api_calibre_opds_import to point to 'book' instead of 'chapters' 2025-11-29 12:16:09 -08:00
JB 3d073e8e55 fix: Rename redirect key to redirect_url in api_calibre_opds_import response 2025-11-29 12:09:06 -08:00
JB cc641cec78 refactor: Remove redundant checks for activeLetter in find_books.js 2025-11-29 12:00:47 -08:00
JB aa45228844 refactor: Replace feed_to_dict with feed.to_dict in CalibreOPDSClient integration 2025-11-29 11:49:17 -08:00
JB 6cc2b4e8a4 feat: Enhance CalibreOPDSClient with improved search scoring and OpenSearch template fetching 2025-11-29 10:21:23 -08:00
JB 252be6d4b7 feat: Add text normalization for improved search functionality in CalibreOPDSClient 2025-11-29 06:02:04 -08:00
JB bce1419d92 feat: Enhance CalibreOPDSClient to start feed from the first link if available 2025-11-29 05:57:03 -08:00
JB b0a3e1dbd9 feat: Enhance search functionality in CalibreOPDSClient to support contextual search with start_href 2025-11-29 05:54:13 -08:00
JB 8a2bd7ec50 feat: Add Calibre OPDS feed and import endpoints for enhanced integration 2025-11-29 05:39:11 -08:00
Deniz Şafak e96c19ace6 Fixed the No module named pip error 2025-11-29 13:21:33 +03:00
JB a5f2bf7fbe feat: Refactor integration settings loading to use environment variable fallbacks for Calibre OPDS 2025-11-28 20:16:34 -08:00
JB 4bfce0f900 feat: Update Calibre integration to use 'calibre_opds' for settings retrieval 2025-11-28 19:49:41 -08:00
JB 5b548cc155 feat: Implement Calibre OPDS import endpoint for downloading books 2025-11-28 19:43:13 -08:00
JB c69e5af54d feat: Improve integration settings loading by ensuring proper mapping and type validation 2025-11-28 19:03:33 -08:00
JB 48e9534b68 feat: Preserve password and API token in integration settings loading 2025-11-28 18:57:53 -08:00
JB a9489dec2d feat: Update Audiobookshelf and Calibre OPDS integration settings with new parameters and improved handling 2025-11-28 18:28:36 -08:00
JB 94983c39bb feat: Enhance Audiobookshelf integration with improved error handling and add Calibre OPDS test route 2025-11-28 18:12:33 -08:00
JB 963b020c0f feat: Add normalization settings for contraction and year style options 2025-11-28 17:50:59 -08:00
JB 83e0841274 feat: Refactor settings page routing to handle form submissions and improve code structure 2025-11-28 17:27:09 -08:00
JB 726decfaf4 feat: Update form action to correctly submit settings changes 2025-11-28 16:55:11 -08:00
JB 319037fe8c feat: Add integration settings loading to settings page 2025-11-28 16:38:31 -08:00
JB c00cbaef69 feat: Enhance entity and job management with new routes and template updates 2025-11-28 16:00:16 -08:00
JB 124e5b33db feat: Update wizard routing and improve pending job handling in modal 2025-11-28 15:31:45 -08:00
JB fd93e1c9e9 feat: Add entity pronunciation preview API and enhance job management pages 2025-11-28 15:20:22 -08:00
JB d08cbcfdc9 Add voice management functionality and voice synthesis preview
- Implemented voice management routes in `voices.py` for listing, saving, and deleting speaker configurations.
- Added a test endpoint for voice synthesis preview, allowing users to test voice settings with provided text and speed.
- Introduced utility functions in `voice.py` for building voice catalogs, resolving voice settings, and synthesizing audio from normalized text.
- Enhanced speaker roster building and configuration application logic to support dynamic voice settings.
2025-11-28 14:57:23 -08:00
JB 0a2b3533f4 feat: Improve logging error handling in job processing to capture failures in stderr 2025-11-28 13:44:15 -08:00
JB ad70c630c7 feat: Enhance security by adding user permissions in Dockerfile, parameterized queries in pronunciation_store, and secure filename handling in routes 2025-11-28 13:19:53 -08:00
JB 39628453de feat: Update Audiobookshelf timeout settings to 3600 seconds for improved upload reliability 2025-11-28 13:00:58 -08:00
Deniz Şafak 4e678c7e13 Fix defaults for replace_single_newlines variable 2025-11-28 23:25:02 +03:00
JB 7cc60aacf6 feat: Add bottom navigation for OPDS modal and enhance styling for better visibility 2025-11-28 12:13:37 -08:00
Deniz ŞafakandGitHub 7ee40d6aca Add web application version of Abogen
Added information about the web application version of Abogen, including repository access and future plans for merging.
2025-11-28 17:06:23 +03:00
JB 775e05f94c feat: Enhance heading processing by stripping numeric prefixes from titles 2025-11-27 19:41:40 -08:00
Deniz Şafak 55a4f958ee Update changelog 2025-11-28 05:17:31 +03:00
Deniz Şafak 6c633e9167 v1.2.4 2025-11-28 05:16:39 +03:00
Deniz Şafak 566158c132 Reformat using black 2025-11-28 05:07:26 +03:00
Deniz Şafak bf43d1799d Update readme 2025-11-28 05:06:51 +03:00
Deniz Şafak 7293b4b826 New option: **Pre-download models and voices for offline use** 2025-11-28 04:58:13 +03:00
Deniz Şafak 34f2d712b3 Update readme 2025-11-28 00:56:44 +03:00
Deniz Şafak 5ef2612df6 Add spaCy support for improved sentence segmentation, possible fix for #91 2025-11-28 00:49:36 +03:00
JB 550fdbf537 feat: Enhance normalization settings with additional options and UI elements 2025-11-27 10:49:29 -08:00
Deniz Şafak 290c265d5e Improve cleanup_preview_threads function 2025-11-24 00:46:13 +03:00
Deniz Şafak ed418ac11d Added support for . separator in timestamps 2025-11-23 17:30:05 +03:00
Deniz Şafak e6abf2e541 Change audiobook to audio 2025-11-23 16:07:28 +03:00
Deniz Şafak f5d95547c5 Added subtitle generation support for non-English languages, improvements in code and documentation 2025-11-23 16:03:41 +03:00
Deniz Şafak 795bd8f1aa Fix WINDWOS_INSTALL.bat 2025-11-22 15:41:40 +03:00
Deniz ŞafakandGitHub c4e2a5ef0d Merge pull request #103 from denizsafak/copilot/improve-slow-code-efficiency
Optimize regex compilation and eliminate busy-wait loops
2025-11-22 15:22:15 +03:00
Deniz Şafak f57d1994cf Update CHANGELOG.md for pre-release 1.2.4: optimize regex compilation and eliminate busy-wait loops 2025-11-22 15:20:17 +03:00
Deniz ŞafakandGitHub 6482a56479 Remove unused import of QEventLoop 2025-11-22 15:14:41 +03:00
Deniz ŞafakandGitHub 8ddfd01dff Delete test_performance.py 2025-11-22 15:13:50 +03:00
Deniz ŞafakandGitHub ac551abd55 Delete PERFORMANCE_OPTIMIZATIONS.md 2025-11-22 15:13:36 +03:00
copilot-swe-agent[bot]anddenizsafak 711858ce2c Address final code review nitpicks: improve code organization
Co-authored-by: denizsafak <39929354+denizsafak@users.noreply.github.com>
2025-11-20 23:29:23 +00:00
copilot-swe-agent[bot]anddenizsafak 5cca6235e1 Fix potential division by zero in performance tests
Co-authored-by: denizsafak <39929354+denizsafak@users.noreply.github.com>
2025-11-20 23:26:36 +00:00
copilot-swe-agent[bot]anddenizsafak 115ab2a0f2 Add comprehensive performance optimization documentation
Co-authored-by: denizsafak <39929354+denizsafak@users.noreply.github.com>
2025-11-20 23:24:11 +00:00
copilot-swe-agent[bot]anddenizsafak bf5dfddee6 Improve cancellation handling and use generator expressions for memory efficiency
Co-authored-by: denizsafak <39929354+denizsafak@users.noreply.github.com>
2025-11-20 23:23:25 +00:00
copilot-swe-agent[bot]anddenizsafak 23a89a618b Address code review feedback: Fix Linux control chars and improve readability
Co-authored-by: denizsafak <39929354+denizsafak@users.noreply.github.com>
2025-11-20 23:20:51 +00:00
copilot-swe-agent[bot]anddenizsafak 4ad384dc9f Add performance tests and optimize repeated path operations
Co-authored-by: denizsafak <39929354+denizsafak@users.noreply.github.com>
2025-11-20 23:18:11 +00:00
copilot-swe-agent[bot]anddenizsafak 5ec06e7d49 Replace busy-wait loop with threading.Event for better efficiency
Co-authored-by: denizsafak <39929354+denizsafak@users.noreply.github.com>
2025-11-20 23:13:22 +00:00
copilot-swe-agent[bot]anddenizsafak 7f75aa8209 Optimize regex patterns by pre-compiling frequently used patterns
Co-authored-by: denizsafak <39929354+denizsafak@users.noreply.github.com>
2025-11-20 23:11:06 +00:00
copilot-swe-agent[bot] cd2e5c9150 Initial plan 2025-11-20 22:59:44 +00:00
Deniz Şafak f477703b1a v1.2.3 2025-11-19 02:24:42 +03:00
Deniz Şafak 6b3125b624 v1.2.2 2025-11-19 02:06:33 +03:00
Deniz Şafak bb5c4204de Reformat using black 2025-11-19 01:54:11 +03:00
Deniz Şafak 43f5589a9d Update changelog and images 2025-11-19 01:39:50 +03:00
Deniz Şafak 10d3f470e7 Add support for embedding cover images in M4B files and update documentation 2025-11-19 01:03:10 +03:00
Deniz ŞafakandGitHub c72c8e777c Update CHANGELOG 2025-11-18 15:27:09 +03:00
Deniz ŞafakandGitHub b51e094af1 Update CHANGELOG with subtitle audio generation details
Added detailed instructions for generating audio from subtitle files.
2025-11-18 15:24:44 +03:00
Deniz ŞafakandGitHub 04844997d5 Update README.md 2025-11-18 15:13:42 +03:00
Deniz ŞafakandGitHub 7835211a25 Update README.md 2025-11-18 15:07:38 +03:00
Deniz Şafak 6bb6fcb406 Add subtitle file voicing feature, improvements in code and documentation 2025-11-18 04:37:15 +03:00
Deniz ŞafakandGitHub ad8a6e520e Update WINDOWS_INSTALL.bat 2025-11-17 12:23:00 +03:00
Deniz Şafak fe22fe83b0 Possible fix for "CUDA is not available" issue, improvements in code and readme 2025-11-16 01:30:32 +03:00
JB d0532347be feat: Improve query matching in OPDS entries for enhanced search functionality 2025-11-12 08:38:06 -08:00
JB 3ef95900cc feat: Enhance OPDS client with pagination support for search and browsing by letter 2025-11-12 06:12:05 -08:00
JB 71dfbd49b6 feat: Add browsing functionality by alphabet letter in Calibre OPDS client 2025-11-11 18:44:16 -08:00
JB 166162931c feat: Implement local search functionality with alphabet filtering in OPDS feed 2025-11-11 18:09:58 -08:00
JB aba72524ce feat: Implement filtering of OPDS feed entries and preserve navigation links 2025-11-11 17:37:11 -08:00
JB 028384e6ee feat: Add stubs for soundfile and static_ffmpeg modules to facilitate testing 2025-11-11 17:00:58 -08:00
JB 504b5ab5e5 feat: Enhance entity management with filtering and preview functionality 2025-11-11 09:57:44 -08:00
JB 95f0307be1 feat: Add handling for author-series name collisions in Audiobookshelf metadata 2025-11-11 07:56:51 -08:00
JB 67453ed17c feat: Implement filtering of unsupported download formats in CalibreOPDSClient 2025-11-11 07:29:13 -08:00
JB 4491674fac feat: Enhance URL handling in CalibreOPDSClient to preserve catalog prefix for relative paths 2025-11-11 06:57:27 -08:00
JB 387d324ea0 feat: Add year pronunciation handling and roman numeral normalization to text processing 2025-11-11 05:25:29 -08:00
JB acede32559 feat: Add series sequence handling and normalization for Audiobookshelf metadata 2025-11-03 05:20:05 -08:00
JB 4e06601b04 feat: Add support for decimal number normalization in text processing 2025-11-03 05:10:02 -08:00
JB f2ab62aeab feat: Enhance Audiobookshelf metadata handling to support additional series and book number tags 2025-11-02 07:04:29 -08:00
JB 72b2ada9ac feat: Implement existing item lookup and overwrite handling for Audiobookshelf uploads 2025-11-02 06:25:14 -08:00
JB af90da0b99 feat: Add contraction handling options and UI for normalization settings 2025-11-02 05:47:20 -08:00
JB d238524cbb feat: Enhance Calibre OPDS integration to extract and handle tags, ratings, and publication dates from metadata 2025-11-01 07:27:56 -07:00
JB 2d61674e90 feat: Implement folder browsing modal for Audiobookshelf integration and enhance folder selection UI 2025-11-01 06:49:43 -07:00
JB 28652d9e78 feat: Add folder browsing functionality to Audiobookshelf integration and enhance related settings UI 2025-11-01 06:07:29 -07:00
JB 1d9dcf8800 feat: Update Audiobookshelf integration to support folder name or ID input and enhance related documentation 2025-10-31 14:41:07 -07:00
JB 5322c6406d feat: Implement Folder ID support in Audiobookshelf integration and update related documentation 2025-10-31 14:12:08 -07:00
JB 1d4316ad18 feat: Add Folder ID support for Audiobookshelf integration in settings and configuration 2025-10-31 12:49:46 -07:00
JB 8aaf183f75 feat: Update base URL handling in Audiobookshelf client for consistent API requests 2025-10-31 05:12:49 -07:00
JB eb28b01c06 feat: Refactor API path handling in Audiobookshelf client for improved endpoint management 2025-10-30 15:54:10 -07:00
JB 65ec77180d feat: Normalize Audiobookshelf base URL input and update placeholder instructions 2025-10-30 15:20:54 -07:00
JB 0906b590a6 feat: Enhance series metadata extraction from categories in OPDS feed 2025-10-30 15:15:47 -07:00
JB b9e3fb5e1c feat: Add series index and label formatting for improved entry metadata display 2025-10-30 12:54:05 -07:00
JB fe5419565d feat: Add support for series metadata and closing outro in audio conversion 2025-10-30 12:43:24 -07:00
JB 688d550f13 feat: Add outro text generation and voice resolution for audio conversion jobs 2025-10-30 12:22:19 -07:00
JB c7356338c2 feat: Implement contraction resolution using spaCy for ambiguous contractions 2025-10-30 09:36:23 -07:00
JB 13c6b120c9 feat: Add position handling in OPDSEntry and update navigation button behavior in the UI 2025-10-30 08:27:21 -07:00
JB 9acec3c309 fix: Refactor regex patterns for number ranges and fractions to improve accuracy 2025-10-30 06:54:33 -07:00
JB 013c5c2dbb feat: Enhance find books page with template options and settings integration 2025-10-30 06:35:33 -07:00
JB e7f6f0221d feat: Implement apostrophe normalization settings and overrides in the UI and backend 2025-10-30 06:25:08 -07:00
JB 963ef71647 feat: Add support for numeric ranges and simple fractions in text normalization 2025-10-30 05:34:02 -07:00
Deniz Şafak afbd5203fe Fixed [WinError 1114] A dynamic link library (DLL) initialization routine failed, Potential fix for CUDA GPU is not available 2025-10-30 12:52:46 +03:00
Deniz ŞafakandGitHub 4b175c7cf2 Update PyTorch installation instructions for version 2.8.0 2025-10-30 12:07:19 +03:00
Deniz ŞafakandGitHub 334c0654ca Modify PyTorch installation for CUDA 12.8
Updated PyTorch installation commands to use version 2.8.0 with CUDA (12.8) support.
2025-10-30 12:06:24 +03:00
Deniz ŞafakandGitHub 42b7979585 Add libxcb-cursor0 and libgl1 to Dockerfile
Mentioned in #97
2025-10-30 02:16:04 +03:00
JB b8a6ca7091 feat: Update conversion button labels and status messages for clarity 2025-10-29 10:17:15 -07:00
JB cf6ccf0171 feat: Revamp Calibre OPDS integration with improved UI, search functionality, and modal handling 2025-10-29 06:52:23 -07:00
JB eb4704bc26 feat: Enhance OPDS browser with navigation link handling and update UI for Calibre catalog 2025-10-29 06:01:21 -07:00
JB 0fc32e9f3a Add Calibre OPDS and Audiobookshelf integration features
- Implemented functions to handle Calibre OPDS settings and test connections.
- Added Audiobookshelf settings handling and test connections.
- Enhanced the UI to allow users to test connections for both integrations.
- Created new API endpoints for importing books from Calibre OPDS and testing Audiobookshelf connections.
- Updated the find_books.html template to include an OPDS browser for searching Calibre catalogs.
- Added JavaScript functionality for handling OPDS browsing and importing books.
- Updated settings.html to include fields for Audiobookshelf configuration.
- Enhanced job management to allow sending jobs to Audiobookshelf.
2025-10-29 05:15:49 -07:00
JB 67f4493d06 feat: Reset wizard state upon successful redirection 2025-10-28 04:38:45 -07:00
JB de0f17cd57 feat: Add Audiobookshelf and Calibre OPDS integration
- Implemented Audiobookshelf integration for uploading audiobooks with metadata, cover, chapters, and subtitles.
- Added configuration options for Audiobookshelf in the settings page, including base URL, API token, library ID, and upload preferences.
- Introduced Calibre OPDS integration for fetching and downloading resources from a Calibre OPDS catalog.
- Enhanced job processing to include post-completion hooks for Audiobookshelf uploads.
- Updated settings template to include new integration options and fields.
- Added utility functions for metadata normalization and chapter extraction.
- Included HTTP client functionality for both Audiobookshelf and Calibre OPDS interactions.
- Updated dependencies to include httpx for HTTP requests.
2025-10-27 17:10:48 -07:00
Deniz Şafak c994c2eeb0 v1.2.1 2025-10-28 02:48:06 +03:00
JB 6e536c6e3b feat: Implement normalize chapter opening caps feature and update related settings 2025-10-27 15:58:34 -07:00
Deniz Şafak e4fe8122ff Fix book handler showing old cache when replace_single_newlines changed 2025-10-27 18:27:07 +03:00
Deniz Şafak 4cef65e70e Fix replace single newlines preview, remove unused imports 2025-10-27 18:15:28 +03:00
Deniz Şafak 24d5588f09 Improvements in code 2025-10-27 17:50:59 +03:00
Deniz Şafak 7883b8865b Fixed light theme slider colors in voice mixer 2025-10-27 17:43:54 +03:00
Deniz Şafak 47fe6341b4 Reformat using black 2025-10-27 17:26:54 +03:00
Deniz Şafak 9d2e8bed00 Added Open processed file and Open input file options for items in the queue manager 2025-10-27 17:24:23 +03:00
Deniz Şafak eedf347866 Upgraded Abogen's interface from PyQt5 to PyQt6, Added tooltip indicators in queue manager 2025-10-27 17:08:32 +03:00
JB 9d35e39e89 feat: Add support for reading book title and authors before narration 2025-10-26 10:49:51 -07:00
JB a81ed70b14 feat: Update LLM context handling and prompt template for improved clarity and legacy support 2025-10-26 10:04:36 -07:00
JB 7951a4d992 feat: Update LLM context mode to use sentence-level context and enhance prompt for regex replacements 2025-10-26 08:42:41 -07:00
JB 10cd2c993c feat: Add environment variables for voice cache and Hugging Face directory in Docker setup 2025-10-26 08:03:40 -07:00
JB 0259963eb8 feat: Update LLM base URL configuration and enhance model selection logic 2025-10-26 07:57:18 -07:00
JB 6b5255a80d Implement LLM client and normalization settings
- Added LLMClient class for handling requests to LLM API, including methods for listing models and generating completions.
- Introduced LLMConfiguration dataclass for managing LLM configuration settings.
- Created normalization_settings module to manage normalization configurations and environment variable overrides.
- Developed JavaScript functionality for the settings interface, including model fetching and preview generation for LLM and normalization.
- Enhanced user experience with status messages and error handling in the settings UI.
2025-10-26 07:42:12 -07:00
Deniz Şafak 0d0d3b7871 Fixed subtitle word-count splitting logic for more accurate segmentation 2025-10-22 18:27:26 +03:00
Deniz Şafak f80c86b00d Added loading gif animation to book handler window 2025-10-22 01:21:55 +03:00
Deniz Şafak f5d1def330 v1.2.0 2025-10-20 03:57:16 +03:00
Deniz Şafak 4a28bfb859 Fixed “Go to folder” button not opening the chapter output directory when only separate chapters were generated 2025-10-20 03:51:27 +03:00
Deniz Şafak 4adda262c0 Fix total character count 2025-10-20 03:35:20 +03:00
Deniz Şafak 1d02802172 Add "How to fix "[WinError 1114]" error to readme 2025-10-20 03:21:27 +03:00
Deniz Şafak 28c0b2c401 Use a newer Python in Windows installer script 2025-10-20 02:29:16 +03:00
Deniz Şafak 1e89b566d1 Added a loading indicator to the book handler window 2025-10-20 01:17:14 +03:00
Deniz Şafak ccb350fa08 Normalize platformpath 2025-10-19 17:17:40 +03:00
Deniz Şafak 42336145dd Fixed taskbar icon not appearing correctly in Windows 2025-10-19 17:13:29 +03:00
Deniz Şafak 5d2da7e91c Fixed book reprocessing issue 2025-10-19 16:37:34 +03:00
Deniz Şafak e69195e6ef Update changelog, fix wrong os import 2025-10-19 16:15:32 +03:00
Deniz Şafak 1520b971ac Fixed / and \ path display by normalizing paths 2025-10-19 16:02:36 +03:00
Deniz Şafak d426bac87e Fixed folder and filename sanitization to properly handle OS-specific illegal characters 2025-10-19 15:40:10 +03:00
JB 0a6d09445d feat: Add original text preservation in chunking and export processes 2025-10-15 05:41:51 -07:00
JB 6ae8b827d2 feat: Update chapter title formatting and enhance settings layout 2025-10-14 07:37:28 -07:00
JB 6248dfdc0c feat: Enhance chapter navigation and add chapter panel functionality 2025-10-14 06:56:54 -07:00
JB 7ca030d67d feat: Add auto-prefix option for chapter titles and enhance reader functionality
- Introduced `auto_prefix_chapter_titles` setting in Job and PendingJob classes to control prefixing of chapter titles with "Chapter".
- Updated job detail and settings templates to display and configure the new option.
- Enhanced reader.js to manage playback controls, including chapter navigation and playback speed adjustments.
- Implemented a new prepare_chapters.html template for chapter selection and configuration during job preparation.
- Added tests for chapter title formatting and heading equivalence to ensure correct behavior of the new feature.
2025-10-14 06:24:15 -07:00
JB bccfd9f5c5 feat: Enhance spine href resolution and normalization in chapter handling 2025-10-13 17:33:45 -07:00
JB f7541388e6 feat: Simplify EPUB chapter href normalization and enhance spine href logging 2025-10-13 17:20:33 -07:00
JB 62d4acc4d6 feat: Improve EPUB path normalization by deduplicating segments and handling backslashes 2025-10-13 16:07:00 -07:00
JB 0922ad4727 feat: Normalize chapter hrefs by removing leading slashes and enhancing href handling 2025-10-13 15:55:11 -07:00
JB a5543528ba feat: Normalize base directory handling in EPUB path normalization 2025-10-13 15:10:55 -07:00
JB 14cc7103cf feat: Enhance chapter navigation with improved canonical URL handling and deduplication 2025-10-13 15:04:28 -07:00
JB 1f0cfef310 feat: Add dynamic loading of JSZip library with error handling for EPUB fetching 2025-10-13 14:50:03 -07:00
JB 5971630803 feat: Enhance EPUB fetching with improved error handling and status messaging 2025-10-13 14:16:28 -07:00
JB b3dc73cb15 feat: Implement EPUB asset fetching with improved error handling and status messaging 2025-10-13 13:42:13 -07:00
JB 0f549a56e4 feat: Improve EPUB loading status messaging and error handling 2025-10-13 12:58:57 -07:00
JB cab5221c46 feat: Enhance reader toolbar with improved button states and status messaging 2025-10-13 12:30:03 -07:00
JB d114ae60fe feat: Add pronunciation override handling with compilation and application logic 2025-10-13 07:29:29 -07:00
JB 3e54007baa feat: Add status messaging and override management functionality with improved UI elements 2025-10-13 06:57:09 -07:00
JB ded405ff70 feat: Enhance entity manual override handling and improve tagline styling 2025-10-13 06:22:24 -07:00
JB eb78347a85 feat: Enhance manual override functionality with status updates and improved UI elements 2025-10-12 17:36:37 -07:00
JB a2c08b1b4d feat: Add normalization for voice preview and improve voice resolution logic 2025-10-12 17:00:49 -07:00
JB 9ee53fc886 feat: Update entity preview button to use new data attributes and improve accessibility 2025-10-12 12:20:15 -07:00
JB 82d780db0d feat: Enhance entity processing and UI with new filters and manual override options 2025-10-12 11:38:06 -07:00
JB 17534d7890 feat: Add filters for entity and people summaries with minimum mention options 2025-10-12 10:01:43 -07:00
JB b4c9a1ced8 feat: Enhance wizard functionality with initial step reset and loading states 2025-10-12 08:17:56 -07:00
JB 8e9c9e6077 feat: Implement entity recognition settings and UI updates across multiple components 2025-10-12 07:29:28 -07:00
JB 7d11ebc338 refactor: Remove speaker mode handling from various components 2025-10-12 06:59:39 -07:00
JB e6d2649d5d Refactor job preparation templates and routes
- Removed `prepare_chapters.html` and `prepare_entities.html` templates as they are no longer needed.
- Updated `routes.py` to remove references to the removed templates and adjusted job preparation logic.
- Simplified response handling in job preparation routes by removing unnecessary parameters.
- Consolidated job preparation logic to improve maintainability and clarity.
2025-10-12 06:16:26 -07:00
JB 091a3785e6 fix: Normalize wizard step handling in finalize_job function 2025-10-11 18:54:05 -07:00
JB ec6dc25e23 feat: Add missing JavaScript modules for dashboard functionality 2025-10-11 18:36:04 -07:00
JB f8d2e860fe feat: Add styles for wizard card stages and forms 2025-10-11 17:07:22 -07:00
JB b8b0aea890 Add new job step templates for book, chapters, and entities
- Implemented `new_job_step_book.html` to handle manuscript and subtitle settings, including language selection, subtitle mode, and narrator defaults.
- Created `new_job_step_chapters.html` for managing detected chapters, allowing users to toggle chapters, rename them, and set voice overrides.
- Developed `new_job_step_entities.html` to configure speaker settings, manage entity pronunciations, and handle manual overrides for voice assignments.
- Enhanced user experience with dynamic forms and validation, ensuring smooth navigation through the job creation process.
2025-10-11 16:36:44 -07:00
JB 7203037a42 refactor: Remove unnecessary step indicators and warnings from upload and chapter preparation templates 2025-10-11 14:35:20 -07:00
JB 93fe48f601 feat: Implement pronunciation store with SQLite backend
- Added a new module for managing pronunciation overrides using SQLite.
- Implemented functions to load, save, search, and delete pronunciation overrides.
- Introduced schema for storing overrides and metadata.
- Added thread-safe access to the database with RLock.
- Created a utility for normalizing tokens for consistent storage and retrieval.

refactor: Overhaul entities step in the preparation wizard

- Renamed Step 3 from "Speakers" to "Entities" across all templates and routes.
- Introduced sub-navigation with tabs for "People", "Entities", and "Manual Overrides".
- Enhanced UI to display detected entities and allow manual overrides for pronunciations.
- Implemented search functionality for manual overrides with AJAX support.
- Updated frontend logic to manage tab interactions and voice selections.

docs: Add detailed plan for entities step overhaul

- Documented requirements, implementation strategies, and testing plans for the entities step.
- Outlined the integration of POS tagging and entity recognition using spaCy.
- Provided a comprehensive overview of the manual overrides workflow and data persistence strategies.
2025-10-11 14:14:19 -07:00
JB 610091c0d9 feat: Implement upload modal event dispatching and enhance wizard modal navigation 2025-10-11 12:29:52 -07:00
JB 6266819670 refactor: Update upload modal inclusion to use context blocks for better readability 2025-10-11 12:04:33 -07:00
JB 4a62363064 feat: Enhance upload modal and chapter preparation templates for improved user experience 2025-10-11 11:57:50 -07:00
JB ccf5a11222 Enhance speaker analysis and web templates
- Added gender inference improvements in speaker_analysis.py, including handling of titles and diacritics.
- Updated analyze_speakers function to include sample excerpts with context paragraphs.
- Modified routes.py to skip suppressed speakers in the speaker roster.
- Enhanced prepare.js to manage speaker samples and pronunciation previews more effectively.
- Refined prepare_chapters.html and prepare_speakers.html templates for better navigation and user experience.
- Added tests for speaker analysis to ensure proper handling of stopwords and threshold suppression.
2025-10-11 11:14:52 -07:00
JB 8ed5202ab8 Add chapter and speaker preparation templates for audiobook conversion workflow
- Created `prepare_chapters.html` to allow users to select and configure chapters for conversion, including options for voice profiles and chunk granularity.
- Developed `prepare_speakers.html` for assigning voices to detected speakers, auditioning samples, and applying saved speaker configurations.
- Implemented step indicators and navigation between chapters and speakers in the UI.
- Added error and notice handling for user feedback during the preparation process.
- Included scripts for voice catalog and language mapping to enhance user experience.
2025-10-11 09:31:49 -07:00
JB 4b0aa50da6 feat: Add 'Find Books' page and integrate links to Standard Ebooks and Project Gutenberg 2025-10-11 08:58:14 -07:00
JB 97fd8b85fc feat: Refactor reader modal implementation and enhance dashboard event handling 2025-10-11 07:13:59 -07:00
JB 5be4da1cce feat: Refactor event handling in dashboard for improved modal interactions and event resolution 2025-10-11 06:33:47 -07:00
JB 3e7bbd648c feat: Enhance job handling with download options for M4B, EPUB 3, and improved UI for speaker selection 2025-10-11 06:04:44 -07:00
JB e15d2b12a3 feat: Update Dockerfile to include mutagen dependency and enhance dashboard event handling 2025-10-11 05:41:44 -07:00
JB aed0df1b09 feat: Enhance dashboard and prepare wizard functionality with speaker step handling and UI updates 2025-10-10 19:18:26 -07:00
JB 60fe7e7ffb feat: Enhance upload form layout with flexible display and improved section handling 2025-10-10 18:48:01 -07:00
JB 20327faf0c feat: Enhance file upload experience with drag-and-drop support and improved UI for upload dropzone 2025-10-10 18:07:18 -07:00
JB be37f03109 Add number normalization and enhance UI for voice preview
- Implemented number conversion to words for grouped numbers in text normalization.
- Added configuration options for number conversion in ApostropheConfig.
- Updated the normalize_apostrophes function to include number normalization.
- Enhanced the dashboard UI with new sections for manuscript and narrator defaults.
- Improved voice preview functionality with better handling of audio playback and status updates.
- Refactored HTML templates for cleaner structure and added new fields for speaker settings.
- Updated CSS styles for improved layout and responsiveness.
- Added tests to ensure correct spelling of grouped numbers in the normalization process.
- Included num2words as a new dependency in pyproject.toml.
2025-10-10 16:12:51 -07:00
JB f35b35c7a9 feat: Enhance EPUB export by grouping chunks for rendering and adding group ID support 2025-10-10 14:43:28 -07:00
JB 283651e7dd fix: Simplify HTML rendering in chunk output by removing unnecessary line breaks 2025-10-10 12:50:48 -07:00
JB 4f46eb74f4 feat: Add original text preservation in chunk overlays and enhance whitespace handling in EPUB export 2025-10-10 12:08:22 -07:00
JB 15c1220ba3 feat: Enhance chunking logic to include display text and preserve whitespace in sentences 2025-10-10 11:17:14 -07:00
JB 443dee09b6 feat: Integrate roman numeral normalization in chapter titles and enhance related tests 2025-10-10 09:31:27 -07:00
JB 3a91e79cb6 feat: Enhance text normalization and chunking logic to preserve original whitespace and handle abbreviations 2025-10-10 08:38:00 -07:00
JB 258c3549f7 feat: Enhance reader styling and accessibility with improved layout and focus indicators 2025-10-10 07:42:13 -07:00
JB e7408a06b8 feat: Implement EPUB reader modal with navigation and audio playback features 2025-10-10 06:48:59 -07:00
JB e37ef99856 feat: Add speaker mode selection and skip logic for speakers step in the wizard 2025-10-09 14:20:36 -07:00
JB 01a6267c7a feat: Implement voice fallback logic and enhance voice resolution tests for custom mixes 2025-10-09 13:58:21 -07:00
JB f0b6976d12 feat: Enhance voice formula parsing and validation, implement voice asset caching, and add tests for new functionality 2025-10-09 13:37:36 -07:00
JB 6bd301b707 feat: Add static job logs view and enhance logging functionality with improved UI and error handling 2025-10-09 12:29:38 -07:00
JB 205c435e72 feat: Add retry functionality for jobs and update UI to support job retries 2025-10-09 11:46:38 -07:00
JB 9828730061 feat: Enhance error logging in conversion job and service with detailed traceback information 2025-10-09 11:01:31 -07:00
JB da0c453205 feat: Refactor speaker configuration and UI components, enhance custom mix functionality, and improve styling for better usability 2025-10-09 10:12:11 -07:00
JB 63f9474741 feat: Implement logging filter for successful HTTP access logs and enhance modal styling for better usability 2025-10-09 05:51:45 -07:00
JB b0875c7486 Enhance audiobook workflow UI with modal and styling updates
- Updated styles.css to introduce new modal and card styles for improved layout and responsiveness.
- Modified index.html to implement a modal for uploading files and settings, enhancing user experience.
- Refactored prepare_job.html to support a wizard-like interface for preparing jobs, including step indicators and dynamic content updates.
- Added functionality for gender selection and voice mixing in the speaker preparation section.
- Improved accessibility and usability with better hints and instructions throughout the forms.
2025-10-08 15:15:10 -07:00
JB 881717c4cb Refactor templates for audiobook preparation and settings
- Updated navigation in base.html to include a link to the queue section.
- Modified index.html to change step labels and added a jobs panel for real-time updates.
- Enhanced prepare_job.html with a wizard-style step navigation and improved speaker configuration options.
- Added new fields for speaker analysis settings and chapter options in prepare_job.html.
- Introduced a voice selection modal for better user experience in speaker selection.
- Updated settings.html to allow selection of randomizer languages for speakers.
- Cleaned up speakers.html by removing unnecessary language selection fields and ensuring consistency in speaker configuration.
2025-10-08 12:50:50 -07:00
JB d01887f31b feat: Add speaker configuration management and UI enhancements
- Introduced a new speaker configuration page with the ability to create, edit, and delete speaker presets.
- Added a step indicator to guide users through the audiobook workflow.
- Enhanced the audiobook creation process by allowing users to select speaker presets and configure individual speaker settings.
- Implemented dynamic UI elements for managing speaker rows, including adding and removing speakers.
- Updated existing templates to integrate speaker configuration features and improve user experience.
- Added JavaScript functionality for managing speaker rows and ensuring proper form handling.
- Created a new module for handling speaker configuration data storage and retrieval.
2025-10-08 11:19:52 -07:00
JB 1bae37477b feat: Enhance speaker analysis with gender inference and update related tests 2025-10-08 07:09:12 -07:00
JB 3b07df9708 feat: Add title and suffix abbreviation expansion, ensure terminal punctuation, and enhance speaker analysis functionality 2025-10-08 05:43:49 -07:00
JB b0cfd8d687 fix: Remove unnecessary newline in _reassign function 2025-10-07 18:08:28 -07:00
JB 41f56a8491 feat: Implement speaker analysis and EPUB 3 export functionality
- Added speaker analysis module to infer speaker identities from text chunks.
- Introduced SpeakerGuess and SpeakerAnalysis data classes for managing speaker data.
- Developed functions for analyzing speaker occurrences and confidence levels.
- Created EPUB 3 exporter to generate EPUB packages with synchronized narration and media overlays.
- Implemented configurable chunking options for TTS synthesis and EPUB alignment.
- Enhanced JavaScript for speaker preview functionality in the web interface.
- Added comprehensive tests for chunking and EPUB exporting features.
- Documented upgrade plan for transitioning to EPUB 3 with multi-speaker support.
2025-10-07 17:57:53 -07:00
JB bacf1b2f9e feat: Enhance chapter preselection logic and add scoring for supplemental titles 2025-10-07 15:29:33 -07:00
JB 6181f12bd4 Refactor code to remove legacy UI, transitioning to a straight webapp 2025-10-07 15:04:28 -07:00
JB da56966247 feat: Refactor supplemental section detection and enhance auto-selection logic in HandlerDialog 2025-10-07 14:51:21 -07:00
JB 783dbcf8f2 feat: Add supplemental section detection for improved content selection in HandlerDialog 2025-10-07 14:19:49 -07:00
Deniz Şafak 866746c5a1 Added Line option to subtitle generation modes 2025-10-08 00:18:39 +03:00
Deniz ŞafakandGitHub 58889d47b0 Merge pull request #94 from mleg/main
Add `Line` option for subtitle generation

Thank you, merged!
2025-10-08 00:13:40 +03:00
Deniz Şafak ae148eb591 Update readme 2025-10-07 23:56:17 +03:00
Deniz Şafak 14ed98c316 Update readme 2025-10-07 23:52:58 +03:00
JB b35ab7b002 feat: Implement dynamic state path determination and migration for queue state file 2025-10-07 13:49:53 -07:00
Deniz Şafak eacbe05934 Fixed cannot access local variable 'is_narrow' error 2025-10-07 23:42:35 +03:00
JB 4b3aa227b7 feat: Update metadata handling to include chapter count and normalize keys for ffmpeg arguments 2025-10-07 11:04:00 -07:00
JB 42334e92a4 feat: Add format specification for m4b, mp4, and m4a files in metadata embedding 2025-10-07 10:40:42 -07:00
JB a51fd25271 feat: Enhance chapter selection and metadata handling in conversion process 2025-10-07 10:23:42 -07:00
JB 02da72434b feat: Implement chapter embedding in m4b files using mutagen and update dependencies 2025-10-07 08:47:18 -07:00
JB 0dd74412d1 feat: Add chapter intro delay setting and implement in conversion process 2025-10-07 07:07:08 -07:00
JB ea1c7bd93e feat: Add ffmetadata rendering and writing functions with tests for chapter inclusion 2025-10-07 06:01:29 -07:00
JB 718a3fa1c0 feat: Enhance job management UI and add apostrophe normalization
- Updated styles for job cards to include a paused state and improved title styling.
- Modified job card template to display job status and progress more clearly, including pause/resume functionality.
- Introduced a new script for managing chapter row states in the prepare job form, allowing for dynamic enabling/disabling of inputs.
- Created a new template for preparing jobs, featuring a summary of metadata and chapter details.
- Added a comprehensive apostrophe normalization module to handle various cases of apostrophe usage in text.
2025-10-07 05:34:53 -07:00
JB 85310ad916 feat: Implement chapter overrides and metadata merging in conversion process
- Added `_coerce_truthy` function to handle truthy value coercion.
- Introduced `_apply_chapter_overrides` to apply chapter modifications based on provided overrides.
- Implemented `_merge_metadata` to combine extracted metadata with overrides, ensuring proper handling of None values.
- Updated `run_conversion_job` to utilize new chapter override and metadata merging functionalities.
- Modified `Job` class to store chapters as dictionaries for better flexibility.
- Enhanced `ConversionService` to normalize chapter input and metadata tags.
- Added comprehensive tests for chapter overrides and metadata merging to ensure functionality and correctness.
2025-10-06 18:05:12 -07:00
JB c8e9eb6fd2 feat: Update audio sink to manage ffmpeg cache by platform 2025-10-06 17:05:50 -07:00
JB 43bee0b76e feat: Enhance audio sink with internal ffmpeg cache directory management 2025-10-06 16:50:40 -07:00
JB fd8ede318f feat: Implement internal cache path management for improved resource handling 2025-10-06 16:34:01 -07:00
JB e76701ab32 feat: Add allow-direct-references setting to hatch metadata 2025-10-06 16:23:57 -07:00
JB 0d74171bb5 feat: Update en-core-web-sm dependency to use direct URL for installation 2025-10-06 16:19:28 -07:00
JB 477c5055b4 feat: Update environment variables and Docker configuration for cache management and temporary directory settings 2025-10-06 16:14:49 -07:00
JB 523e55d8a4 fix: Correct indentation for environment variables in docker-compose.yaml 2025-10-06 15:52:27 -07:00
JB c19050261c feat: Configure cache environment variables for Hugging Face and update Docker settings 2025-10-06 15:50:32 -07:00
JB dc7a115e2e feat: Update environment variables and Docker configuration for improved directory management 2025-10-06 15:26:17 -07:00
JB 153d5ba92c feat: Update environment configuration for Docker to include temporary directory settings 2025-10-06 14:42:54 -07:00
JB 26e0e764db feat: Add UID/GID configuration to .env.example and update README for container user settings 2025-10-06 14:30:55 -07:00
JB 7d132e6fcc feat: Enhance voice resolution and logging in conversion job for improved feedback and performance 2025-10-06 14:04:17 -07:00
JB b75e1c1b2e feat: Refactor queue page to use jobs panel rendering for improved performance 2025-10-06 12:55:44 -07:00
JB fc4c41c7cf feat: Implement job management features with improved UI for active and finished jobs 2025-10-06 12:41:22 -07:00
JB f3aaeda37b feat: Update voice field visibility and enhance CSS for improved layout and accessibility 2025-10-06 11:59:44 -07:00
JB 323ab08f38 feat: Enhance dashboard and styles for improved layout and accessibility 2025-10-06 11:42:15 -07:00
JB 54bc632b2e feat: Improve UI consistency and accessibility across dashboard and settings pages 2025-10-06 10:37:34 -07:00
JB 5497697741 feat: Enhance voice selection and settings UI with default voice option and improved layout 2025-10-06 09:20:33 -07:00
JB 1b907be322 feat: Adjust voice editor layout for improved alignment and spacing 2025-10-06 08:13:05 -07:00
JB e44ba1e903 feat: Enhance voice mixer UI with new preview speed control and improved layout for profile actions 2025-10-06 07:56:42 -07:00
JB 97d81d78e2 feat: Refactor environment loading to use explicit path and find_dotenv for better configuration management 2025-10-06 07:27:04 -07:00
JB 01209f6878 feat: Add gender filter to voice mixer and enhance UI for better user experience
feat: Implement dynamic settings page with form handling and default values
feat: Create a queue page to display ongoing jobs with auto-refresh
feat: Revamp dashboard with live text preview and character/word count
fix: Update navigation links in base template for active state indication
2025-10-06 06:59:16 -07:00
JB 2e402f6b5b feat: Enhance voice mixer functionality with language filtering and improved directory management 2025-10-06 06:00:17 -07:00
JB 0c47067cb8 feat: Revamp voice mixer UI with new layout and enhanced voice management features 2025-10-06 05:32:12 -07:00
JB b718dae1b3 feat: Implement voice mixer UI and functionality
- Added new styles for the voice mixer components in styles.css.
- Updated base.html to include a block for scripts.
- Refactored voices.html to create a structured voice mixer interface with profile management features.
- Introduced voices.js to handle voice mixer logic, including profile creation, editing, and previewing.
- Implemented actions for importing and exporting voice profiles.
- Enhanced user experience with loading states and status messages.
2025-10-06 05:10:32 -07:00
JB 9ba2362528 fix: Correct link to voice mixer in index.html 2025-10-05 16:24:00 -07:00
JB 1629d3e80c feat: Update application to use port 8808 instead of 8000 in README, Dockerfile, app.py, and docker-compose.yaml 2025-10-05 16:18:05 -07:00
JB 66a0679e18 feat: Update Docker configuration for GPU support and remove deprecated compose file 2025-10-05 16:05:16 -07:00
JB 338ff104e8 feat: Implement conversion service with job management and logging
- Added `ConversionService` class to handle job queuing, processing, and cancellation.
- Introduced `Job`, `JobLog`, and `JobResult` data classes to manage job details and results.
- Implemented job status tracking with enums for better state management.
- Created a web interface with HTML templates for job submission and monitoring.
- Developed CSS styles for a modern UI layout and responsive design.
- Added functionality for voice profile management in the voice mixer.
- Implemented a Docker Compose configuration for GPU support.
- Wrote unit tests for the conversion service to ensure job processing works as expected.
2025-10-05 15:53:33 -07:00
Маслов Олег e7fb89f137 Add Line option for subtitle generation 2025-10-05 16:00:49 +03:00
Deniz Şafak 033c809b95 Update changelog 2025-09-18 03:12:35 +03:00
Deniz Şafak f492cc2506 v1.1.9 2025-09-18 03:11:52 +03:00
Deniz Şafak 495a50f666 Merge branch 'main' of https://github.com/denizsafak/abogen 2025-09-18 03:09:04 +03:00
Deniz Şafak 8c6e292f4f Fixed markdown TOC generation 2025-09-18 03:08:30 +03:00
Deniz Şafak 1d8cb8eabd Fixed the issue where spaces were deleted before punctuation marks 2025-09-18 02:37:58 +03:00
Deniz ŞafakandGitHub a3ee95e83f Update readme 2025-09-18 01:50:28 +03:00
Deniz ŞafakandGitHub a98f75c1a9 Update readme 2025-09-17 23:07:36 +03:00
Deniz Şafak f4f3a4c207 Update readme 2025-09-17 22:39:45 +03:00
Deniz Şafak bcd76de0c2 Update changelog 2025-09-17 22:15:43 +03:00
Deniz Şafak 93c556a063 v1.1.8 2025-09-17 22:14:51 +03:00
Deniz Şafak 343ac29759 Update readme 2025-09-17 22:08:26 +03:00
Deniz ŞafakandGitHub ce61b2e312 Merge pull request #83 from betsegaw/main
Update readme to include instructions to enable audio when using the browser view in docker
2025-09-17 22:04:21 +03:00
Deniz Şafak 3d90bf94ac Update readme 2025-09-17 22:03:09 +03:00
Deniz Şafak de8cdfa278 Update readme and changelog 2025-09-17 22:01:14 +03:00
Deniz Şafak 9e9561988d Fixed subtitle splitting before commas 2025-09-17 20:14:26 +03:00
Deniz Şafak a074b8b0ad Fixed ordered list numbers not being included in EPUB content conversion 2025-09-17 19:47:52 +03:00
Deniz Şafak a2dc28f4d4 Fixed save options not working correctly, better indicators 2025-09-17 19:10:47 +03:00
Deniz Şafak 3b5c2ceb8f Update conversion.py 2025-09-17 05:50:58 +03:00
Deniz Şafak 52a562039e Potentially fixed subtitle generation stucks at 9:59:59 2025-09-17 05:50:07 +03:00
Deniz Şafak 6291f89ae9 Added new option Configure silence between chapters 2025-09-17 04:36:48 +03:00
Deniz Şafak be36796d5d Update readme 2025-09-17 03:39:19 +03:00
Deniz Şafak 219e2fc6ae Improved the markdown logic 2025-09-17 03:32:13 +03:00
Deniz ŞafakandGitHub 196cd93008 Merge pull request #75 from brianxiadong/feat-markdown
feat: Add Markdown File Support for Audiobook Generation
2025-09-17 03:30:08 +03:00
Betsegaw (Beta) TadeleandGitHub 4230db4c40 Update README.md 2025-09-16 00:16:17 -07:00
Deniz Şafak 6834d223c9 Fixed No Qt platform plugin could be initialized error 2025-09-14 21:48:08 +03:00
Xia Dong 67dbb0a8fb feat: Add Markdown File Support for Audiobook Generation 2025-09-02 15:14:13 +08:00
Deniz Şafak 023884d6d8 1.1.7 2025-08-25 03:09:37 +03:00
Deniz Şafak aa98547456 Fixed sleep inhibition error 2025-08-25 02:19:05 +03:00
Deniz ŞafakandGitHub 528343f375 Fix demo video not playing in Firefox 2025-08-25 02:07:09 +03:00
Deniz Şafak 7fa8b23c4e Update demo video guide 2025-08-25 02:04:55 +03:00
Deniz Şafak 3171066f0c Improve karaoke mode and update changelog 2025-08-25 01:34:16 +03:00
Deniz ŞafakandGitHub c1c93ea00a Merge pull request #69 from denizsafak/test
Add word-by-word karaoke highlighting option
2025-08-25 01:07:07 +03:00
Deniz ŞafakandGitHub 795dee84ad Merge pull request #65 from robmckinnon/karaoke
Add word-by-word karaoke highlighting option
2025-08-25 00:50:57 +03:00
Deniz ŞafakandGitHub eb4575df23 Merge pull request #68 from denizsafak/apple
Add MPS GPU acceleratin on Silicon Mac
2025-08-25 00:49:00 +03:00
robmckinnon f58b254aed Add word-by-word karaoke highlighting option
Add option label: Sentence + Highlighting
2025-08-18 22:02:24 +01:00
Deniz ŞafakandGitHub e073c32e64 Update WINDOWS_INSTALL.bat
Fix wrong variable
2025-08-13 16:14:12 +03:00
Deniz ŞafakandGitHub 509477bafa Update test_publish.yml 2025-08-12 16:49:56 +03:00
Deniz ŞafakandGitHub 867d373eb9 Update test_publish.yml 2025-08-12 16:37:48 +03:00
Deniz ŞafakandGitHub f727c5ca70 Update test_publish.yml 2025-08-12 11:51:33 +03:00
Deniz Şafak e48df7c106 Test MPS GPU acceleratin on Silicon Mac 2025-08-07 05:22:43 +03:00
Deniz Şafak 395cd75038 v1.1.6 2025-07-30 02:33:52 +03:00
Deniz Şafak e60a1f7e42 Reformat using black 2025-07-30 02:31:49 +03:00
Deniz Şafak 1fe5a73c98 Improved EPUB chapter detection 2025-07-30 02:25:06 +03:00
Deniz Şafak 37db948d2f Include Mandarin Chinese (misaki[zh]) by default 2025-07-29 17:14:59 +03:00
Deniz Şafak 63679405a9 Ask user for CUDA installation in Windows installer script 2025-07-29 16:56:39 +03:00
Deniz Şafak 156fa75c76 Fixed SRT subtitle numbering issue in #41 2025-07-29 15:47:45 +03:00
175 changed files with 60081 additions and 9212 deletions
+44
View File
@@ -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.
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [jborza, jeremiahsb, mohangk]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+7 -5
View File
@@ -5,7 +5,7 @@ on:
#release:
# types: [published]
# Build only
#push:
#push: it
# branches: [main]
# TODO - enable build on pull requests if build times can be reduced
# pull_request:
@@ -22,7 +22,7 @@ jobs:
- name: Login to Github Container Registry
# Only if we need to push an image
if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
# if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
@@ -51,11 +51,13 @@ jobs:
context: ./abogen
file: ./abogen/Dockerfile
# platforms: linux/amd64,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x
platforms: linux/amd64,linux/arm64
# platforms: linux/amd64,linux/arm64
platforms: linux/amd64 # using the solution mentioned in https://github.com/denizsafak/abogen/issues/46
# Only push if we are publishing a release
push: ${{ github.event_name == 'release' && github.event.action == 'published' }}
# push: ${{ github.event_name == 'release' && github.event.action == 'published' }}
push: true
# Use a 'temp' tag, that won't be pushed, for non-release builds
tags: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name || 'temp' }}
tags: ${{ env.IMAGE_REPOSITORY }}:${{ github.event.release.tag_name || 'latest' }}
# Use a cache to reduce build times
cache-to: type=gha,mode=max
cache-from: type=gha
+6
View File
@@ -19,6 +19,7 @@ __pycache__/
env/
venv/
.env/
.env
.venv/
test/
@@ -30,6 +31,11 @@ python_embedded/
# abogen
*config.json
config/
storage/
build/
dist/
.old/
test_assets/
dev_notes/
.claude/
+1
View File
@@ -0,0 +1 @@
3.12
+101 -2
View File
@@ -1,3 +1,102 @@
# 1.3.0
- Special thanks to [@jeremiahsb](https://github.com/jeremiahsb) for his [massive contribution](https://github.com/denizsafak/abogen/pull/120) (>55k lines!) that brought the Web UI, EPUB 3 pipeline, and core architectural improvements to life.
- 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.
- Fixed the `No module named pip` error that occurred for users who installed Abogen via the [**uv**](https://github.com/astral-sh/uv) installer.
- Fixed defaults for `replace_single_newlines` not being applied correctly in some cases.
- Fixed `Save chapters separately for queued epubs is ignored`, issue mentioned by @dymas-cz in #109.
- Fixed incorrect sentence segmentation when using spaCy, where text would erroneously split after opening parentheses.
- Improvements in code and documentation.
# 1.2.4
- **Subtitle generation is now available for all languages!** Abogen now supports subtitle generation for non-English languages using audio duration-based timing. Available modes include `Line`, `Sentence`, and `Sentence + Comma`. (Note: Word-level subtitle modes remain English-only due to Kokoro's timestamp token limitations.)
- New option: **"Use spaCy for sentence segmentation"** You can now use [spaCy](https://spacy.io/) to automatically detect sentence boundaries and produce cleaner, more readable subtitles. Quick summary:
- **What it does:** Splits text into natural sentences so subtitle entries read better and align more naturally with speech.
- **Why this helps:** The previous punctuation-based splitting could break sentences incorrectly at common abbreviations (e.g. "Mr.", "Dr.", "Prof.") or initials, producing wrong subtitle breaks. spaCy avoids those false splits by using linguistic rules to detect real sentence boundaries.
- **For Non-English:** spaCy runs **before** audio generation to create better sentence chunks for TTS.
- **For English:** spaCy runs **during** subtitle generation to find accurate sentence breaks after TTS.
- **Note:** spaCy segmentation is only applied when subtitle mode is `Sentence` or `Sentence + Comma`. When turned off, it falls back to simple punctuation-based splitting.
- New option: **Pre-download models and voices for offline use** You can now pre-download all required Kokoro models, voices, and spaCy language models using this option in the settings menu. Allowing you to use Abogen completely offline without any internet connection.
- Added support for `.` separator in timestamps (e.g. `HH:MM:SS.ms`) for timestamp-based text files.
- Optimized regex compilation and eliminated busy-wait loops.
- Possibly fixed `Silent truncation of long paragraphs` issue mentioned in [#91](https://github.com/denizsafak/abogen/issues/91) by [@xklzlxr](https://github.com/xklzlxr)
- Fixed unused regex patterns and variable naming conventions.
- Improvements in code and documentation.
# 1.2.3
- Same as 1.2.2, re-released to fix an issue with subtitle timing when using timestamp-based text files.
# 1.2.2
- **You can now voice your subtitle files!** Simply add `.srt`, `.ass` or `.vtt` files to generate timed audio. Alternatively, add a text file with timestamps in `HH:MM:SS` or `HH:MM:SS,ms` format to generate audio that matches the timestamps. See [here](https://github.com/denizsafak/abogen?tab=readme-ov-file#about-timestamp-based-text-files) for detailed instructions.
- New option: **"Use silent gaps between subtitles"**: Prevents unnecessary audio speed-up by letting speech continue into the silent gaps between subtitles.
- New option: **"Subtitle speed adjustment method"**: Choose how to speed up audio when needed:
- **TTS Regeneration (better quality):** Re-generates the audio at a faster speed for more natural sound.
- **FFmpeg Time-stretch (better speed):** Quickly speeds up the generated audio.
- Added support for embedding cover images in M4B files. Abogen now automatically extracts cover images from EPUB and PDF files. You can also manually specify a cover image using the `<<METADATA_COVER_PATH:path>>` tag in your text file. (To prevent MPV from showing the cover image, you can add `audio-display=no` to your MPV config file.)
- Fixed `[WinError 1114] A dynamic link library (DLL) initialization routine failed` error on Windows, pre-loading PyTorch DLLs before initializing PyQt6 to avoid DLL initialization errors, mentioned in #98 by @ephr0n.
- Potential fix for `CUDA GPU is not available` issue, by ensuring PyTorch is installed correctly with CUDA support on Windows using the installer script.
- Improvements in code and documentation.
# 1.2.1
- Upgraded Abogen's interface from PyQt5 to PyQt6 for better compatibility and long-term support.
- Added tooltip indicators in queue manager to display book handler options (`Save chapters separately` and `Merge chapters at the end`) for queued items.
- Added `Open processed file` and `Open input file` options for items in the queue manager, instead of just `Open file` option.
- Added loading gif animation to book handler window.
- Fixed light theme slider colors in voice mixer for better visibility (for non-Windows users).
- Fixed subtitle word-count splitting logic for more accurate segmentation.
- Improvements in code and documentation.
# 1.2.0
- Added `Line` option to subtitle generation modes, allowing subtitles to be generated based on line breaks in the text, by @mleg in #94.
- Added a loading indicator to the book handler window for better user experience during book preprocessing.
- Fixed `cannot access local variable 'is_narrow'` error when subtitle format `SRT` was selected, mentioned by @Kinasa0096 in #88.
- Fixed folder and filename sanitization to properly handle OS-specific illegal characters (Windows, Linux, macOS), ensuring compatibility across all platforms when creating chapter folders and files.
- 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.
- Improvements in code and documentation.
# 1.1.9
- Fixed the issue where spaces were deleted before punctuation marks while generating subtitles.
- Fixed markdown TOC generation breaks when "Replace single newlines" is enabled.
- Improvements in code and documentation.
# 1.1.8
- Added `.md` (Markdown) file extension support by @brianxiadong in PR #75
- Added new option `Configure silence between chapters` that lets you configure the silence between chapters, mentioned by @lfperez1982 in #79
- Better indicators and options while displaying and managing the input and processing files.
- Improved the markdown logic to better handle various markdown structures and cases.
- Fixed subtitle splitting before commas by combining punctuation with preceding words.
- Fixed save options not working correctly in queue mode, mentioned by @jborza in #78
- Fixed `No Qt platform plugin could be initialized` error, mentioned by @sunrainxyz in #59
- Fixed ordered list numbers not being included in EPUB content conversion. The numbers are now properly included in the converted content, mentioned by @jefro108 in #47
- Potentially fixed subtitle generation stucks at 9:59:59, mentioned by @bolaykim in #73
- Improvements in code and documentation.
# 1.1.7
- Added MPS GPU acceleration support for Silicon Mac, mentioned in https://github.com/denizsafak/abogen/issues/32#issuecomment-3155902040 by @jefro108. **Please read the [Mac](https://github.com/denizsafak/abogen?tab=readme-ov-file#mac) section in the documentation again, as it requires additional configuration.**
- Added word-by-word karaoke highlighting feature by @robmckinnon in PR #65
- Fixed sleep inhibition error occurring on some Linux systems that do not use systemd, mentioned in #67 by @hendrack
- Improvements in code and documentation.
# 1.1.6
- Improved EPUB chapter detection: Now reliably detects chapters from NAV HTML (TOC) files, even in non-standard EPUBs, fixes the issue mentioned by @jefro108 in #33
- Fixed SRT subtitle numbering issue, mentioned by @page-muncher in #41
- Fixed missing chapter contents issue in some EPUB files.
- Windows installer script now prompts the user to install the CUDA version of PyTorch even if no NVIDIA GPU is detected.
- Abogen now includes Mandarin Chinese (misaki[zh]) by default; manual installation is no longer required.
# 1.1.5
- Changed the temporary directory path to user's cache directory, which is more appropriate for storing cache files and avoids issues with unintended cleanup.
- Fixed the isssue where extra metadata information was not being saved to M4B files when they have no chapters, ensuring that all metadata is correctly written to the output file.
@@ -23,7 +122,7 @@
- Added a new option: `Reset to default settings`, allowing users to reset all settings to their default values.
- Added a new option: `Disable Kokoro's internet access`. This lets you prevent Kokoro from downloading models or voices from HuggingFace Hub, which can help avoid long waiting times if your computer is offline.
- HuggingFace Hub telemetry is now disabled by default for improved privacy. (HuggingFace Hub is used by Kokoro to download its models)
- Potential fix for #37 and #38, where the program was becoming slow while processing large files.
- cPotential fix for #37 and #38, where the program was becoming slow while processing large files.
- Fixed `Open folder` and `Open file` buttons in the queue manager GUI.
- Improvements in code structure.
@@ -94,7 +193,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.
+475 -73
View File
@@ -4,24 +4,28 @@
[![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 powerful text-to-speech conversion tool that makes it easy to turn ePub, PDF, or text files into high-quality audio with matching subtitles in seconds. Use it for audiobooks, voiceovers for Instagram, YouTube, TikTok, or any project that needs natural-sounding text-to-speech, using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M).
<a href="https://trendshift.io/repositories/14433" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14433" alt="denizsafak%2Fabogen | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
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).
<img title="Abogen Main" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen.png' width="380"> <img title="Abogen Processing" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen2.png' width="380">
## Demo
https://github.com/user-attachments/assets/cb66512d-0a52-48c3-bda4-f1e6a03fb8d6
> 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).
https://github.com/user-attachments/assets/094ba3df-7d66-494a-bc31-0e4b41d0b865
> This demo was generated in just 5 seconds, producing 1 minute of audio with perfectly synced subtitles. To create a similar video, see [the demo guide](https://github.com/denizsafak/abogen/tree/main/demo).
## `How to install?` <a href="https://pypi.org/project/abogen/" target="_blank"><img src="https://img.shields.io/pypi/pyversions/abogen" alt="Abogen Compatible PyPi Python Versions" align="right" style="margin-top:6px;"></a>
### Windows
### `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
#### <b>OPTION 1: Install using script</b>
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
@@ -31,7 +35,26 @@ This method handles everything automatically - installing all dependencies inclu
> [!NOTE]
> You don't need to install Python separately. The script will install Python automatically.
#### OPTION 2: Install using pip
#### <b>OPTION 2: Install using uv</b>
First, [install uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already.
```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
```
<details>
<summary><b>Alternative: Install using pip (click to expand)</b></summary>
```bash
# Create a virtual environment (optional)
mkdir abogen && cd abogen
@@ -39,7 +62,8 @@ python -m venv venv
venv\Scripts\activate
# For NVIDIA GPUs:
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
# 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.
@@ -48,7 +72,26 @@ pip install torch torchvision torchaudio --index-url https://download.pytorch.or
pip install abogen
```
### Mac
</details>
### `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"
```
<details>
<summary><b>Alternative: Install using pip (click to expand)</b></summary>
```bash
# Install espeak-ng
brew install espeak-ng
@@ -60,8 +103,34 @@ 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
</details>
### `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
```
<details>
<summary><b>Alternative: Install using pip (click to expand)</b></summary>
```bash
# Install espeak-ng
sudo apt install espeak-ng # Ubuntu/Debian
@@ -84,28 +153,48 @@ pip3 install abogen
pip3 uninstall torch
pip3 install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm6.4
```
> [!TIP]
> If you get `WARNING: The script abogen-cli is installed in '/home/username/.local/bin' which is not on PATH.` error, run the following command to add it to your PATH:
>```bash
>echo "export PATH=\"/home/$USER/.local/bin:\$PATH\"" >> ~/.bashrc && source ~/.bashrc
>```
</details>
> [!TIP]
> If you get "No matching distribution found" error, try installing it on supported Python (3.10 to 3.12). You can use [pyenv](https://github.com/pyenv/pyenv) to manage multiple Python versions easily in Linux. Watch this [video](https://www.youtube.com/watch?v=MVyb-nI4KyI) by NetworkChuck for a quick guide.
> 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.
## Interfaces
Abogen offers **two interfaces**, but currently they have different feature sets. The **Web UI** contains newer features that are still being integrated into the desktop application.
| Command | Interface | Features |
|---------|-----------|----------|
| `abogen` | PyQt6 Desktop GUI | Stable core features |
| `abogen-web` | Flask Web UI | Core features + **Supertonic TTS**, **LLM Normalization**, **Audiobookshelf Integration** and more! |
> **Note:** The Web UI is under active development. We are working to integrate these new features into the PyQt desktop app. until then, the Web UI provides the most feature-rich experience.
> Special thanks to [@jeremiahsb](https://github.com/jeremiahsb) for making this possible! I was honestly surprised by his [massive contribution](https://github.com/denizsafak/abogen/pull/120) (>55,000 lines!) that brought the entire Web UI to life.
# 🖥️ Desktop Application (PyQt)
## `How to run?`
If you installed using pip, you can simply run the following command to start Abogen:
You can simply run this command to start Abogen Desktop GUI:
```bash
abogen
```
> [!TIP]
> If you installed 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.
> 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, or text file (or use the built-in text editor)
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)
@@ -117,27 +206,33 @@ abogen
## `In action`
<img title="Abogen in action" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen.gif'>
Heres 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.
Heres 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`, or `.TXT` files (or use built-in text editor) |
| **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`, `Sentence`, `Sentence + Comma`, `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)` (Special thanks to [@jborza](https://github.com/jborza) for chapter support in PR [#10](https://github.com/denizsafak/abogen/pull/10)) |
| **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 `chapters + pages` from PDFs. |
| **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. |
@@ -146,40 +241,217 @@ Heres Abogen in action: in this demo, it processes 3,000 characters of tex
|---------|-------------|
| **Theme** | Change the application's theme using `System`, `Light`, or `Dark` options. |
| **Configure max words per subtitle** | Configures the maximum number of words per subtitle entry. |
| **Configure silence between chapters** | Configures the duration of silence between chapters (in seconds). |
| **Configure max lines in log window** | Configures the maximum number of lines to display in the log window. |
| **Separate chapters audio format** | Configures the audio format for separate chapters as `wav`, `flac`, `mp3`, or `opus`. |
| **Create desktop shortcut** | Creates a shortcut on your desktop for easy access. |
| **Open config directory** | Opens the directory where the configuration file is stored. |
| **Open cache directory** | Opens the cache directory where converted text files are stored. |
| **Clear cache files** | Deletes cache files created during the conversion or preview. |
| **Check for updates at startup** | Automatically checks for updates when the program starts. |
| **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`
<img title="Abogen Voice Mixer" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/voice_mixer.png'>
With voice mixer, you can create custom voices by mixing different voice models. You can adjust the weight of each voice and save your custom voice as a profile for future use. The voice mixer allows you to create unique and personalized voices. (Huge thanks to [@jborza](https://github.com/jborza) for making this possible through his contributions in [#5](https://github.com/denizsafak/abogen/pull/5))
With voice mixer, you can create custom voices by mixing different voice models. You can adjust the weight of each voice and save your custom voice as a profile for future use. The voice mixer allows you to create unique and personalized voices.
> Special thanks to [@jborza](https://github.com/jborza) for making this possible through his contributions in [#5](https://github.com/denizsafak/abogen/pull/5)
## `Queue Mode`
<img title="Abogen queue mode" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/queue.png'>
Abogen supports **queue mode**, allowing you to add multiple files to a processing queue. This is useful if you want to convert several files in one batch.
- You can add text files (`.txt`) directly using the **Add files** button in the Queue Manager. To add PDF or EPUB files, use the input box in the main window and click the **Add to Queue** button.
- 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)
---
# 🌐 Web Application (WebUI)
## `How to run?`
Run this command to start the Web UI:
```bash
abogen-web
```
Then open http://localhost:8808 and drag in your documents. Jobs run in the background worker and the browser updates automatically.
<img title="Abogen in action" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen-webui.png'>
## `Using the web UI`
1. Upload a document (drag & drop or use the upload button).
2. Choose voice, language, speed, subtitle style, and output format.
3. Click **Create job**. The job immediately appears in the queue.
4. Watch progress and logs update live. Download audio/subtitle assets when complete.
5. Cancel or delete jobs any time. Download logs for troubleshooting.
Multiple jobs can run sequentially; the worker processes them in order.
## `Container image`
You can build a lightweight container image directly from the repository root:
```bash
docker build -t abogen .
mkdir -p ~/abogen-data/uploads ~/abogen-data/outputs
docker run --rm \
-p 8808:8808 \
-v ~/abogen-data:/data \
--name abogen \
abogen
```
Browse to http://localhost:8808. Uploaded source files are stored in `/data/uploads` and rendered audio/subtitles appear in `/data/outputs`.
### Container environment variables
| Variable | Default | Purpose |
|----------|---------|---------|
| `ABOGEN_HOST` | `0.0.0.0` | Bind address for the Flask server |
| `ABOGEN_PORT` | `8808` | HTTP port |
| `ABOGEN_DEBUG` | `false` | Enable Flask debug mode |
| `ABOGEN_UPLOAD_ROOT` | `/data/uploads` | Directory where uploaded files are stored |
| `ABOGEN_OUTPUT_ROOT` | `/data/outputs` | Directory for generated audio and subtitles (legacy alias of `ABOGEN_OUTPUT_DIR`) |
| `ABOGEN_OUTPUT_DIR` | `/data/outputs` | Container path for rendered audio/subtitles |
| `ABOGEN_SETTINGS_DIR` | `/config` | Container path for JSON settings/configuration |
| `ABOGEN_TEMP_DIR` | `/data/cache` (Docker) or platform cache dir | Container path for temporary audio working files |
| `ABOGEN_UID` | `1000` | UID that the container should run as (matches host user) |
| `ABOGEN_GID` | `1000` | GID that the container should run as (matches host group) |
| `ABOGEN_LLM_BASE_URL` | `""` | OpenAI-compatible endpoint used to seed the Settings → LLM panel |
| `ABOGEN_LLM_API_KEY` | `""` | API key passed to the endpoint above |
| `ABOGEN_LLM_MODEL` | `""` | Default model selected when you refresh the model list |
| `ABOGEN_LLM_TIMEOUT` | `30` | Timeout (seconds) for server-side LLM requests |
| `ABOGEN_LLM_CONTEXT_MODE` | `sentence` | Default prompt context window (`sentence`, `paragraph`, `document`) |
| `ABOGEN_LLM_PROMPT` | `""` | Custom normalization prompt template seeded into the UI |
Set any of these with `-e VAR=value` when starting the container.
To discover your local UID/GID for matching file permissions inside the container, run:
```bash
id -u
id -g
```
Use those values to populate `ABOGEN_UID` / `ABOGEN_GID` in your `.env` file.
When running via Docker Compose, set `ABOGEN_SETTINGS_DIR`,
`ABOGEN_OUTPUT_DIR`, and `ABOGEN_TEMP_DIR` in your `.env` file to the host
directories you want mounted into the container. Compose maps them to
`/config`, `/data/outputs`, and `/data/cache` respectively while exporting
those in-container paths to the application. Non-audio caches (e.g., Hugging
Face downloads) stick to the container's internal cache under `/tmp/abogen-home/.cache`
by default, so only conversion scratch data touches the mounted `ABOGEN_TEMP_DIR`.
Ensure each host directory exists and is writable by the UID/GID you configure
before starting the stack.
### Docker Compose (GPU by default)
The repo includes `docker-compose.yaml`, which targets GPU hosts out of the box. Install the NVIDIA Container Toolkit and run:
```bash
docker compose up -d --build
```
Key build/runtime knobs:
- `TORCH_VERSION` pin a specific PyTorch release that matches your driver (leave blank for the latest on the configured index).
- `TORCH_INDEX_URL` swap out the PyTorch download index when targeting a different CUDA build.
- `ABOGEN_DATA` host path that stores uploads/outputs (defaults to `./data`).
CPU-only deployment: comment out the `deploy.resources.reservations.devices` block (and the optional `runtime: nvidia` line) inside the compose file. Compose will then run without requesting a GPU. If you prefer the classic CLI:
```bash
docker build -f abogen/Dockerfile -t abogen-gpu .
docker run --rm \
--gpus all \
-p 8808:8808 \
-v ~/abogen-data:/data \
abogen-gpu
```
## `LLM-assisted text normalization`
Abogen can hand tricky apostrophes and contractions to an OpenAI-compatible large language model. Configure it from **Settings → LLM**:
1. Enter the base URL for your endpoint (Ollama, OpenAI proxy, etc.) and an API key if required. Use the server root (for Ollama: `http://localhost:11434`)—Abogen appends `/v1/...` automatically, but it also accepts inputs that already end in `/v1`.
2. Click **Refresh models** to load the catalog, pick a default model, and adjust the timeout or prompt template.
3. Use the preview box to test the prompt, then save the settings. The Normalization panel can synthesize a short audio preview with the current configuration.
When you are running inside Docker or a CI pipeline, seed the form automatically with `ABOGEN_LLM_*` variables in your `.env` file. The `.env.example` file includes sample values for a local Ollama server.
## `Audiobookshelf integration`
Abogen can push finished audiobooks directly into Audiobookshelf. Configure this under **Settings → Integrations → Audiobookshelf** by providing:
- **Base URL** the HTTPS origin (and optional path prefix) where your Audiobookshelf server is reachable, for example `https://abs.example.com` or `https://media.example.com/abs`. Do **not** append `/api`.
- **Library ID** the identifier of the target Audiobookshelf library (copy it from the librarys 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 Abogens “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 Abogens settings (it verifies the library and resolves the folder), and use the “Send to Audiobookshelf” button on completed jobs.
## `JSON endpoints`
Need machine-readable status updates? The dashboard calls a small set of helper endpoints you can reuse:
- `GET /api/jobs/<id>` returns job metadata, progress, and log lines in JSON.
- `GET /partials/jobs` renders the live job list as HTML (htmx uses this for polling).
- `GET /partials/jobs/<id>/logs` renders just the log window.
More automation hooks are planned; contributions are very welcome if you need additional routes.
---
# Core Features (Available in Both)
## `About Chapter Markers`
When you process ePUB or PDF 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:
When you process ePUB, PDF or markdown files, Abogen converts them into text files stored in your cache directory. When you click "Edit," you're actually modifying these converted text files. In these text files, you'll notice tags that look like this:
```
<<CHAPTER_MARKER:Chapter Title>>
```
These are chapter markers. They are automatically added when you process ePUB or PDF files, based on the chapters you select. They serve an important purpose:
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
@@ -197,7 +469,7 @@ When you process the text file, Abogen will detect these markers automatically a
![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 or PDF files, but you can also add them manually to your text files. Add metadata tags **at the beginning of your text file** like this:
Similar to chapter markers, it is possible to add metadata tags for `M4B` files. This is useful for audiobook players that support metadata, allowing you to add information like title, author, year, etc. Abogen automatically adds these tags when you process ePUB, PDF or markdown files, but you can also add them manually to your text files. Add metadata tags **at the beginning of your text file** like this:
```
<<METADATA_TITLE:Title>>
<<METADATA_ARTIST:Author>>
@@ -206,7 +478,30 @@ Similar to chapter markers, it is possible to add metadata tags for `M4B` files.
<<METADATA_ALBUM_ARTIST:Album Artist>>
<<METADATA_COMPOSER:Narrator>>
<<METADATA_GENRE:Audiobook>>
<<METADATA_COVER_PATH:path/to/cover.jpg>>
```
> 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.
```
**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
## `Supported Languages`
```
@@ -221,12 +516,18 @@ Similar to chapter markers, it is possible to add metadata tags for `M4B` files.
```
For a complete list of supported languages and voices, refer to Kokoro's [VOICES.md](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md). To listen to sample audio outputs, see [SAMPLES.md](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/SAMPLES.md).
> See [How to fix Japanese audio not working?](#japanese-audio-not-working)
---
# Guides & Troubleshooting
## `MPV Config`
I highly recommend using [MPV](https://mpv.io/installation/) to play your audio files, as it supports displaying subtitles even without a video track. Here's my `mpv.conf`:
```
# --- MPV Settings ---
save-position-on-quit
keep-open=yes
audio-display=no
# --- Subtitle ---
sub-ass-override=no
sub-margin-y=50
@@ -238,42 +539,6 @@ audio-samplerate=48000
volume-max=200
```
## `Docker Guide`
If you want to run Abogen in a Docker container:
1) [Download the repository](https://github.com/denizsafak/abogen/archive/refs/heads/main.zip) and extract, or clone it using git.
2) Go to `abogen` folder. You should see `Dockerfile` there.
3) Open your termminal in that directory and run the following commands:
```bash
# Build the Docker image:
docker build --progress plain -t abogen .
# Note that building the image may take a while.
# After building is complete, run the Docker container:
# Windows
docker run --name abogen -v %cd%:/shared -p 5800:5800 -p 5900:5900 --gpus all abogen
# Linux
docker run --name abogen -v $(pwd):/shared -p 5800:5800 -p 5900:5900 --gpus all abogen
# MacOS
docker run --name abogen -v $(pwd):/shared -p 5800:5800 -p 5900:5900 abogen
# We expose port 5800 for use by a web browser, 5900 if you want to connect with a VNC client.
```
Abogen launches automatically inside the container.
- You can access it via a web browser at [http://localhost:5800](http://localhost:5800) or connect to it using a VNC client at `localhost:5900`.
- You can use `/shared` directory to share files between your host and the container.
- For later use, start it with `docker start abogen` and stop it with `docker stop abogen`.
Known issues:
- Audio preview is not working inside container (ALSA error).
- `Open cache directory` and `Open configuration directory` options in settings not working. (Tried pcmanfm, did not work with Abogen).
(Special thanks to [@geo38](https://www.reddit.com/user/geo38/) from Reddit, who provided the Dockerfile and instructions in [this comment](https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/).)
## `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)**
@@ -295,23 +560,157 @@ If you encounter any issues while running Abogen, try launching it from the comm
```
abogen-cli
```
If you installed using the Windows installer `(WINDOWS_INSTALL.bat)`, go to `python_embedded/Scripts` and run:
```
abogen-cli.exe
```
This will start Abogen in command-line mode and display detailed error messages. Please open a new issue on the [Issues](https://github.com/denizsafak/abogen/issues) page with the error message and a description of your problem.
## `Common Issues & Solutions`
<details><summary><b>
<a name="about-abogen">About the name "abogen"</a>
</b></summary>
> The name **"abogen"** comes from a shortened form of **"audiobook generator"**, which is the purpose of this project.
>
> After releasing the project, I learned from [community feedback](https://news.ycombinator.com/item?id=44853064#44857237) that the prefix *"abo"* can unfortunately be understood as an ethnic slur in certain regions (particularly Australia and New Zealand). This was something I was not aware of when naming the project, as English is not my first language.
>
> I want to make it clear that the name was chosen only for its technical meaning, with **no offensive intent**. Im grateful to those who kindly pointed this out, as it helps ensure the project remains respectful and welcoming to everyone.
</details>
<details><summary><b>
<a name="cuda-warning">How to fix "CUDA GPU is not available. Using CPU" warning?</a>
</b></summary>
> This message means PyTorch 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
> ```
</details>
<details><summary><b>
<a name="path-warning">How to fix "WARNING: The script abogen-cli is installed in '/home/username/.local/bin' which is not on PATH" error in Linux?</a>
</b></summary>
> Run the following command to add Abogen to your PATH:
> ```bash
> echo "export PATH=\"/home/$USER/.local/bin:\$PATH\"" >> ~/.bashrc && source ~/.bashrc
> ```
</details>
<details><summary><b>
<a name="no-matching-distribution-found">How to fix "No matching distribution found" error?<a>
</b></summary>
> Try installing Abogen on supported Python (3.10 to 3.12) versions. 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.
</details>
<details><summary><b>
<a name="WinError-1114">How to fix "[WinError 1114] A dynamic link library (DLL) initialization routine failed" error?</a>
</b></summary>
> 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
> ```
</details>
<details><summary><b>
<a name="japanese-audio-not-working">How to fix Japanese audio not working?</a>
</b></summary>
> Japanese audio may require additional configuration.
> I'm not sure about the exact solution, but it seems to be related to installing additional dependencies for Japanese support in Kokoro. Please check [#56](https://github.com/denizsafak/abogen/issues/56) for more information.
</details>
<details><summary><b>
<a name="use-uv-instead-of-pip">How to uninstall Abogen?</a>
</b></summary>
> - 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.
</details>
## `Contributing`
I welcome contributions! If you have ideas for new features, improvements, or bug fixes, please fork the repository and submit a pull request.
### For developers and contributors
If you'd like to modify the code and contribute to development, you can [download the repository](https://github.com/denizsafak/abogen/archive/refs/heads/main.zip), extract it and run the following commands to build **or** install the package:
```bash
# Go to the directory where you extracted the repository and run:
pip install -e . # Installs the package in editable mode
pip install build # Install the build package
python -m build # Builds the package in dist folder (optional)
abogen # Opens the GUI
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.
<details>
<summary><b>Alternative: Using uv (click to expand)</b></summary>
```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
```
</details>
Feel free to explore the code and make any changes you like.
## `Credits`
- Web UI implementation by [@jeremiahsb](https://github.com/jeremiahsb)
- Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible.
- Thanks to the [spaCy](https://spacy.io/) project for its sentence-segmentation tools, which help Abogen produce cleaner, more natural sentence segmentation.
- Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows.
- 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.
@@ -321,7 +720,10 @@ Feel free to explore the code and make any changes you like.
This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details.
[Kokoro](https://github.com/hexgrad/kokoro) is licensed under [Apache-2.0](https://github.com/hexgrad/kokoro/blob/main/LICENSE) which allows commercial use, modification, distribution, and private use.
> [!IMPORTANT]
> Subtitle generation currently works only for English. This is because Kokoro provides timestamp tokens only for English text. If you want subtitles in other languages, please request this feature in the [Kokoro project](https://github.com/hexgrad/kokoro). For more technical details, see [this line](https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py#L383) in the Kokoro's code.
## `Star History`
[![Star History Chart](https://api.star-history.com/svg?repos=denizsafak/abogen&type=Date)](https://www.star-history.com/#denizsafak/abogen&Date)
> Tags: audiobook, kokoro, text-to-speech, TTS, audiobook generator, audiobooks, text to speech, audiobook maker, audiobook creator, audiobook generator, voice-synthesis, text to audio, text to audio converter, text to speech converter, text to speech generator, text to speech software, text to speech app, epub to audio, pdf to audio, content-creation, media-generation
> [!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).
> 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
+83 -28
View File
@@ -1,5 +1,5 @@
@echo off
setlocal
setlocal EnableDelayedExpansion
cd /d "%~dp0"
:: Set misaki language
@@ -8,9 +8,6 @@ cd /d "%~dp0"
:: Japanese: "ja"
set MISAKI_LANG=en
:: Set PyTorch CUDA version
set CUDA_VERSION=128
:::
::: _ ____ ___ ____ _____ _ _
::: / \ | __ ) / _ \ / ___|| ____| \ | |
@@ -23,7 +20,7 @@ set CUDA_VERSION=128
for /f "delims=: tokens=*" %%A in ('findstr /b ::: "%~f0"') do @echo(%%A
set CURRENT_DIR="%CD%"
setlocal enabledelayedexpansion
set "UV_CACHE_DIR=%~dp0.uv_cache"
set NAME=abogen
set PROJECTFOLDER=abogen
set RUN=python_embedded\Scripts\abogen.exe
@@ -33,6 +30,28 @@ set refrenv=%PROJECTFOLDER%\refrenv.bat
set PYTHON_PATH=python_embedded\pythonw.exe
set PYTHON_CONSOLE_PATH=python_embedded\python.exe
:: ---------------------------------------------------------
:: Version Selection
:: ---------------------------------------------------------
echo.
echo Select installation version:
echo [1] Stable (PyPI) - Safer, recommended for most users.
echo [2] Dev (Local) - Install from current folder (may include commits after the latest release).
echo.
choice /C 12 /M "Your choice"
if errorlevel 2 (
set INSTALL_SOURCE=dev
echo.
echo Selected: Dev - Local Editable
) else (
set INSTALL_SOURCE=pypi
echo.
echo Selected: Stable - PyPI
)
echo.
:: ---------------------------------------------------------
:: Check for updates
echo Checking for updates...
set VERSION_FILE=%PROJECTFOLDER%\VERSION
@@ -140,10 +159,10 @@ if exist "%VERSION_FILE%" (
REM Python embedded download configuration for different architectures
if "%PROCESSOR_ARCHITECTURE%"=="x86" (
set PYTHON_EMBEDDED_FILE=%PROJECTFOLDER%\python_embedded_win32.zip
set PYTHON_EMBEDDED_URL=https://github.com/wojiushixiaobai/Python-Embed-Win64/releases/download/3.12.8/python-3.12.8-embed-win32.zip
set PYTHON_EMBEDDED_URL=https://github.com/wojiushixiaobai/Python-Embed-Win64/releases/download/3.12.12/python-3.12.12-embed-win32.zip
) else (
set PYTHON_EMBEDDED_FILE=%PROJECTFOLDER%\python_embedded_amd64.zip
set PYTHON_EMBEDDED_URL=https://github.com/wojiushixiaobai/Python-Embed-Win64/releases/download/3.12.8/python-3.12.8-embed-amd64.zip
set PYTHON_EMBEDDED_URL=https://github.com/wojiushixiaobai/Python-Embed-Win64/releases/download/3.12.12/python-3.12.12-embed-amd64.zip
)
:: Check if Python exists
@@ -201,18 +220,19 @@ if not "%~1"=="" (
echo Open with: "%~1"
)
:: Update pip
echo Updating pip...
:: Update pip and install uv
echo Updating pip and installing uv...
%PYTHON_CONSOLE_PATH% -m pip install --upgrade pip --no-warn-script-location
%PYTHON_CONSOLE_PATH% -m pip install uv --no-warn-script-location
if errorlevel 1 (
echo Failed to update pip.
echo Failed to install uv.
pause
exit /b
)
:: Install docopt's fixed version
echo Installing fixed version of docopt...
%PYTHON_CONSOLE_PATH% -m pip install --force-reinstall https://github.com/denizsafak/abogen/raw/refs/heads/main/abogen/resources/docopt-0.6.2-py2.py3-none-any.whl --no-warn-script-location
%PYTHON_CONSOLE_PATH% -m uv pip install --system --force-reinstall https://github.com/denizsafak/abogen/raw/refs/heads/main/abogen/resources/docopt-0.6.2-py2.py3-none-any.whl
if errorlevel 1 (
echo Failed to install fixed version of docopt.
pause
@@ -221,7 +241,7 @@ if errorlevel 1 (
:: Install progress's fixed version
echo Installing fixed version of progress...
%PYTHON_CONSOLE_PATH% -m pip install --force-reinstall https://github.com/denizsafak/abogen/raw/refs/heads/main/abogen/resources/progress-1.6-py3-none-any.whl --no-warn-script-location
%PYTHON_CONSOLE_PATH% -m uv pip install --system --force-reinstall https://github.com/denizsafak/abogen/raw/refs/heads/main/abogen/resources/progress-1.6-py3-none-any.whl
if errorlevel 1 (
echo Failed to install fixed version of progress.
pause
@@ -230,7 +250,7 @@ if errorlevel 1 (
:: Install setup requirements
echo Installing setup requirements...
%PYTHON_CONSOLE_PATH% -m pip install --upgrade setuptools setuptools-scm wheel sphinx hatchling editables --no-warn-script-location
%PYTHON_CONSOLE_PATH% -m uv pip install --system --upgrade setuptools setuptools-scm wheel sphinx hatchling editables
if errorlevel 1 (
echo Failed to install setup requirements.
pause
@@ -239,32 +259,43 @@ if errorlevel 1 (
:: Install gpustat
echo Installing gpustat...
%PYTHON_CONSOLE_PATH% -m pip install gpustat --no-warn-script-location
%PYTHON_CONSOLE_PATH% -m uv pip install --system gpustat
if errorlevel 1 (
echo Failed to install gpustat.
pause
exit /b
)
:: Install project and dependencies from pyproject.toml
echo Checking and installing project dependencies...
if exist %PYPROJECT_FILE% (
echo Installing project from pyproject.toml...
%PYTHON_CONSOLE_PATH% -m pip install -e . --no-warn-script-location
:: Install project based on user selection
if "%INSTALL_SOURCE%"=="pypi" (
echo Installing stable version from PyPI...
%PYTHON_CONSOLE_PATH% -m uv pip install --system abogen
if errorlevel 1 (
echo Failed to install from pyproject.toml.
echo Failed to install abogen from PyPI.
pause
exit /b
)
) else (
echo Warning: pyproject.toml not found in current directory.
pause
echo Checking and installing project dependencies...
if exist %PYPROJECT_FILE% (
echo Installing project from pyproject.toml using uv...
:: Using uv pip install --system --editable
%PYTHON_CONSOLE_PATH% -m uv pip install --system --editable .
if errorlevel 1 (
echo Failed to install from pyproject.toml.
pause
exit /b
)
) else (
echo Warning: pyproject.toml not found in current directory.
pause
)
)
:: Install misaki again if MISAKI_LANG is not set to "en"
:: Install misaki again if MISAKI_LANG is not set to "en" via uv
if "%MISAKI_LANG%" NEQ "en" (
echo Configuring language pack: %MISAKI_LANG%
%PYTHON_CONSOLE_PATH% -m pip install misaki[lang] --upgrade --no-warn-script-location
%PYTHON_CONSOLE_PATH% -m uv pip install --system misaki[%MISAKI_LANG%] --upgrade
if errorlevel 1 (
echo Failed to install misaki language pack.
pause
@@ -276,13 +307,16 @@ if "%MISAKI_LANG%" NEQ "en" (
for /f %%i in ('%PYTHON_CONSOLE_PATH% -c "from abogen.is_nvidia import check; print(check())"') do set IS_NVIDIA=%%i
:: Check if torch is installed with CUDA support
echo.
echo Checking CUDA availability...
if /I "%IS_NVIDIA%"=="true" (
for /f %%i in ('%PYTHON_CONSOLE_PATH% -c "from torch.cuda import is_available; print(is_available())"') do set cuda_available=%%i
for /f %%i in ('%PYTHON_CONSOLE_PATH% %PROJECTFOLDER%\check_cuda.py') do set cuda_available=%%i
if "%cuda_available%"=="False" (
echo Installing PyTorch with CUDA %CUDA_VERSION% support...
%PYTHON_CONSOLE_PATH% -m pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu%CUDA_VERSION% --no-warn-script-location
echo "Installing PyTorch with CUDA (12.8) support..."
:: We need to use an older version of PyTorch (2.8.0) until this issue is fixed: https://github.com/pytorch/pytorch/issues/166628
:: Solution mentioned by @mazenemam19 in #99:
%PYTHON_CONSOLE_PATH% -m uv pip install --system torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128
echo.
if errorlevel 1 (
echo Failed to install PyTorch.
@@ -293,7 +327,28 @@ if /I "%IS_NVIDIA%"=="true" (
echo CUDA is available on NVIDIA GPU.
)
) else (
echo GPU is not NVIDIA. Skipping PyTorch CUDA installation.
echo.
echo Unable to detect an NVIDIA GPU in your system.
echo.
echo Do you want to install PyTorch anyway?
echo.
echo If you DO have an NVIDIA GPU, please press Y.
echo If you DO NOT have an NVIDIA GPU, please press N.
echo.
choice /C YN /M "Y=Yes, N=No"
if errorlevel 2 (
echo Skipping PyTorch installation.
) else (
echo "Installing PyTorch with CUDA (12.8) support..."
:: We need to use an older version of PyTorch (2.8.0) until this issue is fixed: https://github.com/pytorch/pytorch/issues/166628
:: Solution mentioned by @mazenemam19 in #99:
%PYTHON_CONSOLE_PATH% -m uv pip install --system torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128
if errorlevel 1 (
echo Failed to install PyTorch.
pause
exit /b
)
)
)
:: Ask user if they want to create a desktop shortcut
-42
View File
@@ -1,42 +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-pyqt5 \
espeak-ng \
&& 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
+1 -1
View File
@@ -1 +1 @@
1.1.5
1.3.1
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
import sys
import os
import platform
import ctypes
import importlib.util
def check_cuda_with_fix():
"""
Check if CUDA is available, with a fix for PyTorch DLL loading issue
([WinError 1114]) on Windows.
"""
# Fix PyTorch DLL loading issue ([WinError 1114]) on Windows
try:
if platform.system() == "Windows":
spec = importlib.util.find_spec("torch")
if spec and spec.origin:
dll_path = os.path.join(os.path.dirname(spec.origin), "lib", "c10.dll")
if os.path.exists(dll_path):
ctypes.CDLL(os.path.normpath(dll_path))
except Exception:
pass
try:
from torch.cuda import is_available
print(is_available())
except ImportError:
print("False")
if __name__ == "__main__":
check_cuda_with_fix()
+275
View File
@@ -0,0 +1,275 @@
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"(?<!\b[A-Z])[.!?][\s\n]+")
_WHITESPACE_REGEX = re.compile(r"\s+")
_PARAGRAPH_SPLIT_REGEX = re.compile(r"(?:\r?\n){2,}")
_ABBREVIATION_END_RE = re.compile(
r"\b(?:Mr|Mrs|Ms|Dr|Prof|Rev|Sr|Jr|St|Gen|Lt|Col|Sgt|Capt|Adm|Cmdr|vs|etc)\.$",
re.IGNORECASE,
)
_PIPELINE_APOSTROPHE_CONFIG = ApostropheConfig()
@dataclass(frozen=True)
class Chunk:
id: str
chapter_index: int
chunk_index: int
level: ChunkLevel
text: str
speaker_id: str = "narrator"
voice: Optional[str] = None
voice_profile: Optional[str] = None
voice_formula: Optional[str] = None
display_text: Optional[str] = None
def as_dict(self) -> 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
+7 -7
View File
@@ -2,9 +2,7 @@ from abogen.utils import get_version
# Program Information
PROGRAM_NAME = "abogen"
PROGRAM_DESCRIPTION = (
"Generate audiobooks from EPUBs, PDFs and text with synchronized captions."
)
PROGRAM_DESCRIPTION = "Generate audiobooks from EPUBs, PDFs, text and subtitles with synchronized captions."
GITHUB_URL = "https://github.com/denizsafak/abogen"
VERSION = get_version()
@@ -44,6 +42,7 @@ SUPPORTED_SOUND_FORMATS = [
SUPPORTED_SUBTITLE_FORMATS = [
"srt",
"ass",
"vtt",
]
# Supported input formats
@@ -51,6 +50,9 @@ SUPPORTED_INPUT_FORMATS = [
"epub",
"pdf",
"txt",
"srt",
"ass",
"vtt",
]
# Supported languages for subtitle generation
@@ -59,10 +61,7 @@ SUPPORTED_INPUT_FORMATS = [
# Please refer to: https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py
# 383 English processing (unchanged)
# 384 if self.lang_code in 'ab':
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = [
"a",
"b",
]
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys())
# Voice and sample text constants
VOICES_INTERNAL = [
@@ -148,6 +147,7 @@ COLORS = {
"BLUE_BORDER_HOVER": "#6ab0de",
"YELLOW_BACKGROUND": "rgba(255, 221, 51, 0.40)",
"GREY_BACKGROUND": "rgba(128, 128, 128, 0.15)",
"GREY_BORDER": "#808080",
"RED_BACKGROUND": "rgba(232, 78, 60, 0.15)",
"RED_BG": "rgba(232, 78, 60, 0.10)",
"RED_BG_HOVER": "rgba(232, 78, 60, 0.15)",
-1394
View File
File diff suppressed because it is too large Load Diff
+390
View File
@@ -0,0 +1,390 @@
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] = [
'<?xml version="1.0" encoding="utf-8"?>',
"<!DOCTYPE html>",
'<html xmlns="http://www.w3.org/1999/xhtml">',
"<head>",
f" <title>{title}</title>",
' <meta charset="utf-8" />',
"</head>",
"<body>",
f" <h1>{title}</h1>",
" <p>Each paragraph begins with a stable debug code marker.</p>",
]
for sample in DEBUG_TTS_SAMPLES:
safe_label = sample.label.replace("&", "and")
chapter_lines.append(f" <h2>{safe_label}</h2>")
chapter_lines.append(
" <p><strong>"
+ marker_for(sample.code)
+ "</strong> "
+ sample.text
+ "</p>"
)
chapter_lines += ["</body>", "</html>"]
chapter_xhtml = "\n".join(chapter_lines)
container_xml = """<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
"""
content_opf = """<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookid" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="bookid">abogen-debug-samples</dc:identifier>
<dc:title>abogen debug samples</dc:title>
<dc:language>en</dc:language>
</metadata>
<manifest>
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" />
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav" />
</manifest>
<spine>
<itemref idref="chapter" />
</spine>
</package>
"""
nav_xhtml = """<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Navigation</title>
<meta charset="utf-8" />
</head>
<body>
<nav epub:type="toc" id="toc">
<h2>Table of Contents</h2>
<ol>
<li><a href="chapter.xhtml">Debug samples</a></li>
</ol>
</nav>
</body>
</html>
"""
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
+489
View File
@@ -0,0 +1,489 @@
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()
+3
View File
@@ -0,0 +1,3 @@
from .exporter import EPUB3PackageBuilder, build_epub3_package
__all__ = ["EPUB3PackageBuilder", "build_epub3_package"]
+910
View File
@@ -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 = "<p></p>"
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 = (
" <pre class=\"chapter-original\" hidden=\"hidden\" aria-hidden=\"true\">\n"
f"{safe_original}\n"
" </pre>"
)
return (
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
"<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\" lang=\"{lang}\">\n"
" <head>\n"
" <title>{title}</title>\n"
" <meta charset=\"utf-8\"/>\n"
" <link rel=\"stylesheet\" type=\"text/css\" href=\"styles/style.css\"/>\n"
" </head>\n"
" <body>\n"
" <section epub:type=\"chapter\" id=\"chapter-{index:04d}\">\n"
" <h1>{title}</h1>\n"
" {chunks}\n"
"{original_block}"
" </section>\n"
" </body>\n"
"</html>\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(
" <par id=\"par-{chunk_id}\">\n"
" <text src=\"text/{xhtml}#{chunk_id}\"/>\n"
" <audio src=\"{audio}\" clipBegin=\"{start}\" clipEnd=\"{end}\"/>\n"
" </par>".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 (
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
"<smil xmlns=\"http://www.w3.org/2001/SMIL20/Language\" xmlns:epub=\"http://www.idpf.org/2007/ops\">\n"
" <head>\n"
" <meta name=\"dc:title\" content=\"{title}\"/>\n"
" <meta name=\"dtb:uid\" content=\"{book_id}\"/>\n"
" <meta name=\"dtb:generator\" content=\"Abogen\"/>\n"
" </head>\n"
" <body>\n"
" <seq id=\"seq-{index:04d}\" epub:textref=\"text/{xhtml}\">\n"
"{pars}\n"
" </seq>\n"
" </body>\n"
"</smil>\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 " <par/>",
)
def _render_nav(self, chapters: Sequence[ChapterDocument]) -> str:
items = []
for chapter in chapters:
href = f"text/{chapter.xhtml_name}"
items.append(
" <li><a href=\"{href}\">{title}</a></li>".format(
href=html.escape(href),
title=html.escape(chapter.title),
)
)
return (
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
"<html xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:epub=\"http://www.idpf.org/2007/ops\" xml:lang=\"{lang}\">\n"
" <head>\n"
" <title>Navigation</title>\n"
" <meta charset=\"utf-8\"/>\n"
" </head>\n"
" <body>\n"
" <nav epub:type=\"toc\" id=\"toc\">\n"
" <h1>{title}</h1>\n"
" <ol>\n"
"{items}\n"
" </ol>\n"
" </nav>\n"
" </body>\n"
"</html>\n"
).format(
lang=html.escape(self._language or "en"),
title=html.escape(self._title),
items="\n".join(items) if items else " <li><a href=\"text/chapter_0001.xhtml\">Chapter 1</a></li>",
)
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(
" <item id=\"{item_id}\" href=\"text/{href}\" media-type=\"application/xhtml+xml\" media-overlay=\"{overlay_id}\"/>".format(
item_id=item_id,
href=html.escape(chapter.xhtml_name),
overlay_id=overlay_id,
)
)
manifest_items.append(
" <item id=\"{overlay_id}\" href=\"smil/{smil}\" media-type=\"application/smil+xml\"/>".format(
overlay_id=overlay_id,
smil=html.escape(chapter.smil_name),
)
)
spine_refs.append(f" <itemref idref=\"{item_id}\"/>")
audio_item_id = "primary-audio"
manifest_items.append(
" <item id=\"{item_id}\" href=\"audio/{href}\" media-type=\"{mime}\"/>".format(
item_id=audio_item_id,
href=html.escape(audio_filename),
mime=_detect_audio_mime(audio_filename),
)
)
manifest_items.append(
" <item id=\"nav\" href=\"nav.xhtml\" media-type=\"application/xhtml+xml\" properties=\"nav\"/>"
)
manifest_items.append(
" <item id=\"style\" href=\"{href}\" media-type=\"text/css\"/>".format(
href=html.escape(str(stylesheet_path).replace("\\", "/")),
)
)
if has_cover and self.cover_image_path:
cover_id = "cover-image"
manifest_items.append(
" <item id=\"{item_id}\" href=\"images/{href}\" media-type=\"{mime}\" properties=\"cover-image\"/>".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 (
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
"<package xmlns=\"http://www.idpf.org/2007/opf\" version=\"3.0\" unique-identifier=\"book-id\">\n"
" <metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:opf=\"http://www.idpf.org/2007/opf\" xmlns:media=\"http://www.idpf.org/epub/vocab/mediaoverlays/#\" xmlns:abogen=\"https://abogen.app/ns#\" xmlns:dcterms=\"http://purl.org/dc/terms/\">\n"
"{metadata}\n"
" </metadata>\n"
" <manifest>\n"
"{manifest}\n"
" </manifest>\n"
" <spine>\n"
"{spine}\n"
" </spine>\n"
"</package>\n"
).format(
metadata="\n".join(metadata_elements),
manifest="\n".join(manifest_items),
spine="\n".join(spine_refs) if spine_refs else " <itemref idref=\"chap0001\"/>",
)
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(
(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<container version=\"1.0\" xmlns=\"urn:oasis:names:tc:opendocument:xmlns:container\">\n"
" <rootfiles>\n"
" <rootfile full-path=\"OEBPS/content.opf\" media-type=\"application/oebps-package+xml\"/>\n"
" </rootfiles>\n"
"</container>\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 = "&nbsp;"
return (
f"<span class=\"chunk\" id=\"{escaped_id}\"{speaker_attr}{voice_attr}{level_attr}>"
f"{escaped_text}"
"</span>"
)
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 = "&nbsp;"
return f" <p class=\"chunk-group\"{group_attr}>{inline_html}</p>"
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" <dc:identifier id=\"book-id\">{html.escape(book_id)}</dc:identifier>",
f" <dc:title>{html.escape(title)}</dc:title>",
f" <dc:language>{html.escape(language or 'en')}</dc:language>",
]
for author in authors or ["Unknown"]:
elements.append(f" <dc:creator>{html.escape(author)}</dc:creator>")
if publisher:
elements.append(f" <dc:publisher>{html.escape(publisher)}</dc:publisher>")
if description:
elements.append(f" <dc:description>{html.escape(description)}</dc:description>")
if duration is not None:
elements.append(f" <meta property=\"media:duration\">{_format_iso_duration(duration)}</meta>")
if speaker_mode:
elements.append(
" <meta property=\"abogen:speakerMode\">{}</meta>".format(
html.escape(str(speaker_mode))
)
)
if modified:
elements.append(f" <meta property=\"dcterms:modified\">{html.escape(modified)}</meta>")
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;
}
"""
+6 -3412
View File
File diff suppressed because it is too large Load Diff
+304
View File
@@ -0,0 +1,304 @@
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)(?<!\w){escaped}(?P<possessive>'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
+1
View File
@@ -0,0 +1 @@
"""Integration clients for external services."""
+680
View File
@@ -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"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
+210
View File
@@ -0,0 +1,210 @@
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")
+28 -92
View File
@@ -1,113 +1,49 @@
from json import load
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 PyQt5.QtWidgets import QApplication
from PyQt5.QtGui import QIcon
from PyQt5.QtCore import qInstallMessageHandler, QtMsgType
import sys
# Add the directory to Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
from abogen.utils import load_config, prevent_sleep_end
from abogen.webui.app import main as _run_web_ui
from abogen.utils import get_resource_path, load_config, prevent_sleep_end
# 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)
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."""
# Custom message handler to filter out specific Qt warnings
def qt_message_handler(mode, context, message):
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}")
_run_web_ui()
# Install the custom message handler
qInstallMessageHandler(qt_message_handler)
# Set application ID for Windows taskbar icon
if platform.system() == "Windows":
import ctypes
app_id = f"{PROGRAM_NAME}.{VERSION}"
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
# Handle Wayland on Linux GNOME
if platform.system() == "Linux":
xdg_session = os.environ.get("XDG_SESSION_TYPE", "").lower()
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
if (
"gnome" in desktop
and xdg_session == "wayland"
and "QT_QPA_PLATFORM" not in os.environ
):
os.environ["QT_QPA_PLATFORM"] = "wayland"
def main():
"""Main entry point for console usage."""
app = QApplication(sys.argv)
# Set application icon using get_resource_path from utils
icon_path = get_resource_path("abogen.assets", "icon.ico")
if icon_path:
app.setWindowIcon(QIcon(icon_path))
# Set the .desktop name on Linux
if platform.system() == "Linux":
try:
app.setDesktopFileName("abogen.desktop")
except AttributeError:
pass
ex = abogen()
ex.show()
sys.exit(app.exec_())
if __name__ == "__main__":
if __name__ == "__main__": # pragma: no cover - manual execution hook
main()
+246
View File
@@ -0,0 +1,246 @@
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
+590
View File
@@ -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("<b>Current Status:</b>")
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)
+256
View File
@@ -0,0 +1,256 @@
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,
}
+7
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4283
View File
File diff suppressed because it is too large Load Diff
+187
View File
@@ -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()
+590
View File
@@ -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("<b>Current Status:</b>")
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)
+881
View File
@@ -0,0 +1,881 @@
# 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",
"word_substitutions_enabled",
"word_substitutions_list",
"case_sensitive_substitutions",
"replace_all_caps",
"replace_numerals",
"fix_nonstandard_punctuation",
]
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(
"<h2>How Queue Works?</h2>"
"You can add text and subtitle files (.txt, .srt, .ass, .vtt) directly using the '<b>Add files</b>' button below. "
"To add PDF, EPUB or markdown files, use the input box in the main window and click the <b>'Add to Queue'</b> button. "
"By default, each file in the queue keeps the configuration settings active when they were added. "
"Enabling the <b>'Override item settings with current selection'</b> 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 += "<b style='color: #ff9900;'>(Global Override Active)</b><br>"
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"<b>Input File:</b> {display_file_path}<br>"
if (
show_processing
and processing_file_path
and processing_file_path != display_file_path
):
tooltip += f"<b>Processing File:</b> {processing_file_path}<br>"
tooltip += (
f"<b>Language:</b> {get_val('lang_code')}<br>"
f"<b>Speed:</b> {get_val('speed')}<br>"
f"<b>Voice:</b> {get_val('voice')}<br>"
f"<b>Save Option:</b> {get_val('save_option')}<br>"
)
if output_folder not in (None, "", "None"):
tooltip += f"<b>Output Folder:</b> {output_folder}<br>"
tooltip += (
f"<b>Subtitle Mode:</b> {get_val('subtitle_mode')}<br>"
f"<b>Output Format:</b> {get_val('output_format')}<br>"
f"<b>Characters:</b> {getattr(item, 'total_char_count', '')}<br>"
f"<b>Replace Single Newlines:</b> {get_val('replace_single_newlines', True)}<br>"
f"<b>Use Silent Gaps:</b> {get_val('use_silent_gaps', False)}<br>"
f"<b>Speed Method:</b> {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"<br><b>Save chapters separately:</b> {'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"<br><b>Merge chapters at the end:</b> {'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"
)
# word substitutions
attrs["word_substitutions_enabled"] = getattr(
parent, "word_substitutions_enabled", False
)
attrs["word_substitutions_list"] = getattr(
parent, "word_substitutions_list", ""
)
attrs["case_sensitive_substitutions"] = getattr(
parent, "case_sensitive_substitutions", False
)
attrs["replace_all_caps"] = getattr(parent, "replace_all_caps", False)
attrs["replace_numerals"] = getattr(parent, "replace_numerals", False)
attrs["fix_nonstandard_punctuation"] = getattr(
parent, "fix_nonstandard_punctuation", False
)
# 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)
+28
View File
@@ -0,0 +1,28 @@
# 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
# Word Substitution fields
word_substitutions_enabled: bool = False
word_substitutions_list: str = ""
case_sensitive_substitutions: bool = False
replace_all_caps: bool = False
replace_numerals: bool = False
fix_nonstandard_punctuation: bool = False
File diff suppressed because it is too large Load Diff
+7 -550
View File
@@ -1,554 +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 PyQt5.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QDialogButtonBox,
QPushButton,
QListWidget,
QListWidgetItem,
QFileIconProvider,
QLabel,
QWidget,
QSizePolicy,
)
from PyQt5.QtCore import QFileInfo, Qt
from abogen.constants import COLORS
from copy import deepcopy
from PyQt5.QtGui import QFontMetrics
The actual implementation lives in abogen.pyqt.queue_manager_gui.
"""
from __future__ import annotations
class ElidedLabel(QLabel):
def __init__(self, text, parent=None):
super().__init__(text, parent)
self._full_text = text
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
from abogen.pyqt.queue_manager_gui import * # noqa: F401, F403
from abogen.pyqt.queue_manager_gui import QueueManager
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.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.AlignRight | Qt.AlignVCenter)
char_label.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.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.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.WA_TransparentForMouseEvents, True)
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
for url in event.mimeData().urls():
if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt"):
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():
if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt"):
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")
]
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
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(QListWidget.ExtendedSelection)
self.listwidget.setAlternatingRowColors(True)
self.listwidget.setContextMenuPolicy(Qt.CustomContextMenu)
self.listwidget.customContextMenuRequested.connect(self.show_context_menu)
# Add informative instructions at the top
instructions = QLabel(
"<h2>How Queue Works?</h2>"
"You can add text files (.txt) directly using the '<b>Add files</b>' button below. "
"To add PDF or EPUB files, use the input box in the main window and click the <b>'Add to Queue'</b> button. "
"Each file in the queue keeps the configuration settings active when it was added. "
"Changing the main window configuration afterward <b>does not</b> affect files already in the queue. "
"You can view each file's configuration by hovering over them."
)
instructions.setAlignment(Qt.AlignLeft)
instructions.setWordWrap(True)
instructions.setStyleSheet("margin-bottom: 8px;")
layout.addWidget(instructions)
# Overlay label for empty queue
self.empty_overlay = QLabel(
"Drag and drop your text files here or use the 'Add files' button.",
self.listwidget,
)
self.empty_overlay.setAlignment(Qt.AlignCenter)
self.empty_overlay.setStyleSheet(
f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;"
)
self.empty_overlay.setWordWrap(True)
self.empty_overlay.setAttribute(Qt.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.Ok | QDialogButtonBox.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."""
self.listwidget.clear()
if not self.queue:
self.empty_overlay.resize(self.listwidget.size())
self.empty_overlay.show()
self.update_button_states()
return
else:
self.empty_overlay.hide()
icon_provider = QFileIconProvider()
for item in self.queue:
# Only show the file name, not the full path
file_name = item.file_name
display_name = file_name
import os
if os.path.sep in file_name:
display_name = os.path.basename(file_name)
# Get icon for the file
icon = icon_provider.icon(QFileInfo(file_name))
list_item = QListWidgetItem()
# Set tooltip with detailed info
output_folder = getattr(item, "output_folder", "")
tooltip = (
f"<b>Path:</b> {file_name}<br>"
f"<b>Language:</b> {getattr(item, 'lang_code', '')}<br>"
f"<b>Speed:</b> {getattr(item, 'speed', '')}<br>"
f"<b>Voice:</b> {getattr(item, 'voice', '')}<br>"
f"<b>Save Option:</b> {getattr(item, 'save_option', '')}<br>"
)
if output_folder not in (None, "", "None"):
tooltip += f"<b>Output Folder:</b> {output_folder}<br>"
tooltip += (
f"<b>Subtitle Mode:</b> {getattr(item, 'subtitle_mode', '')}<br>"
f"<b>Output Format:</b> {getattr(item, 'output_format', '')}<br>"
f"<b>Characters:</b> {getattr(item, 'total_char_count', '')}<br>"
f"<b>Replace Single Newlines:</b> {getattr(item, 'replace_single_newlines', False)}"
)
list_item.setToolTip(tooltip)
list_item.setIcon(icon)
list_item.setData(Qt.UserRole, file_name)
# Use custom widget for display
char_count = getattr(item, "total_char_count", 0)
widget = QueueListItemWidget(file_name, 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 PyQt5.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.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.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 PyQt5.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.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.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", False
)
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",
]
}
return attrs
def add_files_from_paths(self, file_paths):
from abogen.utils import calculate_text_length
from PyQt5.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
for attr, value in current_attrs.items():
setattr(item, attr, value)
# 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", False)
== getattr(item, "replace_single_newlines", False)
):
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 PyQt5.QtWidgets import QFileDialog
from abogen.utils import calculate_text_length # import the function
# Only allow .txt files
files, _ = QFileDialog.getOpenFileNames(
self, "Select .txt files", "", "Text Files (*.txt)"
)
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 PyQt5.QtWidgets import QMenu, QAction
from PyQt5.QtGui import QDesktopServices
from PyQt5.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)
# Add Open file action
open_file_action = QAction("Open file", self)
def open_file():
from PyQt5.QtWidgets import QMessageBox
item = selected_items[0]
file_path = item.data(Qt.UserRole)
for q in self.queue:
if q.file_name == file_path:
if not os.path.exists(q.file_name):
QMessageBox.warning(
self, "File Not Found", f"The file does not exist."
)
return
QDesktopServices.openUrl(QUrl.fromLocalFile(q.file_name))
break
open_file_action.triggered.connect(open_file)
menu.addAction(open_file_action)
# Add Go to folder action
go_to_folder_action = QAction("Go to folder", self)
def go_to_folder():
from PyQt5.QtWidgets import QMessageBox
item = selected_items[0]
file_path = item.data(Qt.UserRole)
for q in self.queue:
if q.file_name == file_path:
if not os.path.exists(q.file_name):
QMessageBox.warning(
self, "File Not Found", f"The file does not exist."
)
return
folder = os.path.dirname(q.file_name)
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):
# Accept: keep changes
super().accept()
def reject(self):
# Cancel: restore original queue
from PyQt5.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.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
self.queue.clear()
self.queue.extend(deepcopy(self._original_queue))
super().reject()
def keyPressEvent(self, event):
from PyQt5.QtCore import Qt
if event.key() == Qt.Key_Delete:
self.remove_item()
else:
super().keyPressEvent(event)
__all__ = ["QueueManager"]
+6 -1
View File
@@ -13,4 +13,9 @@ class QueuedItem:
subtitle_mode: str
output_format: str
total_char_count: int
replace_single_newlines: bool = False
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
+265
View File
@@ -0,0 +1,265 @@
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
+161
View File
@@ -0,0 +1,161 @@
"""
Lazy-loaded spaCy utilities for sentence segmentation.
"""
# Cached spaCy module and models (lazy loaded)
_spacy = None
_nlp_cache = {}
# Language code to spaCy model mapping
SPACY_MODELS = {
"a": "en_core_web_sm", # American English
"b": "en_core_web_sm", # British English
"e": "es_core_news_sm", # Spanish
"f": "fr_core_news_sm", # French
"i": "it_core_news_sm", # Italian
"p": "pt_core_news_sm", # Brazilian Portuguese
"z": "zh_core_web_sm", # Mandarin Chinese
"j": "ja_core_news_sm", # Japanese
"h": "xx_sent_ud_sm", # Hindi (multi-language model)
}
def _load_spacy():
"""Lazy load spaCy module."""
global _spacy
if _spacy is None:
try:
import spacy
_spacy = spacy
except ImportError:
return None
return _spacy
def get_spacy_model(lang_code, log_callback=None):
"""
Get or load a spaCy model for the given language code.
Downloads the model automatically if not available.
Args:
lang_code: Language code (a, b, e, f, etc.)
log_callback: Optional function to log messages
Returns:
Loaded spaCy model or None if unavailable
"""
def log(msg, is_error=False):
# Prefer GUI log callback when provided to avoid spamming stdout.
if log_callback:
color = "red" if is_error else "grey"
try:
log_callback((msg, color))
except Exception:
# Fallback to printing if callback misbehaves
print(msg)
else:
print(msg)
# Check if model is cached
if lang_code in _nlp_cache:
return _nlp_cache[lang_code]
# Check if language is supported
model_name = SPACY_MODELS.get(lang_code)
if not model_name:
log(f"\nspaCy: No model mapping for language '{lang_code}'...")
return None
# Lazy load spaCy
spacy = _load_spacy()
if spacy is None:
log("\nspaCy: Module not installed, falling back to default segmentation...")
return None
# Try to load the model
try:
log(f"\nLoading spaCy model '{model_name}'...")
# sentence segmentation involving parentheses, quotes, and complex structure.
# We only disable heavier components we don't need like NER.
nlp = spacy.load(
model_name,
disable=["ner", "tagger", "lemmatizer", "attribute_ruler"],
)
# Ensure a sentence segmentation strategy is in place
# The parser provides sents, but if it's missing (unlikely for core models), fallback to sentencizer
if "parser" not in nlp.pipe_names and "sentencizer" not in nlp.pipe_names:
nlp.add_pipe("sentencizer")
_nlp_cache[lang_code] = nlp
return nlp
except OSError:
# Model not found, attempt download
log(f"\nspaCy: Downloading model '{model_name}'...")
try:
from spacy.cli import download
download(model_name)
# Retry loading with the same fix
nlp = spacy.load(
model_name,
disable=["ner", "tagger", "lemmatizer", "attribute_ruler"],
)
if "parser" not in nlp.pipe_names and "sentencizer" not in nlp.pipe_names:
nlp.add_pipe("sentencizer")
_nlp_cache[lang_code] = nlp
log(f"spaCy model '{model_name}' downloaded and loaded")
return nlp
except Exception as e:
log(
f"\nspaCy: Failed to download model '{model_name}': {e}...",
is_error=True,
)
return None
except Exception as e:
log(f"\nspaCy: Error loading model '{model_name}': {e}...", is_error=True)
return None
def segment_sentences(text, lang_code, log_callback=None):
"""
Segment text into sentences using spaCy.
Args:
text: Text to segment
lang_code: Language code
log_callback: Optional function to log messages
Returns:
List of sentence strings, or None if spaCy unavailable
"""
nlp = get_spacy_model(lang_code, log_callback)
if nlp is None:
return None
# Ensure spaCy can handle large texts by adjusting max_length if necessary
try:
text_len = len(text or "")
if text_len and hasattr(nlp, "max_length") and text_len > nlp.max_length:
# increase a bit beyond the text length to be safe
nlp.max_length = text_len + 1000
except Exception:
pass
# Process text and extract sentences
doc = nlp(text)
return [sent.text.strip() for sent in doc.sents if sent.text.strip()]
def is_spacy_available():
"""Check if spaCy can be imported."""
return _load_spacy() is not None
def clear_cache():
"""Clear the model cache to free memory."""
global _nlp_cache
_nlp_cache.clear()
+762
View File
@@ -0,0 +1,762 @@
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
+166
View File
@@ -0,0 +1,166 @@
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())
+584
View File
@@ -0,0 +1,584 @@
import re
import platform
from abogen.utils import detect_encoding, load_config
from abogen.constants import SAMPLE_VOICE_TEXTS
# Pre-compile frequently used regex patterns for better performance
_METADATA_TAG_PATTERN = re.compile(r"<<METADATA_[^:]+:[^>]*>>")
_WHITESPACE_PATTERN = re.compile(r"[^\S\n]+")
_MULTIPLE_NEWLINES_PATTERN = re.compile(r"\n{3,}")
_SINGLE_NEWLINE_PATTERN = re.compile(r"(?<!\n)\n(?!\n)")
_CHAPTER_MARKER_PATTERN = re.compile(r"<<CHAPTER_MARKER:[^>]*>>")
_HTML_TAG_PATTERN = re.compile(r"<[^>]+>")
_VOICE_TAG_PATTERN = re.compile(r"{[^}]+}")
_ASS_STYLING_PATTERN = re.compile(r"\{[^}]+\}")
_ASS_NEWLINE_N_PATTERN = re.compile(r"\\N")
_ASS_NEWLINE_LOWER_N_PATTERN = re.compile(r"\\n")
_CHAPTER_MARKER_SEARCH_PATTERN = re.compile(r"<<CHAPTER_MARKER:(.*?)>>")
_VOICE_MARKER_PATTERN = re.compile(r"<<VOICE:[^>]*>>")
_VOICE_MARKER_SEARCH_PATTERN = re.compile(r"<<VOICE:(.*?)>>")
_WEBVTT_HEADER_PATTERN = re.compile(r"^WEBVTT.*?\n", re.MULTILINE)
_VTT_STYLE_PATTERN = re.compile(r"STYLE\s*\n.*?(?=\n\n|$)", re.DOTALL)
_VTT_NOTE_PATTERN = re.compile(r"NOTE\s*\n.*?(?=\n\n|$)", re.DOTALL)
_DOUBLE_NEWLINE_SPLIT_PATTERN = re.compile(r"\n\s*\n")
_VTT_TIMESTAMP_PATTERN = re.compile(r"([\d:.]+)\s*-->\s*([\d:.]+)")
_TIMESTAMP_ONLY_PATTERN = re.compile(r"^(\d{1,2}:\d{2}:\d{2}(?:[.,]\d{1,3})?)$")
_WINDOWS_ILLEGAL_CHARS_PATTERN = re.compile(r'[<>:"/\\|?*]')
_CONTROL_CHARS_PATTERN = re.compile(r"[\x00-\x1f]")
_LINUX_CONTROL_CHARS_PATTERN = re.compile(
r"[\x01-\x1f]"
) # Linux: exclude \x00 for separate handling
_MACOS_ILLEGAL_CHARS_PATTERN = re.compile(r"[:]")
_LINUX_ILLEGAL_CHARS_PATTERN = re.compile(r"[/\x00]")
def clean_subtitle_text(text):
"""Remove chapter markers, voice markers, and metadata tags from subtitle text."""
# Use pre-compiled patterns for better performance
text = _METADATA_TAG_PATTERN.sub("", text)
text = _CHAPTER_MARKER_PATTERN.sub("", text)
text = _VOICE_MARKER_PATTERN.sub("", text)
return text.strip()
def calculate_text_length(text):
# Use pre-compiled patterns for better performance
# Ignore chapter markers, voice markers, and metadata patterns in a single pass
text = _CHAPTER_MARKER_PATTERN.sub("", text)
text = _VOICE_MARKER_PATTERN.sub("", text)
text = _METADATA_TAG_PATTERN.sub("", text)
# Ignore newlines and leading/trailing spaces
text = text.replace("\n", "").strip()
# Calculate character count
char_count = len(text)
return char_count
def clean_text(text, *args, **kwargs):
# Remove metadata tags first
text = _METADATA_TAG_PATTERN.sub("", text)
# Load replace_single_newlines from config
cfg = load_config()
replace_single_newlines = cfg.get("replace_single_newlines", True)
# Collapse all whitespace (excluding newlines) into single spaces per line and trim edges
# Use pre-compiled pattern for better performance
lines = [_WHITESPACE_PATTERN.sub(" ", line).strip() for line in text.splitlines()]
text = "\n".join(lines)
# Standardize paragraph breaks (multiple newlines become exactly two) and trim overall whitespace
# Use pre-compiled pattern for better performance
text = _MULTIPLE_NEWLINES_PATTERN.sub("\n\n", text).strip()
# Optionally replace single newlines with spaces, but preserve double newlines
if replace_single_newlines:
# Use pre-compiled pattern for better performance
text = _SINGLE_NEWLINE_PATTERN.sub(" ", text)
return text
def parse_srt_file(file_path):
"""
Parse an SRT subtitle file and return a list of subtitle entries.
Args:
file_path: Path to the SRT file
Returns:
List of tuples: [(start_time_seconds, end_time_seconds, text), ...]
"""
encoding = detect_encoding(file_path)
with open(file_path, "r", encoding=encoding, errors="replace") as f:
content = f.read()
# Split by double newlines to get individual subtitle blocks
blocks = re.split(r"\n\s*\n", content.strip())
subtitles = []
for block in blocks:
if not block.strip():
continue
lines = block.strip().split("\n")
if len(lines) < 3:
continue
# First line is index, second line is timestamp, rest is text
try:
timestamp_line = lines[1]
match = re.match(
r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})",
timestamp_line,
)
if not match:
continue
start_str = match.group(1)
end_str = match.group(2)
text = "\n".join(lines[2:])
# Convert timestamp to seconds
def time_to_seconds(t):
h, m, s_ms = t.split(":")
s, ms = s_ms.split(",")
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
start_sec = time_to_seconds(start_str)
end_sec = time_to_seconds(end_str)
# Clean text of any styling tags using pre-compiled pattern
text = _HTML_TAG_PATTERN.sub("", text)
# Remove chapter markers and metadata tags
text = clean_subtitle_text(text)
if text: # Only add non-empty subtitles
subtitles.append((start_sec, end_sec, text))
except (ValueError, IndexError):
continue
return subtitles
def parse_vtt_file(file_path):
"""
Parse a VTT (WebVTT) subtitle file and return a list of subtitle entries.
Args:
file_path: Path to the VTT file
Returns:
List of tuples: [(start_time_seconds, end_time_seconds, text), ...]
"""
encoding = detect_encoding(file_path)
with open(file_path, "r", encoding=encoding, errors="replace") as f:
content = f.read()
# Remove WEBVTT header and any style/note blocks using pre-compiled patterns
content = _WEBVTT_HEADER_PATTERN.sub("", content)
content = _VTT_STYLE_PATTERN.sub("", content)
content = _VTT_NOTE_PATTERN.sub("", content)
# Split by double newlines to get individual subtitle blocks using pre-compiled pattern
blocks = _DOUBLE_NEWLINE_SPLIT_PATTERN.split(content.strip())
subtitles = []
for block in blocks:
if not block.strip():
continue
lines = block.strip().split("\n")
if len(lines) < 2:
continue
# VTT can have optional identifier on first line, timestamp on second or first
timestamp_line = None
text_start_idx = 0
# Check if first line is timestamp
if "-->" in lines[0]:
timestamp_line = lines[0]
text_start_idx = 1
elif len(lines) > 1 and "-->" in lines[1]:
timestamp_line = lines[1]
text_start_idx = 2
else:
continue
try:
# VTT format: 00:00:00.000 --> 00:00:05.000 or 00:00.000 --> 00:05.000
# Use pre-compiled pattern
match = _VTT_TIMESTAMP_PATTERN.match(timestamp_line)
if not match:
continue
start_str = match.group(1)
end_str = match.group(2)
text = "\n".join(lines[text_start_idx:])
# Convert timestamp to seconds
def time_to_seconds(t):
parts = t.split(":")
if len(parts) == 3: # HH:MM:SS.mmm
h, m, s = parts
s, ms = s.split(".")
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
elif len(parts) == 2: # MM:SS.mmm
m, s = parts
s, ms = s.split(".")
return int(m) * 60 + int(s) + int(ms) / 1000.0
return 0
start_sec = time_to_seconds(start_str)
end_sec = time_to_seconds(end_str)
# Clean text of any styling tags and cue settings using pre-compiled patterns
text = _HTML_TAG_PATTERN.sub("", text)
text = _VOICE_TAG_PATTERN.sub("", text) # Remove voice tags
# Remove chapter markers and metadata tags
text = clean_subtitle_text(text)
if text: # Only add non-empty subtitles
subtitles.append((start_sec, end_sec, text))
except (ValueError, IndexError, AttributeError):
continue
return subtitles
def detect_timestamps_in_text(file_path):
"""Detect if text file contains timestamp markers (HH:MM:SS or HH:MM:SS,ms format) on separate lines."""
try:
encoding = detect_encoding(file_path)
with open(file_path, "r", encoding=encoding, errors="replace") as f:
lines = [
line.strip() for line in f.readlines()[:50] if line.strip()
] # Check first 50 non-empty lines
# Count lines that are ONLY timestamps (no other text)
# Supports HH:MM:SS or HH:MM:SS,ms format
# Use pre-compiled pattern for better performance
timestamp_lines = sum(
1 for line in lines if _TIMESTAMP_ONLY_PATTERN.match(line)
)
# Must have at least 2 timestamp-only lines and they should be >5% of total lines
return timestamp_lines >= 2 and (timestamp_lines / max(len(lines), 1)) > 0.05
except Exception:
return False
def parse_timestamp_text_file(file_path):
"""Parse text file with timestamps. Returns list of (start_time, end_time, text) tuples.
Supports HH:MM:SS or HH:MM:SS,ms format. Returns time in seconds as float."""
encoding = detect_encoding(file_path)
with open(file_path, "r", encoding=encoding, errors="replace") as f:
content = f.read()
# Split by timestamp pattern (supports HH:MM:SS or HH:MM:SS,ms)
pattern = r"^(\d{1,2}:\d{2}:\d{2}(?:[.,]\d{1,3})?)$"
lines = content.split("\n")
def parse_time(time_str):
"""Convert HH:MM:SS or HH:MM:SS,ms to seconds as float."""
time_str = time_str.replace(",", ".")
parts = time_str.split(":")
return float(int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2]))
entries = []
current_time = None
current_text = []
pre_timestamp_text = [] # Text before first timestamp
for line in lines:
match = re.match(pattern, line.strip())
if match:
# Save previous entry
if current_time is not None and current_text:
text = "\n".join(current_text).strip()
if text:
entries.append((current_time, text))
elif current_time is None and pre_timestamp_text:
# First timestamp found, save pre-timestamp text with time 0
text = "\n".join(pre_timestamp_text).strip()
if text:
entries.append((0.0, text))
pre_timestamp_text = []
# Start new entry
time_str = match.group(1)
current_time = parse_time(time_str)
current_text = []
elif current_time is not None:
current_text.append(line)
else:
# Text before first timestamp
pre_timestamp_text.append(line)
# Save last entry
if current_time is not None and current_text:
text = "\n".join(current_text).strip()
if text:
entries.append((current_time, text))
elif not entries and pre_timestamp_text:
# No timestamps found at all, treat entire file as starting at 0
text = "\n".join(pre_timestamp_text).strip()
if text:
entries.append((0.0, text))
# Convert to subtitle format with end times
subtitles = []
for i, (start_time, text) in enumerate(entries):
end_time = entries[i + 1][0] if i + 1 < len(entries) else None
# Remove chapter markers and metadata tags
text = clean_subtitle_text(text)
if text: # Only add non-empty entries
subtitles.append((start_time, end_time, text))
return subtitles
def parse_ass_file(file_path):
"""
Parse an ASS/SSA subtitle file and return a list of subtitle entries.
Args:
file_path: Path to the ASS/SSA file
Returns:
List of tuples: [(start_time_seconds, end_time_seconds, text), ...]
"""
encoding = detect_encoding(file_path)
with open(file_path, "r", encoding=encoding, errors="replace") as f:
lines = f.readlines()
subtitles = []
in_events = False
format_indices = {}
for line in lines:
line = line.strip()
if line.startswith("[Events]"):
in_events = True
continue
if line.startswith("[") and in_events:
# New section, stop processing
break
if in_events and line.startswith("Format:"):
# Parse format line to know column positions
parts = line.split(":", 1)[1].strip().split(",")
for i, part in enumerate(parts):
format_indices[part.strip().lower()] = i
continue
if in_events and (line.startswith("Dialogue:") or line.startswith("Comment:")):
if line.startswith("Comment:"):
continue # Skip comments
parts = line.split(":", 1)[1].strip().split(",", len(format_indices) - 1)
if (
"start" in format_indices
and "end" in format_indices
and "text" in format_indices
):
start_str = parts[format_indices["start"]].strip()
end_str = parts[format_indices["end"]].strip()
text = parts[format_indices["text"]].strip()
# Convert timestamp to seconds (ASS format: H:MM:SS.CS where CS is centiseconds)
def ass_time_to_seconds(t):
parts = t.split(":")
if len(parts) == 3:
h, m, s = parts
s_parts = s.split(".")
seconds = float(s_parts[0])
centiseconds = float(s_parts[1]) if len(s_parts) > 1 else 0
return (
int(h) * 3600 + int(m) * 60 + seconds + centiseconds / 100.0
)
return 0
start_sec = ass_time_to_seconds(start_str)
end_sec = ass_time_to_seconds(end_str)
# Clean text of ASS styling tags using pre-compiled patterns
text = _ASS_STYLING_PATTERN.sub("", text) # Remove {tags}
text = _ASS_NEWLINE_N_PATTERN.sub("\n", text) # Convert \N to newline
text = _ASS_NEWLINE_LOWER_N_PATTERN.sub(
"\n", text
) # Convert \n to newline
# Remove chapter markers and metadata tags
text = clean_subtitle_text(text)
if text: # Only add non-empty subtitles
subtitles.append((start_sec, end_sec, text))
return subtitles
def get_sample_voice_text(lang_code):
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"])
def sanitize_name_for_os(name, is_folder=True):
"""
Sanitize a filename or folder name based on the operating system.
Args:
name: The name to sanitize
is_folder: Whether this is a folder name (default: True)
Returns:
Sanitized name safe for the current OS
"""
if not name:
return "audiobook"
system = platform.system()
if system == "Windows":
# Windows illegal characters: < > : " / \ | ? *
# Also can't end with space or dot
# Use pre-compiled pattern for better performance
sanitized = _WINDOWS_ILLEGAL_CHARS_PATTERN.sub("_", name)
# Remove control characters (0-31)
sanitized = _CONTROL_CHARS_PATTERN.sub("_", sanitized)
# Remove trailing spaces and dots
sanitized = sanitized.rstrip(". ")
# Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
reserved = (
["CON", "PRN", "AUX", "NUL"]
+ [f"COM{i}" for i in range(1, 10)]
+ [f"LPT{i}" for i in range(1, 10)]
)
if sanitized.upper() in reserved or sanitized.upper().split(".")[0] in reserved:
sanitized = f"_{sanitized}"
elif system == "Darwin": # macOS
# macOS illegal characters: : (colon is converted to / by the system)
# Also can't start with dot (hidden file) for folders typically
# Use pre-compiled pattern for better performance
sanitized = _MACOS_ILLEGAL_CHARS_PATTERN.sub("_", name)
# Remove control characters
sanitized = _CONTROL_CHARS_PATTERN.sub("_", sanitized)
# Avoid leading dot for folders (creates hidden folders)
if is_folder and sanitized.startswith("."):
sanitized = "_" + sanitized[1:]
else: # Linux and others
# Linux illegal characters: / and null character
# Though / is illegal, most other chars are technically allowed
# Use pre-compiled pattern for better performance
sanitized = _LINUX_ILLEGAL_CHARS_PATTERN.sub("_", name)
# Remove other control characters for safety (excluding \x00 which is already handled)
sanitized = _LINUX_CONTROL_CHARS_PATTERN.sub("_", sanitized)
# Avoid leading dot for folders (creates hidden folders)
if is_folder and sanitized.startswith("."):
sanitized = "_" + sanitized[1:]
# Ensure the name is not empty after sanitization
if not sanitized or sanitized.strip() == "":
sanitized = "audiobook"
# Limit length to 255 characters (common limit across filesystems)
if len(sanitized) > 255:
sanitized = sanitized[:255].rstrip(". ")
return sanitized
def validate_voice_name(voice_name):
"""Validate voice name against VOICES_INTERNAL list (case-insensitive).
Handles both single voices and formulas like 'af_heart*0.5 + am_echo*0.5'.
Args:
voice_name: Voice name or formula string to validate
Returns:
Tuple of (is_valid, invalid_voice_name):
- is_valid: True if all voices in the name/formula are valid
- invalid_voice_name: The first invalid voice found, or None if all valid
"""
from abogen.constants import VOICES_INTERNAL
# Create case-insensitive lookup set (done once per call)
voice_lookup_lower = {v.lower() for v in VOICES_INTERNAL}
voice_name = voice_name.strip()
# Check if it's a formula (contains *)
if "*" in voice_name:
# Extract voice names from formula
voices = voice_name.split("+")
for term in voices:
if "*" in term:
base_voice = term.split("*")[0].strip()
# Case-insensitive comparison
if base_voice.lower() not in voice_lookup_lower:
return False, base_voice
return True, None
else:
# Single voice - case-insensitive comparison
if voice_name.lower() not in voice_lookup_lower:
return False, voice_name
return True, None
def split_text_by_voice_markers(text, default_voice):
"""Split text by voice markers, returning list of (voice, text) tuples.
IMPORTANT: Returns the last voice used so it can persist across chapters.
Voice names are normalized to lowercase to match VOICES_INTERNAL.
Args:
text: Text potentially containing <<VOICE:name>> markers
default_voice: Voice to use if no markers found or before first marker
Returns:
Tuple of (segments_list, last_voice_used, valid_count, invalid_count):
- segments_list: List of (voice_name, segment_text) tuples
- last_voice_used: The voice that should continue into next chapter
- valid_count: Number of valid voice markers processed
- invalid_count: Number of invalid voice markers skipped
"""
from abogen.constants import VOICES_INTERNAL
voice_splits = list(_VOICE_MARKER_SEARCH_PATTERN.finditer(text))
if not voice_splits:
# No voice markers, return entire text with default voice
return [(default_voice, text)], default_voice, 0, 0
segments = []
current_voice = default_voice
valid_markers = 0
invalid_markers = 0
# Text before first marker uses default voice
first_start = voice_splits[0].start()
if first_start > 0:
intro_text = text[:first_start].strip()
if intro_text:
segments.append((current_voice, intro_text))
# Process each voice marker
for idx, match in enumerate(voice_splits):
voice_name = match.group(1).strip()
start = match.end()
end = voice_splits[idx + 1].start() if idx + 1 < len(voice_splits) else len(text)
segment_text = text[start:end].strip()
# Validate voice name
is_valid, invalid_voice = validate_voice_name(voice_name)
if is_valid:
# Normalize to lowercase to match canonical form
# Handle both single voices and formulas
if "*" in voice_name:
# Normalize each voice in the formula
normalized_parts = []
for part in voice_name.split("+"):
part = part.strip()
if "*" in part:
voice_part, weight = part.split("*", 1)
# Find the canonical (lowercase) voice name
voice_part_lower = voice_part.strip().lower()
canonical_voice = next(
(v for v in VOICES_INTERNAL if v.lower() == voice_part_lower),
voice_part.strip()
)
normalized_parts.append(f"{canonical_voice}*{weight.strip()}")
current_voice = " + ".join(normalized_parts)
else:
# Find the canonical (lowercase) voice name
voice_name_lower = voice_name.lower()
current_voice = next(
(v for v in VOICES_INTERNAL if v.lower() == voice_name_lower),
voice_name
)
valid_markers += 1
else:
# Invalid voice - stay with previous voice
invalid_markers += 1
if segment_text:
segments.append((current_voice, segment_text))
# Return segments, last voice, and counts
return segments, current_voice, valid_markers, invalid_markers
File diff suppressed because it is too large Load Diff
+117
View File
@@ -0,0 +1,117 @@
"""
Minimal TTS Backend Interface
This module defines a minimal interface for TTS backends to enable future
extensibility while maintaining backward compatibility with existing Kokoro
implementation.
"""
from abc import ABC, abstractmethod
from typing import Any, Iterator, Optional, Union
class TTSBackend(ABC):
"""
Minimal interface for TTS backends.
This interface is designed to be minimal and focused on the essential
operations needed for text-to-speech conversion.
"""
@abstractmethod
def __call__(
self,
text: str,
voice: Union[str, Any],
speed: float = 1.0,
**kwargs: Any
) -> Iterator[Any]:
"""
Generate speech segments from text.
Args:
text: Text to convert to speech
voice: Voice specification or object
speed: Speed multiplier for speech
**kwargs: Additional backend-specific parameters
Yields:
Speech segments (audio data, timing info, etc.)
"""
pass
class KokoroTTSBackend(TTSBackend):
"""
Implementation of TTSBackend using Kokoro.
This class provides the concrete implementation that maintains
the existing behavior while conforming to the TTSBackend interface.
"""
def __init__(self, lang_code: str, repo_id: str = "hexgrad/Kokoro-82M", device: str = "cpu"):
"""
Initialize Kokoro backend.
Args:
lang_code: Language code for the model
repo_id: Repository ID for the Kokoro model
device: Device to run the model on (cpu, cuda, etc.)
"""
self.lang_code = lang_code
self.repo_id = repo_id
self.device = device
self._pipeline = None
def _get_pipeline(self):
"""Lazy initialization of the Kokoro pipeline."""
if self._pipeline is None:
from abogen.utils import load_numpy_kpipeline
_, KPipeline = load_numpy_kpipeline()
try:
self._pipeline = KPipeline(
lang_code=self.lang_code,
repo_id=self.repo_id,
device=self.device
)
except RuntimeError as e:
if "CUDA" in str(e) and self.device != "cpu":
# Fall back to CPU if CUDA fails
self._pipeline = KPipeline(
lang_code=self.lang_code,
repo_id=self.repo_id,
device="cpu"
)
else:
raise
return self._pipeline
def __call__(
self,
text: str,
voice: Union[str, Any],
speed: float = 1.0,
split_pattern: str = r"\n+",
**kwargs: Any
) -> Iterator[Any]:
"""
Generate speech segments from text using Kokoro.
Args:
text: Text to convert to speech
voice: Voice specification or object
speed: Speed multiplier for speech
split_pattern: Pattern to split text into segments
**kwargs: Additional parameters passed to the pipeline
Yields:
Speech segments
"""
pipeline = self._get_pipeline()
return pipeline(
text,
voice=voice,
speed=speed,
split_pattern=split_pattern,
**kwargs
)
+275
View File
@@ -0,0 +1,275 @@
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)
+289 -55
View File
@@ -1,15 +1,63 @@
import os
import sys
import json
import warnings
import logging
import os
import platform
import subprocess
import re
import shutil
import subprocess
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):
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:
continue
if result is not None:
detected_encoding = result
break
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.
@@ -57,42 +105,200 @@ 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 nonWindows, 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)
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")
return os.path.join(config_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):
@@ -129,6 +335,11 @@ def create_process(cmd, stdin=None, text=True, capture_output=False):
# Determine shell usage: use shell only for string commands
use_shell = isinstance(cmd, str)
if use_shell:
logger.warning(
"Security Warning: create_process called with string command. Prefer using a list of arguments to avoid shell injection risks."
)
kwargs = {
"shell": use_shell,
"stdout": subprocess.PIPE,
@@ -151,11 +362,14 @@ def create_process(cmd, stdin=None, text=True, capture_output=False):
kwargs["stdin"] = stdin
if platform.system() == "Windows":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = subprocess.SW_HIDE
startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined]
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined]
startupinfo.wShowWindow = subprocess.SW_HIDE # type: ignore[attr-defined]
kwargs.update(
{"startupinfo": startupinfo, "creationflags": subprocess.CREATE_NO_WINDOW}
{
"startupinfo": startupinfo,
"creationflags": subprocess.CREATE_NO_WINDOW, # type: ignore[attr-defined]
}
)
# Print the command being executed
@@ -230,14 +444,24 @@ def calculate_text_length(text):
def get_gpu_acceleration(enabled):
try:
import torch
from torch.cuda import is_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 "CUDA GPU available but using CPU.", False
if is_available():
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 more diagnostic info if CUDA is not available
# Gather CUDA diagnostic info if not available
try:
cuda_devices = torch.cuda.device_count()
cuda_error = (
@@ -249,7 +473,7 @@ def get_gpu_acceleration(enabled):
cuda_error = str(e)
return f"CUDA GPU is not available. Using CPU. ({cuda_error})", False
except Exception as e:
return f"Error checking CUDA GPU: {e}", False
return f"Error checking GPU: {e}", False
def prevent_sleep_start():
@@ -259,7 +483,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":
@@ -268,17 +492,24 @@ def prevent_sleep_start():
# Add program name and reason for inhibition
program_name = PROGRAM_NAME
reason = "Prevent sleep during abogen process"
_sleep_procs["Linux"] = create_process(
[
"systemd-inhibit",
f"--who={program_name}",
f"--why={reason}",
"--what=sleep",
"--mode=block",
"sleep",
"infinity",
]
)
# Only attempt to use systemd-inhibit if it's available on the system.
if shutil.which("systemd-inhibit"):
_sleep_procs["Linux"] = create_process(
[
"systemd-inhibit",
f"--who={program_name}",
f"--why={reason}",
"--what=sleep",
"--mode=block",
"sleep",
"infinity",
]
)
else:
# Non-systemd distro or systemd tools not installed: skip inhibition rather than crash
print(
"systemd-inhibit not found: skipping sleep inhibition on this Linux system."
)
def prevent_sleep_end():
@@ -286,18 +517,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
+145
View File
@@ -0,0 +1,145 @@
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
File diff suppressed because it is too large Load Diff
+46 -22
View File
@@ -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)]
+171 -1
View File
@@ -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,169 @@ 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}
+74
View File
@@ -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"]
+9
View File
@@ -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)
+135
View File
@@ -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()
File diff suppressed because it is too large Load Diff
+251
View File
@@ -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<code>[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:<name>" 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_<CODE>.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:<name>" 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:<name>.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
+36
View File
@@ -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 "$@"
+18
View File
@@ -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",
]
+680
View File
@@ -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/<path:name>")
def api_delete_voice_profile(name: str) -> ResponseReturnValue:
delete_profile(name)
return jsonify({"success": True, "profiles": serialize_profiles()})
@api_bp.post("/voice-profiles/<path:name>/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
+34
View File
@@ -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()
+175
View File
@@ -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/<id>/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/<pending_id>")
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/<pending_id>/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/<pending_id>/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/<pending_id>/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/<pending_id>/overrides/<override_id>")
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/<pending_id>/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,
)
+305
View File
@@ -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("/<job_id>")
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("/<job_id>/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("/<job_id>/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("/<job_id>/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("/<job_id>/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("/<job_id>/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("/<job_id>/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("/<job_id>/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("/<job_id>/download/<file_type>")
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("/<job_id>/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("/<job_id>/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("/<job_id>/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("/<job_id>/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()
+388
View File
@@ -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/<step>")
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"))
+296
View File
@@ -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/<run_id>")
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/<run_id>/<filename>")
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,
)
+24
View File
@@ -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()]
+363
View File
@@ -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,
}
+434
View File
@@ -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,
}
File diff suppressed because it is too large Load Diff
+235
View File
@@ -0,0 +1,235 @@
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
try:
import torch # type: ignore[import-not-found]
except Exception:
return "cpu"
system = platform.system()
if system == "Darwin" and platform.processor() == "arm":
try:
if torch.backends.mps.is_available():
return "mps"
except Exception:
pass
return "cpu"
try:
if torch.cuda.is_available():
return "cuda"
except Exception:
pass
return "cpu"
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
devices: List[str] = ["cpu"]
if use_gpu:
preferred = _select_device()
if preferred != "cpu":
devices.insert(0, preferred)
last_error: Optional[Exception] = None
for device in devices:
try:
return get_preview_pipeline(language, device), device != "cpu"
except Exception as exc:
last_error = exc
raise RuntimeError("Preview pipeline is unavailable") from last_error
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:
pipeline, pipeline_uses_gpu = _resolve_pipeline(language, use_gpu)
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, pipeline_uses_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
+67
View File
@@ -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
+752
View File
@@ -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)
+809
View File
@@ -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)
+140
View File
@@ -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/<name>/delete")
def delete_speaker_config_named(name: str) -> ResponseReturnValue:
delete_config(name)
return redirect(url_for("voices.speaker_configs_page"))
File diff suppressed because it is too large Load Diff
+582
View File
@@ -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();
}
+247
View File
@@ -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();
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+45
View File
@@ -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();
}
+221
View File
@@ -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.
}
};
+882
View File
@@ -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();
}
+97
View File
@@ -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();
}
+121
View File
@@ -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);
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+514
View File
@@ -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 };
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}abogen{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
</head>
<body>
<header class="top-bar">
<div class="brand">
<span class="brand__mark">🔊</span>
<span class="brand__name">abogen</span>
<span class="brand__tagline">Verba audiuntur</span>
</div>
<nav class="top-actions">
{% set endpoint = request.endpoint or '' %}
<a href="{{ url_for('main.index') }}" class="btn{% if endpoint == 'main.index' %} is-active{% endif %}">Dashboard</a>
<a href="{{ url_for('voices.voice_profiles') }}" class="btn{% if endpoint == 'voices.voice_profiles' %} is-active{% endif %}">Speaker Studio</a>
<a href="{{ url_for('entities.entities_page') }}" class="btn{% if endpoint == 'entities.entities_page' %} is-active{% endif %}">TTS Overrides</a>
<a href="{{ url_for('books.find_books_page') }}" class="btn{% if endpoint == 'books.find_books_page' %} is-active{% endif %}">Find Books</a>
<a href="{{ url_for('jobs.queue_page') }}" class="btn{% if endpoint in ['jobs.queue_page', 'jobs.job_detail'] %} is-active{% endif %}">Queue</a>
<a href="{{ url_for('settings.settings_page') }}" class="btn{% if endpoint == 'settings.settings_page' %} is-active{% endif %}">Settings</a>
</nav>
</header>
<main class="main">
{% block content %}{% endblock %}
</main>
<footer class="footer">
<span>Need help?</span>
<a href="https://github.com/denizsafak/abogen" target="_blank" rel="noopener">Read the docs</a>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}abogen · Debug WAVs{% endblock %}
{% block content %}
<section class="card">
<h1 class="card__title">Debug WAVs</h1>
<p class="tag">Run ID: <code>{{ run_id }}</code></p>
<p class="hint">Each clip reads: ID, one second pause, then the reference text.</p>
{% if artifacts %}
<div class="field field--wide">
<label>Samples</label>
<ul>
{% for item in artifacts %}
<li>
<div class="field field--stack" style="margin: 0;">
<div>
<strong>{{ item.label }}</strong>
{% if item.text %}
<span class="muted">{{ item.text }}</span>
{% endif %}
</div>
<audio controls preload="none" src="{{ item.url }}"></audio>
</div>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="field field--inline">
<a href="{{ url_for('settings.settings_page', _anchor='debug') }}" class="button button--ghost">Back to Settings</a>
</div>
</section>
{% endblock %}
+275
View File
@@ -0,0 +1,275 @@
{% extends "base.html" %}
{% block title %}abogen · TTS Overrides{% endblock %}
{% block content %}
<section
class="card card--panel"
data-override-root
data-preview-url="{{ url_for('api.api_entity_pronunciation_preview') }}"
data-language="{{ language }}"
>
<h1 class="card__title">TTS Overrides &amp; Pronunciation</h1>
<p class="card__subtitle">Review and refine stored pronunciations so recurring names sound right in every project.</p>
<form class="entity-filter" method="get">
<div class="entity-filter__row">
<label class="field">
<span>Language</span>
<select name="lang">
{% for code, label in languages %}
<option value="{{ code }}" {% if code == language %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="field">
<span>Voice filter</span>
<select name="voice">
{% for option in voice_filter_options %}
<option value="{{ option.value }}" {% if option.value == voice_filter %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
<label class="field">
<span>Pronunciations</span>
<select name="pronunciation">
{% for option in pronunciation_filter_options %}
<option value="{{ option.value }}" {% if option.value == pronunciation_filter %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
<label class="field">
<span>Limit</span>
<input type="number" name="limit" min="10" max="500" value="{{ limit }}">
</label>
</div>
<div class="entity-filter__row entity-filter__row--actions">
<label class="field field--grow">
<span>Search overrides</span>
<input type="search" name="q" value="{{ query }}" placeholder="Search by token, pronunciation, or notes">
</label>
<div class="entity-filter__actions">
<button type="submit" class="button">Apply filters</button>
<a href="{{ url_for('entities.entities_page') }}" class="button button--ghost">Reset</a>
</div>
</div>
</form>
<p class="hint">Looking for saved speaker rosters instead? <a href="{{ url_for('voices.speaker_configs_page') }}">Manage speaker presets</a>.</p>
</section>
<section class="card card--panel">
<header class="entity-summary">
<h2 class="card__title">Overrides for {{ language_label }}</h2>
<p class="card__subtitle">
Showing {{ stats.filtered }} of {{ stats.total }} stored overrides ·
{{ stats.with_pronunciation }} with pronunciations · {{ stats.with_voice }} with assigned voices
</p>
</header>
{% if status_message or status_error %}
<div class="notice {% if status_error %}notice--error{% else %}notice--success{% endif %}">
{{ status_error or status_message }}
</div>
{% endif %}
<div class="entity-table-tools">
<label class="field entity-table-tools__filter">
<span>Filter visible overrides</span>
<input
type="search"
data-role="override-filter"
placeholder="Type a name or voice to filter"
autocomplete="off"
>
</label>
<button type="button" class="button button--ghost" data-role="override-filter-clear">Clear</button>
</div>
<form
id="override-create-form"
class="form-section entity-create"
method="post"
action="{{ url_for('entities.upsert_global_override') }}"
>
<h3 class="form-section__title">Add manual override</h3>
<p class="entity-create__description">Create pronunciations or assign default voices without preparing a job.</p>
<input type="hidden" name="lang" value="{{ language }}">
<input type="hidden" name="action" value="save">
<input type="hidden" name="state_voice" value="{{ voice_filter }}">
<input type="hidden" name="state_pronunciation" value="{{ pronunciation_filter }}">
<input type="hidden" name="state_limit" value="{{ limit }}">
<input type="hidden" name="state_query" value="{{ query }}">
<div class="field-grid field-grid--compact">
<label class="field">
<span>Token</span>
<input type="text" name="token" placeholder="e.g. Arrakis" required>
</label>
<label class="field">
<span>Pronunciation</span>
<input type="text" name="pronunciation" placeholder="Optional phonetic spelling">
</label>
<label class="field">
<span>Voice override</span>
<select name="voice">
<option value="" selected>No override</option>
{% if options.voice_profile_options %}
<optgroup label="Saved mixes">
{% for profile in options.voice_profile_options %}
{% set profile_value = 'profile:' ~ profile.name %}
<option value="{{ profile_value }}">{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
{% endfor %}
</optgroup>
{% endif %}
<optgroup label="Stock voices">
{% for voice in options.voice_catalog %}
<option value="{{ voice.id }}">{{ voice.display_name }} - {{ voice.language_label }} - {{ voice.gender }}</option>
{% endfor %}
</optgroup>
</select>
</label>
</div>
<div class="entity-create__actions">
<button
type="button"
class="button button--ghost"
data-role="preview-button"
data-form-id="override-create-form"
>Preview</button>
<button type="submit" class="button">Add override</button>
</div>
<div class="entity-preview" data-role="preview-container">
<div class="entity-preview__status" data-role="preview-message"></div>
<audio
class="entity-preview__audio"
data-role="preview-audio"
controls
hidden
></audio>
</div>
</form>
{% if overrides %}
<div class="table-wrapper">
<table class="jobs-table overrides-table" data-role="override-table">
<thead>
<tr>
<th scope="col">Token</th>
<th scope="col">Pronunciation</th>
<th scope="col">Voice</th>
<th scope="col">Usage</th>
<th scope="col">Last updated</th>
<th scope="col">Preview</th>
<th scope="col" class="overrides-table__actions-heading">Actions</th>
</tr>
</thead>
<tbody>
{% for override in overrides %}
{% set form_id = "override-form-" ~ loop.index %}
<tr
data-role="override-row"
data-token="{{ override.token | lower }}"
>
<td data-label="Token"><span class="overrides-table__token">{{ override.token }}</span></td>
<td data-label="Pronunciation">
<input
class="overrides-table__input"
type="text"
name="pronunciation"
form="{{ form_id }}"
value="{{ override.pronunciation or '' }}"
placeholder="Add pronunciation"
>
</td>
<td data-label="Voice">
{% 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 %}
<select
class="overrides-table__select"
name="voice"
form="{{ form_id }}"
>
<option value="" {% if not current_voice %}selected{% endif %}>No override</option>
{% if options.voice_profile_options %}
<optgroup label="Saved mixes">
{% for profile in options.voice_profile_options %}
{% set profile_value = 'profile:' ~ profile.name %}
<option value="{{ profile_value }}" {% if current_voice == profile_value %}selected{% endif %}>
{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}
</option>
{% endfor %}
</optgroup>
{% endif %}
<optgroup label="Stock voices">
{% for voice in options.voice_catalog %}
<option value="{{ voice.id }}" {% if current_voice == voice.id %}selected{% endif %}>
{{ voice.display_name }} - {{ voice.language_label }} - {{ voice.gender }}
</option>
{% endfor %}
</optgroup>
{% if current_voice and not known_voice.value %}
<option value="{{ current_voice }}" selected>{{ current_voice }}</option>
{% endif %}
</select>
</td>
<td data-label="Usage">{{ override.usage_count }}</td>
<td data-label="Last updated">{{ override.updated_at_label }}</td>
<td data-label="Preview">
<div class="entity-preview" data-role="preview-container">
<div class="entity-preview__controls">
<button
type="button"
class="button button--ghost button--small"
data-role="preview-button"
data-form-id="{{ form_id }}"
>Preview</button>
<span class="entity-preview__status" data-role="preview-message"></span>
</div>
<audio
class="entity-preview__audio"
data-role="preview-audio"
controls
hidden
></audio>
</div>
</td>
<td data-label="Actions">
<form
id="{{ form_id }}"
class="overrides-table__form"
method="post"
action="{{ url_for('entities.upsert_global_override') }}"
>
<input type="hidden" name="lang" value="{{ language }}">
<input type="hidden" name="token" value="{{ override.token }}">
<input type="hidden" name="state_voice" value="{{ voice_filter }}">
<input type="hidden" name="state_pronunciation" value="{{ pronunciation_filter }}">
<input type="hidden" name="state_limit" value="{{ limit }}">
<input type="hidden" name="state_query" value="{{ query }}">
<div class="overrides-table__actions">
<button type="submit" class="button button--small" name="action" value="save">Save</button>
<button type="submit" class="button button--ghost button--small button--danger" name="action" value="delete">Delete</button>
</div>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="hint" data-role="filter-empty" hidden>No overrides match your current filter.</p>
{% else %}
<p class="hint">No overrides matched your filters. Try adjusting the search or create overrides from the Entities step while preparing a job.</p>
{% endif %}
</section>
{% endblock %}
{% block scripts %}
{{ super() }}
<script src="{{ url_for('static', filename='entities.js') }}" defer></script>
{% endblock %}
+100
View File
@@ -0,0 +1,100 @@
{% extends "base.html" %}
{% block title %}abogen · Find Books{% endblock %}
{% block content %}
<section class="card">
<div class="card__title">Find Books</div>
<p class="card__subtitle">Browse trusted public-domain libraries or drill into your Calibre catalog without leaving abogen.</p>
<div class="card__body find-books__body">
<div class="find-books__resources">
<article class="resource-tile">
<h2>Standard Ebooks</h2>
<p>Discover meticulously produced public-domain ebooks with consistent typography, metadata, and clean EPUB sources.</p>
<p>
<a class="button" href="https://standardebooks.org/ebooks" target="_blank" rel="noopener">
Visit standardebooks.org
</a>
</p>
<p class="hint">Opens in a new tab so you can grab EPUB downloads and drag them into abogen.</p>
</article>
<article class="resource-tile">
<h2>Project Gutenberg</h2>
<p>The worlds largest public-domain ebook library, offering thousands of EPUB and plain-text editions sourced from volunteer transcriptions.</p>
<p>
<a class="button" href="https://gutenberg.org/ebooks/" target="_blank" rel="noopener">
Visit gutenberg.org
</a>
</p>
<p class="hint">Download the EPUB version for best results, then import it into abogen.</p>
</article>
</div>
<div class="find-books__opds">
<h2>Calibre catalog</h2>
<p class="hint">Wire abogen to your Calibre OPDS server to browse, search, and import titles without leaving the app.</p>
{% if not opds_available %}
<div class="alert alert--warning">Enable the Calibre OPDS integration in settings to browse your library here.</div>
{% else %}
<button type="button" class="button" data-action="open-opds-modal">Browse Calibre catalog</button>
{% endif %}
</div>
</div>
</section>
{% if opds_available %}
<div class="modal" data-role="opds-modal" hidden>
<div class="modal__overlay" data-role="opds-modal-close" tabindex="-1"></div>
<div class="modal__content card card--modal opds-modal" role="dialog" aria-modal="true" aria-labelledby="opds-modal-title">
<header class="modal__header opds-modal__header">
<div class="modal__heading">
<p class="modal__eyebrow">Calibre OPDS</p>
<h2 class="modal__title" id="opds-modal-title">Browse your catalog</h2>
</div>
<form class="opds-modal__search" data-role="opds-search">
<label class="visually-hidden" for="opds-search-input">Search your Calibre catalog</label>
<div class="opds-search__field">
<svg class="opds-search__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M15.5 14h-.79l-.28-.27A6.5 6.5 0 0016 9.5 6.5 6.5 0 109.5 16a6.5 6.5 0 004.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1114 9.5 4.5 4.5 0 019.5 14z" />
</svg>
<input type="search" id="opds-search-input" name="q" placeholder="Search by title, author, or keyword" autocomplete="off">
</div>
<div class="opds-search__actions">
<button type="submit" class="button">Search</button>
<button type="button" class="button button--ghost" data-action="opds-refresh">Reset</button>
</div>
</form>
<button type="button" class="button button--ghost opds-modal__close" data-role="opds-modal-close">Close</button>
</header>
<div class="opds-modal__tabs" data-role="opds-tabs"></div>
<div class="modal__body opds-modal__body">
<div class="opds-browser" data-role="opds-browser">
<div class="opds-modal__status" data-role="opds-status"></div>
<div class="opds-modal__nav" data-role="opds-nav"></div>
<div class="opds-modal__alpha" data-role="opds-alpha-picker"></div>
<div class="opds-modal__results-wrap">
<ul class="opds-modal__results" data-role="opds-results"></ul>
</div>
<div class="opds-modal__nav opds-modal__nav--bottom" data-role="opds-nav-bottom"></div>
</div>
</div>
</div>
</div>
{% endif %}
{% with pending=None, readonly=False, active_step='settings' %}
{% include "partials/upload_modal.html" %}
{% endwith %}
{% include "partials/reader_modal.html" %}
{% endblock %}
{% block scripts %}
{{ super() }}
<script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script>
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
{% if opds_available %}
<script type="module" src="{{ url_for('static', filename='find_books.js') }}"></script>
{% endif %}
{% endblock %}
+97
View File
@@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}abogen · Dashboard{% endblock %}
{% block content %}
<style>
.stats-overview {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.stat-card {
background: var(--bg-surface, #fff);
border: 1px solid var(--border-subtle, #ddd);
border-radius: var(--radius-md, 8px);
padding: 1rem;
flex: 1;
min-width: 120px;
text-align: center;
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0,0,0,0.05));
}
.stat-card__value {
display: block;
font-size: 2rem;
font-weight: 700;
color: var(--text-main, #333);
line-height: 1.2;
}
.stat-card__label {
display: block;
font-size: 0.75rem;
color: var(--text-muted, #666);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
</style>
<section class="stats-overview">
<div class="stat-card">
<span class="stat-card__value">{{ stats.total }}</span>
<span class="stat-card__label">Total Jobs</span>
</div>
<div class="stat-card">
<span class="stat-card__value">{{ stats.completed }}</span>
<span class="stat-card__label">Converted</span>
</div>
<div class="stat-card">
<span class="stat-card__value">{{ stats.running }}</span>
<span class="stat-card__label">Converting</span>
</div>
<div class="stat-card">
<span class="stat-card__value">{{ stats.pending }}</span>
<span class="stat-card__label">Pending</span>
</div>
<div class="stat-card">
<span class="stat-card__value">{{ stats.failed }}</span>
<span class="stat-card__label">Failed</span>
</div>
</section>
<section class="card card--workflow">
<h1 class="card__title">Create a New Audiobook</h1>
<p class="card__subtitle">Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and entities in the next steps.</p>
<div class="upload-card__dropzone" data-role="upload-dropzone" tabindex="0" role="button" aria-label="Open upload settings or drop a manuscript file">
<div class="upload-card__dropzone-content">
<span class="upload-card__icon" aria-hidden="true">&uarr;</span>
<p class="upload-card__headline">Drop your manuscript to begin</p>
<p class="upload-card__hint">Drag &amp; drop a supported file here, or click to choose one in the next step.</p>
<p class="upload-card__filename" data-role="upload-dropzone-filename" hidden></p>
<button type="button" class="button" data-role="open-upload-modal">Open upload &amp; settings</button>
{% if opds_available %}
<div class="wizard-card__body">
<p>Upload an EPUB or text file to begin.</p>
<a class="button button--ghost" href="{{ url_for('books.find_books_page') }}">Browse Calibre library</a>
</div>
{% endif %}
</div>
</div>
</section>
{% with pending=None, readonly=False, active_step='settings' %}
{% include "partials/upload_modal.html" %}
{% endwith %}
{% include "partials/reader_modal.html" %}
{% endblock %}
{% block scripts %}
{{ super() }}
<script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script>
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
{% endblock %}
+158
View File
@@ -0,0 +1,158 @@
{% extends "base.html" %}
{% block title %}Job · {{ job.original_filename }}{% endblock %}
{% block content %}
<section class="card">
<div class="card__title">{{ job.original_filename }}</div>
<div class="grid grid--two">
<article>
<h2>Settings</h2>
<ul>
<li><strong>Voice:</strong> {{ job.voice }}</li>
<li><strong>Language:</strong> {{ options.languages.get(job.language, job.language) }}</li>
{% if job.voice_profile %}
<li><strong>Voice profile:</strong> {{ job.voice_profile }}</li>
{% endif %}
<li><strong>Speed:</strong> {{ '%.2f' | format(job.speed) }}×</li>
<li><strong>Subtitle mode:</strong> {{ job.subtitle_mode }}</li>
<li><strong>Audio format:</strong> {{ job.output_format }}</li>
<li><strong>Subtitle format:</strong> {{ job.subtitle_format }}</li>
<li><strong>GPU:</strong> {{ 'Yes' if job.use_gpu else 'No' }}</li>
<li><strong>Save chapters separately:</strong> {{ 'Yes' if job.save_chapters_separately else 'No' }}</li>
<li><strong>Merge at end:</strong> {{ 'Yes' if job.merge_chapters_at_end else 'No' }}</li>
<li><strong>Separate chapter format:</strong> {{ job.separate_chapters_format|upper }}</li>
<li><strong>Silence between chapters:</strong> {{ '%.1f'|format(job.silence_between_chapters) }}s</li>
<li><strong>Chapter intro delay:</strong> {{ '%.1f'|format(job.chapter_intro_delay) }}s</li>
<li><strong>Title intro:</strong> {{ 'Yes' if job.read_title_intro else 'No' }}</li>
<li><strong>Closing outro:</strong> {{ 'Yes' if job.read_closing_outro else 'No' }}</li>
<li><strong>Normalize chapter openings:</strong> {{ 'Yes' if job.normalize_chapter_opening_caps else 'No' }}</li>
<li><strong>Prefix chapter titles:</strong> {{ 'Yes' if job.auto_prefix_chapter_titles else 'No' }}</li>
<li><strong>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</li>
<li><strong>Project folder:</strong> {{ 'Yes' if job.save_as_project else 'No' }}</li>
<li><strong>Chunk granularity:</strong> {{ job.chunk_level|replace('_', ' ')|title }}</li>
<li><strong>Speaker analysis threshold:</strong> {{ job.speaker_analysis_threshold }}</li>
<li><strong>Generate EPUB 3:</strong> {{ 'Yes' if job.generate_epub3 else 'No' }}</li>
</ul>
</article>
<article>
<h2>Status</h2>
<p><span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span></p>
<p>Created: {{ job.created_at|datetimeformat }}</p>
<p>Started: {{ job.started_at|datetimeformat }}</p>
<p>Finished: {{ job.finished_at|datetimeformat }}</p>
<p>Characters: {{ job.processed_characters }} / {{ job.total_characters or '—' }}</p>
{% set flags = downloads or {} %}
{% if flags.get('m4b') %}
<p><a class="button" href="{{ url_for('jobs.download_file', job_id=job.id, file_type='audio') }}">Download M4B</a></p>
{% elif flags.get('audio') %}
<p><a class="button" href="{{ url_for('jobs.download_file', job_id=job.id, file_type='audio') }}">Download audio</a></p>
{% endif %}
{% if flags.get('epub3') %}
<p><a class="button button--ghost" href="{{ url_for('jobs.job_epub', job_id=job.id) }}">Download EPUB 3</a></p>
{% endif %}
{% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %}
<form action="{{ url_for('jobs.cancel_job', job_id=job.id) }}" method="post">
<button type="submit" class="button button--ghost">Cancel job</button>
</form>
{% elif job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED] %}
<form action="{{ url_for('jobs.retry_job', job_id=job.id) }}" method="post">
<button type="submit" class="button">Retry job</button>
</form>
{% endif %}
</article>
</div>
</section>
{% set analysis = job.speaker_analysis or {} %}
{% if analysis %}
{% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
<section class="card">
<div class="card__title">Speaker analysis</div>
<div class="grid grid--two">
<article>
<h2>Summary</h2>
{% set stats = analysis.get('stats', {}) %}
<ul>
<li><strong>Total chunks:</strong> {{ stats.get('total_chunks', '—') }}</li>
<li><strong>Explicit dialogue chunks:</strong> {{ stats.get('explicit_chunks', '—') }}</li>
<li><strong>Active speakers:</strong> {{ stats.get('active_speakers', '—') }}</li>
<li><strong>Unique speakers observed:</strong> {{ stats.get('unique_speakers', '—') }}</li>
<li><strong>Suppressed speakers:</strong> {{ stats.get('suppressed', 0) }}</li>
</ul>
</article>
<article>
<h2>Detected speakers</h2>
{% set speakers = analysis.get('speakers', {}) %}
{% set narrator_id = analysis.get('narrator', 'narrator') %}
{% if speakers %}
<ul>
{% 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) %}
<li>
<div class="speaker-line">
<strong>{{ payload.get('label', speaker_id|replace('_', ' ')|title) }}</strong>
<button type="button"
class="icon-button speaker-list__preview"
data-role="speaker-preview"
data-job-id="{{ job.id }}"
data-speaker-id="{{ speaker_id }}"
data-preview-text="{{ preview_text|e }}"
data-language="{{ job.language }}"
data-voice="{{ payload.get('resolved_voice') or payload.get('voice_formula') or payload.get('voice') or job.voice }}"
data-speed="{{ '%.2f'|format(job.speed) }}"
data-use-gpu="{{ 'true' if job.use_gpu else 'false' }}"
aria-label="Preview pronunciation for {{ payload.get('label', speaker_id|replace('_', ' ')|title) }}"
title="Preview pronunciation">
<span class="icon-button__glyph" aria-hidden="true">🔊</span>
<span class="spinner spinner--sm spinner--muted" aria-hidden="true"></span>
</button>
</div>
<div class="meta">
<span>{{ payload.get('count', 0) }} chunks</span>
<span>Confidence: {{ payload.get('confidence', 'low')|title }}</span>
{% if payload.get('pronunciation') %}
<span>Pronunciation: {{ payload.get('pronunciation') }}</span>
{% endif %}
</div>
{% set quotes = payload.get('sample_quotes', []) %}
{% if quotes %}
<details>
<summary>Sample quotes</summary>
<ul>
{% for quote in quotes %}
<li>{{ quote }}</li>
{% endfor %}
</ul>
</details>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>No additional speakers detected.</p>
{% endif %}
{% set suppressed = analysis.get('suppressed_details') or analysis.get('suppressed', []) %}
{% if suppressed %}
<p class="muted">
Suppressed speakers:
{% if suppressed[0] is string %}
{{ suppressed | join(', ') }}
{% else %}
{{ suppressed | map(attribute='label') | join(', ') }}
{% endif %}
</p>
{% endif %}
</article>
</div>
</section>
{% endif %}
{% include "partials/logs_section.html" %}
{% endblock %}
{% block scripts %}
{{ super() }}
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
{% endblock %}
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Job Not Found{% endblock %}
{% block content %}
<section class="card logs-static">
<div class="card__title-row">
<div class="card__title">Job Not Found</div>
</div>
<p>This job no longer exists. It may have been deleted or the server was restarted.</p>
<p><a href="{{ url_for('main.index') }}" class="button button--primary">Return to Dashboard</a></p>
</section>
{% endblock %}
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Logs · {{ job.original_filename }}{% endblock %}
{% block content %}
<section class="card logs-static">
{% include "partials/logs.html" %}
{% if job.logs %}
<details class="log-copy">
<summary>Copy raw log output</summary>
<textarea class="log-copy__textarea" readonly>{{- log_text -}}</textarea>
</details>
{% endif %}
</section>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More