mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
Compare commits
335
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bd2c109d8 | ||
|
|
9fa81fbe1e | ||
|
|
9fd9fad238 | ||
|
|
ca5c5ee62d | ||
|
|
e51be95bc1 | ||
|
|
2223f46c9e | ||
|
|
8322f7f416 | ||
|
|
2c15d2f78a | ||
|
|
cc9c2a22ba | ||
|
|
d1366b445d | ||
|
|
c224cdbb56 | ||
|
|
d30415ffe7 | ||
|
|
083f1eb09b | ||
|
|
30929e8f4e | ||
|
|
ded73843c9 | ||
|
|
7b1f4f54ee | ||
|
|
8cbfc15028 | ||
|
|
79957d09ed | ||
|
|
925ef51edc | ||
|
|
be7c7baa75 | ||
|
|
6ab863e65f | ||
|
|
950eb317f0 | ||
|
|
bd30939d27 | ||
|
|
ace1022da4 | ||
|
|
eb7d135bc3 | ||
|
|
5ae153f841 | ||
|
|
3c800df450 | ||
|
|
fc0af420c6 | ||
|
|
7677f5a1e2 | ||
|
|
0e726b97c4 | ||
|
|
b5b157879f | ||
|
|
27041c6475 | ||
|
|
7df4345486 | ||
|
|
5b174737ad | ||
|
|
1219b408b5 | ||
|
|
b2058ef3ee | ||
|
|
d1e0e0a536 | ||
|
|
4272c847f7 | ||
|
|
89645682f1 | ||
|
|
e2b2f610a6 | ||
|
|
76cab1192c | ||
|
|
1fdd1c8540 | ||
|
|
ede5343e0c | ||
|
|
63d179ba19 | ||
|
|
90786d2bed | ||
|
|
c2209eeb2a | ||
|
|
899c9f5aa5 | ||
|
|
938e122166 | ||
|
|
5303dcf681 | ||
|
|
b47952b857 | ||
|
|
0fe8ee0ad4 | ||
|
|
5dd53354a1 | ||
|
|
eb57744533 | ||
|
|
de8debb6b1 | ||
|
|
e47536e2ab | ||
|
|
32f616e90b | ||
|
|
19e98c3ad6 | ||
|
|
eabfe87ffb | ||
|
|
9a72c209b3 | ||
|
|
888c737293 | ||
|
|
77eb58bdff | ||
|
|
95d5921e67 | ||
|
|
08ebedc177 | ||
|
|
1c33a99554 | ||
|
|
015435adb6 | ||
|
|
0afaaf561b | ||
|
|
fdf95dbae7 | ||
|
|
05d7c28128 | ||
|
|
aa71783e5a | ||
|
|
0c8a7cdf81 | ||
|
|
daf4d78766 | ||
|
|
66a97835f0 | ||
|
|
0d097f562a | ||
|
|
a7ae13558c | ||
|
|
5735f05be0 | ||
|
|
c3673b6dc7 | ||
|
|
675ed437c8 | ||
|
|
5010616e85 | ||
|
|
65eecbcfee | ||
|
|
c9b17b6f49 | ||
|
|
7722863435 | ||
|
|
6a1928483b | ||
|
|
2a20dfab6f | ||
|
|
a2f186e7a1 | ||
|
|
56f81ef892 | ||
|
|
968c673e58 | ||
|
|
174ff4232f | ||
|
|
c43659005e | ||
|
|
e3cdd1a9ca | ||
|
|
ed31aaf632 | ||
|
|
0820c40b14 | ||
|
|
0ac2810515 | ||
|
|
1c3fd9e4cf | ||
|
|
ef2b045b69 | ||
|
|
196e2cdf2e | ||
|
|
a501e96b12 | ||
|
|
1e13c901fe | ||
|
|
609af66748 | ||
|
|
2e1e9af995 | ||
|
|
40eb294fec | ||
|
|
b502ff9068 | ||
|
|
a14469c1d0 | ||
|
|
aa53438acf | ||
|
|
baef01ad45 | ||
|
|
7eceb601f8 | ||
|
|
b3aaa94831 | ||
|
|
c7dd8cf13a | ||
|
|
7db1779ca5 | ||
|
|
76b3aae341 | ||
|
|
07c78255e8 | ||
|
|
040bce1bc1 | ||
|
|
540b191d5b | ||
|
|
079c36702e | ||
|
|
59c8568348 | ||
|
|
f2ba8f692c | ||
|
|
2e1f38a98b | ||
|
|
3d073e8e55 | ||
|
|
cc641cec78 | ||
|
|
aa45228844 | ||
|
|
6cc2b4e8a4 | ||
|
|
252be6d4b7 | ||
|
|
bce1419d92 | ||
|
|
b0a3e1dbd9 | ||
|
|
8a2bd7ec50 | ||
|
|
e96c19ace6 | ||
|
|
a5f2bf7fbe | ||
|
|
4bfce0f900 | ||
|
|
5b548cc155 | ||
|
|
c69e5af54d | ||
|
|
48e9534b68 | ||
|
|
a9489dec2d | ||
|
|
94983c39bb | ||
|
|
963b020c0f | ||
|
|
83e0841274 | ||
|
|
726decfaf4 | ||
|
|
319037fe8c | ||
|
|
c00cbaef69 | ||
|
|
124e5b33db | ||
|
|
fd93e1c9e9 | ||
|
|
d08cbcfdc9 | ||
|
|
0a2b3533f4 | ||
|
|
ad70c630c7 | ||
|
|
39628453de | ||
|
|
4e678c7e13 | ||
|
|
7cc60aacf6 | ||
|
|
7ee40d6aca | ||
|
|
775e05f94c | ||
|
|
55a4f958ee | ||
|
|
6c633e9167 | ||
|
|
566158c132 | ||
|
|
bf43d1799d | ||
|
|
7293b4b826 | ||
|
|
34f2d712b3 | ||
|
|
5ef2612df6 | ||
|
|
550fdbf537 | ||
|
|
290c265d5e | ||
|
|
ed418ac11d | ||
|
|
e6abf2e541 | ||
|
|
f5d95547c5 | ||
|
|
795bd8f1aa | ||
|
|
c4e2a5ef0d | ||
|
|
f57d1994cf | ||
|
|
6482a56479 | ||
|
|
8ddfd01dff | ||
|
|
ac551abd55 | ||
|
|
711858ce2c | ||
|
|
5cca6235e1 | ||
|
|
115ab2a0f2 | ||
|
|
bf5dfddee6 | ||
|
|
23a89a618b | ||
|
|
4ad384dc9f | ||
|
|
5ec06e7d49 | ||
|
|
7f75aa8209 | ||
|
|
cd2e5c9150 | ||
|
|
f477703b1a | ||
|
|
d0532347be | ||
|
|
3ef95900cc | ||
|
|
71dfbd49b6 | ||
|
|
166162931c | ||
|
|
aba72524ce | ||
|
|
028384e6ee | ||
|
|
504b5ab5e5 | ||
|
|
95f0307be1 | ||
|
|
67453ed17c | ||
|
|
4491674fac | ||
|
|
387d324ea0 | ||
|
|
acede32559 | ||
|
|
4e06601b04 | ||
|
|
f2ab62aeab | ||
|
|
72b2ada9ac | ||
|
|
af90da0b99 | ||
|
|
d238524cbb | ||
|
|
2d61674e90 | ||
|
|
28652d9e78 | ||
|
|
1d9dcf8800 | ||
|
|
5322c6406d | ||
|
|
1d4316ad18 | ||
|
|
8aaf183f75 | ||
|
|
eb28b01c06 | ||
|
|
65ec77180d | ||
|
|
0906b590a6 | ||
|
|
b9e3fb5e1c | ||
|
|
fe5419565d | ||
|
|
688d550f13 | ||
|
|
c7356338c2 | ||
|
|
13c6b120c9 | ||
|
|
9acec3c309 | ||
|
|
013c5c2dbb | ||
|
|
e7f6f0221d | ||
|
|
963ef71647 | ||
|
|
b8a6ca7091 | ||
|
|
cf6ccf0171 | ||
|
|
eb4704bc26 | ||
|
|
0fc32e9f3a | ||
|
|
67f4493d06 | ||
|
|
de0f17cd57 | ||
|
|
6e536c6e3b | ||
|
|
9d35e39e89 | ||
|
|
a81ed70b14 | ||
|
|
7951a4d992 | ||
|
|
10cd2c993c | ||
|
|
0259963eb8 | ||
|
|
6b5255a80d | ||
|
|
0a6d09445d | ||
|
|
6ae8b827d2 | ||
|
|
6248dfdc0c | ||
|
|
7ca030d67d | ||
|
|
bccfd9f5c5 | ||
|
|
f7541388e6 | ||
|
|
62d4acc4d6 | ||
|
|
0922ad4727 | ||
|
|
a5543528ba | ||
|
|
14cc7103cf | ||
|
|
1f0cfef310 | ||
|
|
5971630803 | ||
|
|
b3dc73cb15 | ||
|
|
0f549a56e4 | ||
|
|
cab5221c46 | ||
|
|
d114ae60fe | ||
|
|
3e54007baa | ||
|
|
ded405ff70 | ||
|
|
eb78347a85 | ||
|
|
a2c08b1b4d | ||
|
|
9ee53fc886 | ||
|
|
82d780db0d | ||
|
|
17534d7890 | ||
|
|
b4c9a1ced8 | ||
|
|
8e9c9e6077 | ||
|
|
7d11ebc338 | ||
|
|
e6d2649d5d | ||
|
|
091a3785e6 | ||
|
|
ec6dc25e23 | ||
|
|
f8d2e860fe | ||
|
|
b8b0aea890 | ||
|
|
7203037a42 | ||
|
|
93fe48f601 | ||
|
|
610091c0d9 | ||
|
|
6266819670 | ||
|
|
4a62363064 | ||
|
|
ccf5a11222 | ||
|
|
8ed5202ab8 | ||
|
|
4b0aa50da6 | ||
|
|
97fd8b85fc | ||
|
|
5be4da1cce | ||
|
|
3e7bbd648c | ||
|
|
e15d2b12a3 | ||
|
|
aed0df1b09 | ||
|
|
60fe7e7ffb | ||
|
|
20327faf0c | ||
|
|
be37f03109 | ||
|
|
f35b35c7a9 | ||
|
|
283651e7dd | ||
|
|
4f46eb74f4 | ||
|
|
15c1220ba3 | ||
|
|
443dee09b6 | ||
|
|
3a91e79cb6 | ||
|
|
258c3549f7 | ||
|
|
e7408a06b8 | ||
|
|
e37ef99856 | ||
|
|
01a6267c7a | ||
|
|
f0b6976d12 | ||
|
|
6bd301b707 | ||
|
|
205c435e72 | ||
|
|
9828730061 | ||
|
|
da0c453205 | ||
|
|
63f9474741 | ||
|
|
b0875c7486 | ||
|
|
881717c4cb | ||
|
|
d01887f31b | ||
|
|
1bae37477b | ||
|
|
3b07df9708 | ||
|
|
b0cfd8d687 | ||
|
|
41f56a8491 | ||
|
|
bacf1b2f9e | ||
|
|
6181f12bd4 | ||
|
|
da56966247 | ||
|
|
783dbcf8f2 | ||
|
|
b35ab7b002 | ||
|
|
4b3aa227b7 | ||
|
|
42334e92a4 | ||
|
|
a51fd25271 | ||
|
|
02da72434b | ||
|
|
0dd74412d1 | ||
|
|
ea1c7bd93e | ||
|
|
718a3fa1c0 | ||
|
|
85310ad916 | ||
|
|
c8e9eb6fd2 | ||
|
|
43bee0b76e | ||
|
|
fd8ede318f | ||
|
|
e76701ab32 | ||
|
|
0d74171bb5 | ||
|
|
477c5055b4 | ||
|
|
523e55d8a4 | ||
|
|
c19050261c | ||
|
|
dc7a115e2e | ||
|
|
153d5ba92c | ||
|
|
26e0e764db | ||
|
|
7d132e6fcc | ||
|
|
b75e1c1b2e | ||
|
|
fc4c41c7cf | ||
|
|
f3aaeda37b | ||
|
|
323ab08f38 | ||
|
|
54bc632b2e | ||
|
|
5497697741 | ||
|
|
1b907be322 | ||
|
|
e44ba1e903 | ||
|
|
97d81d78e2 | ||
|
|
01209f6878 | ||
|
|
2e402f6b5b | ||
|
|
0c47067cb8 | ||
|
|
b718dae1b3 | ||
|
|
9ba2362528 | ||
|
|
1629d3e80c | ||
|
|
66a0679e18 | ||
|
|
338ff104e8 |
@@ -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.
|
||||||
@@ -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']
|
||||||
@@ -19,6 +19,7 @@ __pycache__/
|
|||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
.env/
|
.env/
|
||||||
|
.env
|
||||||
.venv/
|
.venv/
|
||||||
test/
|
test/
|
||||||
|
|
||||||
@@ -30,6 +31,11 @@ python_embedded/
|
|||||||
|
|
||||||
# abogen
|
# abogen
|
||||||
*config.json
|
*config.json
|
||||||
|
config/
|
||||||
|
storage/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
.old/
|
.old/
|
||||||
|
test_assets/
|
||||||
|
dev_notes/
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
+40
-2
@@ -1,3 +1,41 @@
|
|||||||
|
# 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
|
# 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.
|
- **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: **"Use silent gaps between subtitles"**: Prevents unnecessary audio speed-up by letting speech continue into the silent gaps between subtitles.
|
||||||
@@ -26,7 +64,7 @@
|
|||||||
- Fixed `/` and `\` path display by normalizing paths.
|
- 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 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 taskbar icon not appearing correctly in Windows.
|
||||||
- Fixed “Go to folder” button not opening the chapter output directory when only separate chapters were generated.
|
- Fixed "Go to folder" button not opening the chapter output directory when only separate chapters were generated.
|
||||||
- Improvements in code and documentation.
|
- Improvements in code and documentation.
|
||||||
|
|
||||||
# 1.1.9
|
# 1.1.9
|
||||||
@@ -155,7 +193,7 @@
|
|||||||
- Improved invalid profile handling in the voice mixer.
|
- Improved invalid profile handling in the voice mixer.
|
||||||
|
|
||||||
# v1.0.3
|
# 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.
|
- 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.
|
- 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.
|
- Added icons for flags and genders in the GUI, making it easier to identify different options.
|
||||||
|
|||||||
+57
-22
@@ -20,6 +20,7 @@ set MISAKI_LANG=en
|
|||||||
for /f "delims=: tokens=*" %%A in ('findstr /b ::: "%~f0"') do @echo(%%A
|
for /f "delims=: tokens=*" %%A in ('findstr /b ::: "%~f0"') do @echo(%%A
|
||||||
|
|
||||||
set CURRENT_DIR="%CD%"
|
set CURRENT_DIR="%CD%"
|
||||||
|
set "UV_CACHE_DIR=%~dp0.uv_cache"
|
||||||
set NAME=abogen
|
set NAME=abogen
|
||||||
set PROJECTFOLDER=abogen
|
set PROJECTFOLDER=abogen
|
||||||
set RUN=python_embedded\Scripts\abogen.exe
|
set RUN=python_embedded\Scripts\abogen.exe
|
||||||
@@ -29,6 +30,28 @@ set refrenv=%PROJECTFOLDER%\refrenv.bat
|
|||||||
set PYTHON_PATH=python_embedded\pythonw.exe
|
set PYTHON_PATH=python_embedded\pythonw.exe
|
||||||
set PYTHON_CONSOLE_PATH=python_embedded\python.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
|
:: Check for updates
|
||||||
echo Checking for updates...
|
echo Checking for updates...
|
||||||
set VERSION_FILE=%PROJECTFOLDER%\VERSION
|
set VERSION_FILE=%PROJECTFOLDER%\VERSION
|
||||||
@@ -197,18 +220,19 @@ if not "%~1"=="" (
|
|||||||
echo Open with: "%~1"
|
echo Open with: "%~1"
|
||||||
)
|
)
|
||||||
|
|
||||||
:: Update pip
|
:: Update pip and install uv
|
||||||
echo Updating pip...
|
echo Updating pip and installing uv...
|
||||||
%PYTHON_CONSOLE_PATH% -m pip install --upgrade pip --no-warn-script-location
|
%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 (
|
if errorlevel 1 (
|
||||||
echo Failed to update pip.
|
echo Failed to install uv.
|
||||||
pause
|
pause
|
||||||
exit /b
|
exit /b
|
||||||
)
|
)
|
||||||
|
|
||||||
:: Install docopt's fixed version
|
:: Install docopt's fixed version
|
||||||
echo Installing fixed version of docopt...
|
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 (
|
if errorlevel 1 (
|
||||||
echo Failed to install fixed version of docopt.
|
echo Failed to install fixed version of docopt.
|
||||||
pause
|
pause
|
||||||
@@ -217,7 +241,7 @@ if errorlevel 1 (
|
|||||||
|
|
||||||
:: Install progress's fixed version
|
:: Install progress's fixed version
|
||||||
echo Installing fixed version of progress...
|
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 (
|
if errorlevel 1 (
|
||||||
echo Failed to install fixed version of progress.
|
echo Failed to install fixed version of progress.
|
||||||
pause
|
pause
|
||||||
@@ -226,7 +250,7 @@ if errorlevel 1 (
|
|||||||
|
|
||||||
:: Install setup requirements
|
:: Install setup requirements
|
||||||
echo Installing 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 (
|
if errorlevel 1 (
|
||||||
echo Failed to install setup requirements.
|
echo Failed to install setup requirements.
|
||||||
pause
|
pause
|
||||||
@@ -235,32 +259,43 @@ if errorlevel 1 (
|
|||||||
|
|
||||||
:: Install gpustat
|
:: Install gpustat
|
||||||
echo Installing 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 (
|
if errorlevel 1 (
|
||||||
echo Failed to install gpustat.
|
echo Failed to install gpustat.
|
||||||
pause
|
pause
|
||||||
exit /b
|
exit /b
|
||||||
)
|
)
|
||||||
|
|
||||||
:: Install project and dependencies from pyproject.toml
|
:: Install project based on user selection
|
||||||
echo Checking and installing project dependencies...
|
if "%INSTALL_SOURCE%"=="pypi" (
|
||||||
if exist %PYPROJECT_FILE% (
|
echo Installing stable version from PyPI...
|
||||||
echo Installing project from pyproject.toml...
|
%PYTHON_CONSOLE_PATH% -m uv pip install --system abogen
|
||||||
%PYTHON_CONSOLE_PATH% -m pip install -e . --no-warn-script-location
|
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo Failed to install from pyproject.toml.
|
echo Failed to install abogen from PyPI.
|
||||||
pause
|
pause
|
||||||
exit /b
|
exit /b
|
||||||
)
|
)
|
||||||
) else (
|
) else (
|
||||||
echo Warning: pyproject.toml not found in current directory.
|
echo Checking and installing project dependencies...
|
||||||
pause
|
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" (
|
if "%MISAKI_LANG%" NEQ "en" (
|
||||||
echo Configuring language pack: %MISAKI_LANG%
|
echo Configuring language pack: %MISAKI_LANG%
|
||||||
%PYTHON_CONSOLE_PATH% -m pip install misaki[%MISAKI_LANG%] --upgrade --no-warn-script-location
|
%PYTHON_CONSOLE_PATH% -m uv pip install --system misaki[%MISAKI_LANG%] --upgrade
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo Failed to install misaki language pack.
|
echo Failed to install misaki language pack.
|
||||||
pause
|
pause
|
||||||
@@ -275,13 +310,13 @@ for /f %%i in ('%PYTHON_CONSOLE_PATH% -c "from abogen.is_nvidia import check; pr
|
|||||||
echo.
|
echo.
|
||||||
echo Checking CUDA availability...
|
echo Checking CUDA availability...
|
||||||
if /I "%IS_NVIDIA%"=="true" (
|
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" (
|
if "%cuda_available%"=="False" (
|
||||||
echo Installing PyTorch with CUDA (12.8) support...
|
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
|
:: 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:
|
:: Solution mentioned by @mazenemam19 in #99:
|
||||||
%PYTHON_CONSOLE_PATH% -m pip install torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128 --no-warn-script-location
|
%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.
|
echo.
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo Failed to install PyTorch.
|
echo Failed to install PyTorch.
|
||||||
@@ -304,10 +339,10 @@ if /I "%IS_NVIDIA%"=="true" (
|
|||||||
if errorlevel 2 (
|
if errorlevel 2 (
|
||||||
echo Skipping PyTorch installation.
|
echo Skipping PyTorch installation.
|
||||||
) else (
|
) else (
|
||||||
echo Installing PyTorch with CUDA (12.8) support...
|
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
|
:: 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:
|
:: Solution mentioned by @mazenemam19 in #99:
|
||||||
%PYTHON_CONSOLE_PATH% -m pip install torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128 --no-warn-script-location
|
%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 (
|
if errorlevel 1 (
|
||||||
echo Failed to install PyTorch.
|
echo Failed to install PyTorch.
|
||||||
pause
|
pause
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
# Special thanks to @geo38 from Reddit, who provided this Dockerfile:
|
|
||||||
# https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/
|
|
||||||
|
|
||||||
# Use a docker base image that runs a window manager that can be viewed
|
|
||||||
# outside the image with a web browser or VNC client.
|
|
||||||
# https://github.com/jlesage/docker-baseimage-gui
|
|
||||||
FROM jlesage/baseimage-gui:debian-12-v4
|
|
||||||
|
|
||||||
# Load stuff needed by abogen
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y \
|
|
||||||
python3 \
|
|
||||||
python3-venv \
|
|
||||||
python3-pip \
|
|
||||||
python3-pyqt6 \
|
|
||||||
espeak-ng \
|
|
||||||
libxcb-cursor0 \
|
|
||||||
libgl1 \
|
|
||||||
&& apt-get clean \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# The base image will run /startapp.sh on launch.
|
|
||||||
#
|
|
||||||
# The base image runs that script as user 'app' uid=1000. That user
|
|
||||||
# does not exist in the base image but is created at run time.
|
|
||||||
#
|
|
||||||
# We need to install abogen in python venv (requirement of newer python3).
|
|
||||||
#
|
|
||||||
# The python venv has to be writable by the 'app' user as abogen dynamically
|
|
||||||
# installs python packages, so create the venv as that user
|
|
||||||
#
|
|
||||||
# We intend to share the /shared directory with the host using a bind volume
|
|
||||||
# in order to access any source files and the created files.
|
|
||||||
|
|
||||||
RUN echo '#!/bin/bash\nsource /app/venv/bin/activate\nexec abogen' > /startapp.sh \
|
|
||||||
&& chmod 555 /startapp.sh \
|
|
||||||
&& mkdir /app /shared \
|
|
||||||
&& chown 1000:1000 /app /shared \
|
|
||||||
&& chmod 755 /app /shared
|
|
||||||
USER 1000:1000
|
|
||||||
RUN python3 -m venv /app/venv
|
|
||||||
RUN /bin/bash -c "source /app/venv/bin/activate && pip install abogen"
|
|
||||||
# Change back to user ROOT as the startup scripts inside base image needs it
|
|
||||||
USER root
|
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.2.2
|
1.3.1
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -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
|
||||||
+1
-4
@@ -61,10 +61,7 @@ SUPPORTED_INPUT_FORMATS = [
|
|||||||
# Please refer to: https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py
|
# Please refer to: https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py
|
||||||
# 383 English processing (unchanged)
|
# 383 English processing (unchanged)
|
||||||
# 384 if self.lang_code in 'ab':
|
# 384 if self.lang_code in 'ab':
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = [
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys())
|
||||||
"a",
|
|
||||||
"b",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Voice and sample text constants
|
# Voice and sample text constants
|
||||||
VOICES_INTERNAL = [
|
VOICES_INTERNAL = [
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .exporter import EPUB3PackageBuilder, build_epub3_package
|
||||||
|
|
||||||
|
__all__ = ["EPUB3PackageBuilder", "build_epub3_package"]
|
||||||
@@ -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 = " "
|
||||||
|
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 = " "
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
"""
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Abogen Flet Frontend Package.
|
||||||
|
|
||||||
|
This package provides a unified, dual-target (desktop + web) user interface
|
||||||
|
for the Abogen audiobook generation application, built with the Flet framework.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Components sub-package."""
|
||||||
|
from .widgets import (
|
||||||
|
resolve_icon,
|
||||||
|
build_drop_zone,
|
||||||
|
build_log_terminal,
|
||||||
|
log_entry,
|
||||||
|
build_progress_row,
|
||||||
|
build_primary_button,
|
||||||
|
build_secondary_button,
|
||||||
|
build_card,
|
||||||
|
build_section_header,
|
||||||
|
build_status_badge,
|
||||||
|
labelled_row,
|
||||||
|
show_snack,
|
||||||
|
build_divider,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_drop_zone",
|
||||||
|
"resolve_icon",
|
||||||
|
"build_log_terminal",
|
||||||
|
"log_entry",
|
||||||
|
"build_progress_row",
|
||||||
|
"build_primary_button",
|
||||||
|
"build_secondary_button",
|
||||||
|
"build_card",
|
||||||
|
"build_section_header",
|
||||||
|
"build_status_badge",
|
||||||
|
"labelled_row",
|
||||||
|
"show_snack",
|
||||||
|
"build_divider",
|
||||||
|
]
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
"""
|
||||||
|
Reusable UI components for the Abogen Flet frontend.
|
||||||
|
|
||||||
|
Each function in this module returns a standalone Flet control or small
|
||||||
|
widget tree. Components read the current palette from the page's theme
|
||||||
|
mode and should not hold any mutable state themselves – state lives in the
|
||||||
|
session's ``AppState`` object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, List, Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from ..utils.theme import get_palette, RADIUS_MD, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_icon(icon: Any) -> Any:
|
||||||
|
"""Convert a snake_case icon name to Flet IconData when possible."""
|
||||||
|
if isinstance(icon, str):
|
||||||
|
return getattr(ft.Icons, icon.upper(), icon)
|
||||||
|
return icon
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Drop-zone (file input area)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_drop_zone(
|
||||||
|
*,
|
||||||
|
on_pick: Callable[[], None],
|
||||||
|
label: str = "Drag & drop your file here or click to browse",
|
||||||
|
sub_label: str = "Supports: .txt · .epub · .pdf · .md · .srt · .ass · .vtt",
|
||||||
|
accent: bool = False,
|
||||||
|
error: bool = False,
|
||||||
|
filename: Optional[str] = None,
|
||||||
|
file_size: Optional[str] = None,
|
||||||
|
char_count: Optional[str] = None,
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.GestureDetector:
|
||||||
|
"""
|
||||||
|
Build an interactive file drop-zone widget.
|
||||||
|
|
||||||
|
The zone shows a dashed border and centred instructions by default,
|
||||||
|
switching to an 'active' green style when a file is loaded and a red
|
||||||
|
style when an error has occurred.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
on_pick: Callback invoked when the user clicks or activates the zone.
|
||||||
|
label: Primary instruction text.
|
||||||
|
sub_label: Secondary hint text shown beneath the label.
|
||||||
|
accent: When True, renders the 'active/success' green style.
|
||||||
|
error: When True, renders the 'error/red' style.
|
||||||
|
filename: When provided, replaces the instruction text with file info.
|
||||||
|
file_size: Human-readable file size to display alongside the filename.
|
||||||
|
char_count: Character count to display alongside file info.
|
||||||
|
page: The current Flet ``Page``; used to derive the active palette.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``ft.GestureDetector`` wrapping the visual drop-zone container.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
p = get_palette(page) if page else None
|
||||||
|
|
||||||
|
# Colour scheme
|
||||||
|
if error:
|
||||||
|
border_color = "#e84e3c" if dark else "#c0392b"
|
||||||
|
bg_color = "#1a0a08" if dark else "#fff5f5"
|
||||||
|
text_color = "#e84e3c" if dark else "#c0392b"
|
||||||
|
icon_name = "error_outline"
|
||||||
|
elif accent:
|
||||||
|
border_color = "#42ad4a" if dark else "#2e9437"
|
||||||
|
bg_color = "#091810" if dark else "#f0fff1"
|
||||||
|
text_color = "#42ad4a" if dark else "#2e9437"
|
||||||
|
icon_name = "check_circle_outline"
|
||||||
|
else:
|
||||||
|
border_color = "#3a4466" if dark else "#a8b4d0"
|
||||||
|
bg_color = "#151928" if dark else "#f7f8fd"
|
||||||
|
text_color = "#9ba3b8" if dark else "#5a6172"
|
||||||
|
icon_name = "upload_file"
|
||||||
|
|
||||||
|
if filename:
|
||||||
|
# Compact file-info display
|
||||||
|
info_rows: List[ft.Control] = [
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
ft.Icon(resolve_icon("insert_drive_file"), color=text_color, size=28),
|
||||||
|
ft.Column(
|
||||||
|
[
|
||||||
|
ft.Text(
|
||||||
|
filename,
|
||||||
|
weight=ft.FontWeight.W_600,
|
||||||
|
size=13,
|
||||||
|
color=text_color,
|
||||||
|
no_wrap=False,
|
||||||
|
max_lines=2,
|
||||||
|
overflow=ft.TextOverflow.ELLIPSIS,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tight=True,
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
spacing=SPACE_SM,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if file_size or char_count:
|
||||||
|
chips: List[ft.Control] = []
|
||||||
|
if file_size:
|
||||||
|
chips.append(
|
||||||
|
ft.Text(f"📄 {file_size}", size=11, color=text_color, italic=True)
|
||||||
|
)
|
||||||
|
if char_count:
|
||||||
|
chips.append(
|
||||||
|
ft.Text(f"🔤 {char_count} chars", size=11, color=text_color, italic=True)
|
||||||
|
)
|
||||||
|
info_rows.append(
|
||||||
|
ft.Row(chips, alignment=ft.MainAxisAlignment.CENTER, spacing=SPACE_MD)
|
||||||
|
)
|
||||||
|
content = ft.Column(
|
||||||
|
info_rows,
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
spacing=SPACE_SM,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
content = ft.Column(
|
||||||
|
[
|
||||||
|
ft.Icon(resolve_icon(icon_name), size=48, color=border_color, opacity=0.8),
|
||||||
|
ft.Text(
|
||||||
|
label,
|
||||||
|
size=14,
|
||||||
|
weight=ft.FontWeight.W_500,
|
||||||
|
color=text_color,
|
||||||
|
text_align=ft.TextAlign.CENTER,
|
||||||
|
),
|
||||||
|
ft.Text(
|
||||||
|
sub_label,
|
||||||
|
size=11,
|
||||||
|
color=text_color,
|
||||||
|
opacity=0.6,
|
||||||
|
text_align=ft.TextAlign.CENTER,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
spacing=SPACE_SM,
|
||||||
|
)
|
||||||
|
|
||||||
|
inner = ft.Container(
|
||||||
|
content=content,
|
||||||
|
border=ft.Border.all(2, border_color),
|
||||||
|
border_radius=RADIUS_MD,
|
||||||
|
bgcolor=bg_color,
|
||||||
|
padding=ft.Padding.all(SPACE_LG),
|
||||||
|
height=160,
|
||||||
|
alignment=ft.Alignment.CENTER,
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.GestureDetector(
|
||||||
|
content=ft.Row([inner], spacing=0),
|
||||||
|
on_tap=lambda _: on_pick(),
|
||||||
|
mouse_cursor=ft.MouseCursor.CLICK,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Log terminal
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_log_terminal(
|
||||||
|
*,
|
||||||
|
ref: Optional[ft.Ref] = None,
|
||||||
|
max_height: int = 260,
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.Container:
|
||||||
|
"""
|
||||||
|
Build a scrollable, read-only log terminal widget.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ref: Optional ``ft.Ref[ft.ListView]`` to bind the inner list-view so
|
||||||
|
callers can append entries programmatically.
|
||||||
|
max_height: Maximum pixel height before vertical scrolling activates.
|
||||||
|
page: Current Flet ``Page`` for palette derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A styled ``ft.Container`` wrapping a ``ft.ListView``.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
bg = "#0d1117" if dark else "#f8f9fc"
|
||||||
|
text_color = "#b0b8cc" if dark else "#3d4358"
|
||||||
|
border_color = "#252a38" if dark else "#dce0ea"
|
||||||
|
|
||||||
|
list_view = ft.ListView(
|
||||||
|
expand=True,
|
||||||
|
auto_scroll=True,
|
||||||
|
spacing=1,
|
||||||
|
padding=ft.Padding.all(SPACE_SM),
|
||||||
|
)
|
||||||
|
if ref is not None:
|
||||||
|
ref.current = list_view
|
||||||
|
|
||||||
|
return ft.Container(
|
||||||
|
content=list_view,
|
||||||
|
bgcolor=bg,
|
||||||
|
border=ft.Border.all(1, border_color),
|
||||||
|
border_radius=RADIUS_SM,
|
||||||
|
height=max_height,
|
||||||
|
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def log_entry(message: str, level: str = "info", page: Optional[ft.Page] = None) -> ft.Text:
|
||||||
|
"""
|
||||||
|
Create a single log-line ``ft.Text`` widget with appropriate colour coding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: The log message string.
|
||||||
|
level: Severity string: ``'info'``, ``'success'``, ``'error'``,
|
||||||
|
``'warning'``, ``'debug'``, ``'critical'``.
|
||||||
|
page: Current Flet ``Page`` for dark/light mode detection.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A styled ``ft.Text`` control.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
palette: dict[str, str] = {
|
||||||
|
"info": "#9ba3b8" if dark else "#5a6172",
|
||||||
|
"success": "#42ad4a" if dark else "#2e9437",
|
||||||
|
"error": "#e84e3c" if dark else "#c0392b",
|
||||||
|
"warning": "#f5a623" if dark else "#d4870a",
|
||||||
|
"debug": "#5a6172" if dark else "#9ba3b8",
|
||||||
|
"critical": "#ff5722",
|
||||||
|
"trace": "#4e5568" if dark else "#b0b8cc",
|
||||||
|
}
|
||||||
|
color = palette.get(level.lower(), palette["info"])
|
||||||
|
return ft.Text(message, size=12, color=color, selectable=True, no_wrap=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Progress row
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_progress_row(
|
||||||
|
*,
|
||||||
|
progress_value: float = 0.0,
|
||||||
|
etr_text: str = "",
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.Column:
|
||||||
|
"""
|
||||||
|
Build a progress-bar + ETR-label column.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
progress_value: Float in [0.0, 1.0].
|
||||||
|
etr_text: Pre-formatted estimated-time-remaining string.
|
||||||
|
page: Current ``Page`` for palette derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``ft.Column`` containing the progress bar and label.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
fill = "#5b8af5" if dark else "#3a5fc4"
|
||||||
|
bg = "#1e2230" if dark else "#e4e8f0"
|
||||||
|
|
||||||
|
bar = ft.ProgressBar(
|
||||||
|
value=progress_value,
|
||||||
|
color=fill,
|
||||||
|
bgcolor=bg,
|
||||||
|
height=8,
|
||||||
|
border_radius=ft.BorderRadius.all(4),
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
label = ft.Text(
|
||||||
|
etr_text,
|
||||||
|
size=11,
|
||||||
|
color="#9ba3b8" if dark else "#5a6172",
|
||||||
|
text_align=ft.TextAlign.CENTER,
|
||||||
|
)
|
||||||
|
return ft.Column(
|
||||||
|
[bar, label],
|
||||||
|
spacing=SPACE_SM,
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Primary action button
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_primary_button(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
icon: Optional[str] = None,
|
||||||
|
on_click: Optional[Callable] = None,
|
||||||
|
disabled: bool = False,
|
||||||
|
width: Optional[int] = None,
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.ElevatedButton:
|
||||||
|
"""
|
||||||
|
Build a prominent, styled primary action button.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Button label.
|
||||||
|
icon: Optional Flet icon name (e.g. ``'play_arrow'``).
|
||||||
|
on_click: Click callback.
|
||||||
|
disabled: Whether the button is non-interactive.
|
||||||
|
width: Optional fixed pixel width.
|
||||||
|
page: Current ``Page`` for accent colour derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A styled ``ft.ElevatedButton``.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
bg = "#5b8af5" if dark else "#3a5fc4"
|
||||||
|
on_bg = "#ffffff"
|
||||||
|
|
||||||
|
style = ft.ButtonStyle(
|
||||||
|
bgcolor={
|
||||||
|
ft.ControlState.DEFAULT: bg,
|
||||||
|
ft.ControlState.HOVERED: "#3a5fc4" if dark else "#2a4fae",
|
||||||
|
ft.ControlState.DISABLED: "#2a2f3f" if dark else "#c0c8d8",
|
||||||
|
},
|
||||||
|
color={
|
||||||
|
ft.ControlState.DEFAULT: on_bg,
|
||||||
|
ft.ControlState.DISABLED: "#4e5568" if dark else "#9ba3b8",
|
||||||
|
},
|
||||||
|
elevation={"default": 2, "hovered": 4},
|
||||||
|
padding=ft.Padding.symmetric(horizontal=SPACE_LG, vertical=SPACE_MD),
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=RADIUS_SM),
|
||||||
|
animation_duration=150,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.ElevatedButton(
|
||||||
|
content=text,
|
||||||
|
icon=resolve_icon(icon),
|
||||||
|
on_click=on_click,
|
||||||
|
disabled=disabled,
|
||||||
|
width=width,
|
||||||
|
style=style,
|
||||||
|
height=48,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Secondary / ghost button
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_secondary_button(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
icon: Optional[str] = None,
|
||||||
|
on_click: Optional[Callable] = None,
|
||||||
|
disabled: bool = False,
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.OutlinedButton:
|
||||||
|
"""
|
||||||
|
Build a secondary outlined button.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Button label.
|
||||||
|
icon: Optional Flet icon name.
|
||||||
|
on_click: Click callback.
|
||||||
|
disabled: Whether the button is non-interactive.
|
||||||
|
page: Current ``Page`` for border colour derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A styled ``ft.OutlinedButton``.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
border_clr = "#3a4466" if dark else "#a8b4d0"
|
||||||
|
text_clr = "#e8eaf0" if dark else "#1a1d27"
|
||||||
|
|
||||||
|
style = ft.ButtonStyle(
|
||||||
|
side={
|
||||||
|
ft.ControlState.DEFAULT: ft.BorderSide(1.5, border_clr),
|
||||||
|
ft.ControlState.HOVERED: ft.BorderSide(1.5, "#5b8af5" if dark else "#3a5fc4"),
|
||||||
|
},
|
||||||
|
color={
|
||||||
|
ft.ControlState.DEFAULT: text_clr,
|
||||||
|
ft.ControlState.HOVERED: "#5b8af5" if dark else "#3a5fc4",
|
||||||
|
ft.ControlState.DISABLED: "#4e5568" if dark else "#9ba3b8",
|
||||||
|
},
|
||||||
|
padding=ft.Padding.symmetric(horizontal=SPACE_LG, vertical=SPACE_MD),
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=RADIUS_SM),
|
||||||
|
animation_duration=150,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.OutlinedButton(
|
||||||
|
content=text,
|
||||||
|
icon=resolve_icon(icon),
|
||||||
|
on_click=on_click,
|
||||||
|
disabled=disabled,
|
||||||
|
style=style,
|
||||||
|
height=44,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Section card
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_card(
|
||||||
|
content: ft.Control,
|
||||||
|
*,
|
||||||
|
padding: int = SPACE_LG,
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.Container:
|
||||||
|
"""
|
||||||
|
Wrap a control in a styled card container.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: The child control to embed.
|
||||||
|
padding: Internal padding in pixels.
|
||||||
|
page: Current ``Page`` for palette derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A styled ``ft.Container``.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
bg = "#181b23" if dark else "#ffffff"
|
||||||
|
border_clr = "#2c3147" if dark else "#dce0ea"
|
||||||
|
|
||||||
|
return ft.Container(
|
||||||
|
content=content,
|
||||||
|
bgcolor=bg,
|
||||||
|
border=ft.Border.all(1, border_clr),
|
||||||
|
border_radius=RADIUS_MD,
|
||||||
|
padding=ft.Padding.all(padding),
|
||||||
|
shadow=ft.BoxShadow(
|
||||||
|
spread_radius=0,
|
||||||
|
blur_radius=12,
|
||||||
|
color=ft.Colors.with_opacity(0.12 if dark else 0.06, ft.Colors.BLACK),
|
||||||
|
offset=ft.Offset(0, 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Section header
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_section_header(
|
||||||
|
title: str,
|
||||||
|
*,
|
||||||
|
subtitle: Optional[str] = None,
|
||||||
|
icon: Optional[str] = None,
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.Row:
|
||||||
|
"""
|
||||||
|
Build a consistent section header row with an optional icon.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Section heading text.
|
||||||
|
subtitle: Optional explanatory sub-text.
|
||||||
|
icon: Optional Flet icon name.
|
||||||
|
page: Current ``Page`` for palette derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``ft.Row`` containing the icon and text column.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
title_color = "#e8eaf0" if dark else "#1a1d27"
|
||||||
|
sub_color = "#9ba3b8" if dark else "#5a6172"
|
||||||
|
accent = "#5b8af5" if dark else "#3a5fc4"
|
||||||
|
|
||||||
|
children: List[ft.Control] = []
|
||||||
|
if icon:
|
||||||
|
children.append(ft.Icon(resolve_icon(icon), size=20, color=accent))
|
||||||
|
|
||||||
|
text_parts: List[ft.Control] = [
|
||||||
|
ft.Text(title, size=15, weight=ft.FontWeight.W_600, color=title_color)
|
||||||
|
]
|
||||||
|
if subtitle:
|
||||||
|
text_parts.append(ft.Text(subtitle, size=11, color=sub_color))
|
||||||
|
|
||||||
|
children.append(
|
||||||
|
ft.Column(text_parts, spacing=1, tight=True, expand=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.Row(children, spacing=SPACE_SM, vertical_alignment=ft.CrossAxisAlignment.START)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status badge
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_status_badge(
|
||||||
|
label: str,
|
||||||
|
*,
|
||||||
|
variant: str = "info",
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.Container:
|
||||||
|
"""
|
||||||
|
Build a small status badge chip.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
label: Badge text.
|
||||||
|
variant: Colour variant: ``'info'``, ``'success'``, ``'error'``,
|
||||||
|
``'warning'``, ``'neutral'``.
|
||||||
|
page: Current ``Page`` for theme derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A pill-shaped ``ft.Container``.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
palette = {
|
||||||
|
"info": ("#1a2a5e" if dark else "#dde8ff", "#5b8af5" if dark else "#3a5fc4"),
|
||||||
|
"success": ("#0d2010" if dark else "#d4f4d7", "#42ad4a" if dark else "#2e9437"),
|
||||||
|
"error": ("#2a0a08" if dark else "#ffe0dc", "#e84e3c" if dark else "#c0392b"),
|
||||||
|
"warning": ("#2a1a00" if dark else "#fff4d8", "#f5a623" if dark else "#d4870a"),
|
||||||
|
"neutral": ("#1e2230" if dark else "#edf0f5", "#9ba3b8" if dark else "#5a6172"),
|
||||||
|
}
|
||||||
|
bg, fg = palette.get(variant, palette["info"])
|
||||||
|
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Text(label, size=10, weight=ft.FontWeight.W_600, color=fg),
|
||||||
|
bgcolor=bg,
|
||||||
|
border_radius=999,
|
||||||
|
padding=ft.Padding.symmetric(horizontal=8, vertical=3),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Labelled control row
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def labelled_row(
|
||||||
|
label: str,
|
||||||
|
control: ft.Control,
|
||||||
|
*,
|
||||||
|
label_width: int = 200,
|
||||||
|
tooltip: Optional[str] = None,
|
||||||
|
page: Optional[ft.Page] = None,
|
||||||
|
) -> ft.Row:
|
||||||
|
"""
|
||||||
|
Lay a label and a control side-by-side in a consistent row.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
label: Human-readable label text.
|
||||||
|
control: The UI control placed to the right of the label.
|
||||||
|
label_width: Fixed pixel width of the label column.
|
||||||
|
tooltip: Optional tooltip text on the label.
|
||||||
|
page: Current ``Page`` for palette derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``ft.Row`` with the label pinned to a fixed width.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
lbl_color = "#9ba3b8" if dark else "#5a6172"
|
||||||
|
|
||||||
|
lbl = ft.Text(label, size=13, color=lbl_color, weight=ft.FontWeight.W_500, width=label_width)
|
||||||
|
if tooltip:
|
||||||
|
lbl.tooltip = tooltip
|
||||||
|
|
||||||
|
return ft.Row(
|
||||||
|
[lbl, ft.Container(content=control, expand=True)],
|
||||||
|
alignment=ft.MainAxisAlignment.START,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
spacing=SPACE_MD,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Snack-bar helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def show_snack(
|
||||||
|
page: ft.Page,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
error: bool = False,
|
||||||
|
duration: int = 3000,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Display a brief snack-bar notification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: The Flet ``Page`` instance.
|
||||||
|
message: Text to display.
|
||||||
|
error: When True, colours the bar red instead of the default accent.
|
||||||
|
duration: Visible duration in milliseconds.
|
||||||
|
"""
|
||||||
|
dark = page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
bg = "#e84e3c" if error else ("#5b8af5" if dark else "#3a5fc4")
|
||||||
|
page.snack_bar = ft.SnackBar(
|
||||||
|
content=ft.Text(message, color="#ffffff", size=13),
|
||||||
|
bgcolor=bg,
|
||||||
|
duration=duration,
|
||||||
|
show_close_icon=True,
|
||||||
|
close_icon_color="#ffffff",
|
||||||
|
)
|
||||||
|
page.snack_bar.open = True
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Divider helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_divider(page: Optional[ft.Page] = None) -> ft.Divider:
|
||||||
|
"""
|
||||||
|
Build a styled horizontal rule divider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Current ``Page`` for palette derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ``ft.Divider``.
|
||||||
|
"""
|
||||||
|
dark = page is not None and page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
return ft.Divider(color="#252a38" if dark else "#e8ebf2", height=1, thickness=1)
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
"""
|
||||||
|
Abogen Flet Frontend – main entry point.
|
||||||
|
|
||||||
|
Run as desktop app:
|
||||||
|
python -m abogen.frontend.main
|
||||||
|
|
||||||
|
Run as web app (binds to port 8080 by default):
|
||||||
|
python -m abogen.frontend.main --web --port 8080
|
||||||
|
|
||||||
|
Architecture
|
||||||
|
------------
|
||||||
|
One ``ft.app()`` call launches the server. For every new browser tab (or the
|
||||||
|
desktop window) Flet invokes ``_app_entry(page)`` in its own coroutine, which
|
||||||
|
creates a fresh ``AppState`` and wires together the navigation rail and views.
|
||||||
|
This guarantees complete per-session isolation in multi-user web deployments.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from .state import AppState
|
||||||
|
from .components import resolve_icon
|
||||||
|
from .views.dashboard import DashboardView
|
||||||
|
from .views.settings import SettingsView
|
||||||
|
from .views.queue_view import QueueView
|
||||||
|
from .utils.theme import make_theme, DARK, LIGHT, SPACE_SM, SPACE_MD, SPACE_LG, RADIUS_MD
|
||||||
|
from abogen.constants import PROGRAM_NAME as APP_NAME
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Navigation destinations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_NAV_ITEMS = [
|
||||||
|
("Convert", "swap_horiz", "swap_horiz"),
|
||||||
|
("Queue", "list_alt", "list_alt"),
|
||||||
|
("Settings", "settings", "settings"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_ASSETS_DIR = Path(__file__).resolve().parents[1] / "assets"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sidebar_item(
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
icon: str,
|
||||||
|
selected: bool,
|
||||||
|
palette,
|
||||||
|
on_click,
|
||||||
|
) -> ft.Container:
|
||||||
|
accent = palette.accent if selected else palette.text_secondary
|
||||||
|
bg = palette.sidebar_selected_bg if selected else palette.sidebar_bg
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Row(
|
||||||
|
[
|
||||||
|
ft.Icon(resolve_icon(icon), size=20, color=accent),
|
||||||
|
ft.Text(
|
||||||
|
label,
|
||||||
|
size=13,
|
||||||
|
weight=ft.FontWeight.W_600 if selected else ft.FontWeight.W_500,
|
||||||
|
color=accent,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=SPACE_MD,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
),
|
||||||
|
bgcolor=bg,
|
||||||
|
border_radius=RADIUS_MD,
|
||||||
|
padding=ft.Padding.symmetric(horizontal=SPACE_MD, vertical=10),
|
||||||
|
ink=True,
|
||||||
|
on_click=on_click,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-session entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _app_entry(page: ft.Page) -> None:
|
||||||
|
try:
|
||||||
|
# ── State ────────────────────────────────────────────────────────────
|
||||||
|
state = AppState()
|
||||||
|
state.load_from_config()
|
||||||
|
|
||||||
|
# ── Page basics ──────────────────────────────────────────────────────
|
||||||
|
page.title = APP_NAME
|
||||||
|
page.padding = 0
|
||||||
|
page.spacing = 0
|
||||||
|
page.bgcolor = DARK.bg_base
|
||||||
|
page.theme_mode = ft.ThemeMode.DARK
|
||||||
|
page.theme = make_theme(dark=True)
|
||||||
|
page.dark_theme = make_theme(dark=True)
|
||||||
|
page.fonts = {}
|
||||||
|
page.window.min_width = 520
|
||||||
|
page.window.min_height = 600
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
# ── Content area ref ─────────────────────────────────────────────────
|
||||||
|
content_area = ft.Column(expand=True, spacing=0)
|
||||||
|
sidebar_body = ft.Column(spacing=SPACE_SM)
|
||||||
|
theme_button_host = ft.Container()
|
||||||
|
brand_title = ft.Text(
|
||||||
|
APP_NAME,
|
||||||
|
size=18,
|
||||||
|
weight=ft.FontWeight.W_700,
|
||||||
|
color=DARK.text_primary,
|
||||||
|
)
|
||||||
|
brand_fallback_icon = ft.Icon(resolve_icon("speaker_notes"), size=32, color=DARK.accent)
|
||||||
|
divider = ft.VerticalDivider(width=1, color=DARK.border)
|
||||||
|
|
||||||
|
# ── Views ────────────────────────────────────────────────────────────
|
||||||
|
dashboard_view = DashboardView(page, state)
|
||||||
|
settings_view = SettingsView(page, state)
|
||||||
|
queue_view = QueueView(page, state)
|
||||||
|
|
||||||
|
views = [
|
||||||
|
dashboard_view.build,
|
||||||
|
queue_view.build,
|
||||||
|
settings_view.build,
|
||||||
|
]
|
||||||
|
_selected_index = [0]
|
||||||
|
|
||||||
|
def _refresh_sidebar() -> None:
|
||||||
|
dark = page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
pal = DARK if dark else LIGHT
|
||||||
|
sidebar_body.controls = [
|
||||||
|
_build_sidebar_item(
|
||||||
|
label=label,
|
||||||
|
icon=icon,
|
||||||
|
selected=index == _selected_index[0],
|
||||||
|
palette=pal,
|
||||||
|
on_click=lambda _, i=index: _navigate(i),
|
||||||
|
)
|
||||||
|
for index, (label, icon, _) in enumerate(_NAV_ITEMS)
|
||||||
|
]
|
||||||
|
sidebar.bgcolor = pal.sidebar_bg
|
||||||
|
divider.color = pal.border
|
||||||
|
brand_title.color = pal.text_primary
|
||||||
|
brand_fallback_icon.color = pal.accent
|
||||||
|
theme_button_host.content = ft.Container(
|
||||||
|
content=ft.Icon(
|
||||||
|
resolve_icon("dark_mode" if dark else "light_mode"),
|
||||||
|
size=20,
|
||||||
|
color=pal.text_secondary,
|
||||||
|
),
|
||||||
|
tooltip="Toggle theme",
|
||||||
|
border_radius=RADIUS_MD,
|
||||||
|
padding=8,
|
||||||
|
ink=True,
|
||||||
|
on_click=lambda _: _toggle_theme(page, _refresh_sidebar),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _navigate(index: int) -> None:
|
||||||
|
_selected_index[0] = index
|
||||||
|
content_area.controls.clear()
|
||||||
|
built = views[index]()
|
||||||
|
content_area.controls.append(
|
||||||
|
ft.Container(
|
||||||
|
content=built,
|
||||||
|
expand=True,
|
||||||
|
padding=ft.Padding.symmetric(horizontal=SPACE_LG, vertical=SPACE_LG),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_refresh_sidebar()
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
# ── Sidebar ──────────────────────────────────────────────────────────
|
||||||
|
pal = DARK
|
||||||
|
sidebar = ft.Container(
|
||||||
|
width=220,
|
||||||
|
bgcolor=pal.sidebar_bg,
|
||||||
|
padding=ft.Padding.all(SPACE_MD),
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Row(
|
||||||
|
[
|
||||||
|
ft.Image(
|
||||||
|
src="icon.png",
|
||||||
|
width=36,
|
||||||
|
height=36,
|
||||||
|
fit=ft.BoxFit.CONTAIN,
|
||||||
|
error_content=brand_fallback_icon,
|
||||||
|
),
|
||||||
|
brand_title,
|
||||||
|
],
|
||||||
|
spacing=SPACE_MD,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
),
|
||||||
|
padding=ft.Padding.only(top=SPACE_SM, bottom=SPACE_LG),
|
||||||
|
),
|
||||||
|
sidebar_body,
|
||||||
|
ft.Container(expand=True),
|
||||||
|
ft.Row([theme_button_host], alignment=ft.MainAxisAlignment.END),
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
spacing=SPACE_SM,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_refresh_sidebar()
|
||||||
|
|
||||||
|
# ── Page handle for pubsub (queue → dashboard) ───────────────────────
|
||||||
|
def _handle_pubsub(topic: str) -> None:
|
||||||
|
if topic == "start_queue":
|
||||||
|
_navigate(0)
|
||||||
|
|
||||||
|
page.pubsub.subscribe(_handle_pubsub)
|
||||||
|
|
||||||
|
# ── Layout ────────────────────────────────────────────────────────────
|
||||||
|
page.add(
|
||||||
|
ft.Row(
|
||||||
|
[
|
||||||
|
sidebar,
|
||||||
|
divider,
|
||||||
|
ft.Container(content=content_area, expand=True),
|
||||||
|
],
|
||||||
|
expand=True,
|
||||||
|
spacing=0,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.START,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Show dashboard by default
|
||||||
|
_navigate(0)
|
||||||
|
page.update()
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
print(f"ERROR IN _app_entry: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _toggle_theme(page: ft.Page, refresh_sidebar) -> None:
|
||||||
|
"""Switch between dark and light theme modes."""
|
||||||
|
if page.theme_mode == ft.ThemeMode.DARK:
|
||||||
|
page.theme_mode = ft.ThemeMode.LIGHT
|
||||||
|
page.bgcolor = LIGHT.bg_base
|
||||||
|
else:
|
||||||
|
page.theme_mode = ft.ThemeMode.DARK
|
||||||
|
page.bgcolor = DARK.bg_base
|
||||||
|
|
||||||
|
page.theme = make_theme(page.theme_mode == ft.ThemeMode.DARK)
|
||||||
|
refresh_sidebar()
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI helpers & entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _is_port_free(host: str, port: int) -> bool:
|
||||||
|
import socket
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
s.bind((host, port))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _find_free_port(host: str, start_port: int) -> int:
|
||||||
|
import socket
|
||||||
|
port = start_port
|
||||||
|
while port < 65535:
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
s.bind((host, port))
|
||||||
|
return port
|
||||||
|
except OSError:
|
||||||
|
port += 1
|
||||||
|
return start_port
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""
|
||||||
|
Start the Abogen Flet frontend.
|
||||||
|
|
||||||
|
Parses ``--web`` and ``--port`` CLI arguments to choose desktop vs. web
|
||||||
|
mode, then hands control to ``ft.app()``.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logging.getLogger("flet").setLevel(logging.INFO)
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description=f"{APP_NAME} – Flet frontend")
|
||||||
|
parser.add_argument(
|
||||||
|
"--web", action="store_true",
|
||||||
|
help="Run as a web server instead of a desktop window.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port", type=int, default=8080,
|
||||||
|
help="Port for the web server (default: 8080). Ignored in desktop mode.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host", default="127.0.0.1",
|
||||||
|
help="Host for the web server (default: 127.0.0.1). Use 0.0.0.0 to expose publicly.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.web:
|
||||||
|
port_specified = "--port" in sys.argv
|
||||||
|
target_port = args.port
|
||||||
|
|
||||||
|
if not port_specified:
|
||||||
|
target_port = _find_free_port(args.host, 8080)
|
||||||
|
if target_port != 8080:
|
||||||
|
print(f"Port 8080 is in use. Automatically routed to free port: {target_port}")
|
||||||
|
else:
|
||||||
|
if not _is_port_free(args.host, target_port):
|
||||||
|
print(f"Error: Port {target_port} is already in use on {args.host}.", file=sys.stderr)
|
||||||
|
print("Please select a different port or omit the --port flag to find one automatically.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Starting Abogen WebUI on http://{args.host}:{target_port} ...")
|
||||||
|
ft.app(
|
||||||
|
target=_app_entry,
|
||||||
|
view=ft.AppView.WEB_BROWSER,
|
||||||
|
port=target_port,
|
||||||
|
host=args.host,
|
||||||
|
assets_dir=str(_ASSETS_DIR) if _ASSETS_DIR.exists() else None,
|
||||||
|
no_cdn=True,
|
||||||
|
web_renderer="canvaskit",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
ft.app(
|
||||||
|
target=_app_entry,
|
||||||
|
view=ft.AppView.FLET_APP,
|
||||||
|
assets_dir=str(_ASSETS_DIR) if _ASSETS_DIR.exists() else None,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to launch native desktop window: {e}", file=sys.stderr)
|
||||||
|
print("Falling back to running as a web application in your default browser...", file=sys.stderr)
|
||||||
|
target_port = _find_free_port("127.0.0.1", 8080)
|
||||||
|
print(f"Starting Abogen WebUI on http://127.0.0.1:{target_port} ...")
|
||||||
|
ft.app(
|
||||||
|
target=_app_entry,
|
||||||
|
view=ft.AppView.WEB_BROWSER,
|
||||||
|
port=target_port,
|
||||||
|
host="127.0.0.1",
|
||||||
|
assets_dir=str(_ASSETS_DIR) if _ASSETS_DIR.exists() else None,
|
||||||
|
no_cdn=True,
|
||||||
|
web_renderer="canvaskit",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main_web() -> None:
|
||||||
|
"""
|
||||||
|
Start the Abogen Flet frontend as a web server.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
if "--web" not in sys.argv:
|
||||||
|
sys.argv.insert(1, "--web")
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""State sub-package – exports AppState and ConversionJob."""
|
||||||
|
from .app_state import AppState, ConversionJob
|
||||||
|
|
||||||
|
__all__ = ["AppState", "ConversionJob"]
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"""
|
||||||
|
Centralized, per-session application state for the Abogen Flet frontend.
|
||||||
|
|
||||||
|
Each Flet page (session) gets its own instance of AppState, which guarantees
|
||||||
|
complete isolation between simultaneous web-browser clients and the desktop
|
||||||
|
window. The class carries every configuration variable, file buffer reference,
|
||||||
|
and generation progress field that the rest of the UI reads or writes.
|
||||||
|
|
||||||
|
This module intentionally has no Flet imports so it can be unit-tested without
|
||||||
|
a running Flet server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from abogen.utils import load_config, save_config
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _default_config() -> Dict[str, Any]:
|
||||||
|
"""Load the persisted user config dict, returning an empty dict on failure."""
|
||||||
|
try:
|
||||||
|
return load_config() or {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-session state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConversionJob:
|
||||||
|
"""Lightweight descriptor of a single queued conversion job."""
|
||||||
|
|
||||||
|
file_path: str
|
||||||
|
"""Absolute path to the text/epub/pdf/txt input file."""
|
||||||
|
|
||||||
|
display_name: str
|
||||||
|
"""User-visible filename (may be the original epub/pdf path)."""
|
||||||
|
|
||||||
|
voice: str
|
||||||
|
"""Voice formula string (e.g. 'af_heart' or 'af_heart*0.5+am_adam*0.5')."""
|
||||||
|
|
||||||
|
lang_code: str
|
||||||
|
"""Single-char language prefix used by Kokoro (e.g. 'a', 'b', 'e')."""
|
||||||
|
|
||||||
|
speed: float = 1.0
|
||||||
|
"""Playback speed multiplier, range 0.1 – 2.0."""
|
||||||
|
|
||||||
|
output_format: str = "mp3"
|
||||||
|
"""Output audio container format."""
|
||||||
|
|
||||||
|
subtitle_mode: str = "Disabled"
|
||||||
|
"""Subtitle generation mode."""
|
||||||
|
|
||||||
|
save_option: str = "Save next to input file"
|
||||||
|
"""Save location strategy."""
|
||||||
|
|
||||||
|
output_folder: Optional[str] = None
|
||||||
|
"""Absolute path when save_option is 'Choose output folder'."""
|
||||||
|
|
||||||
|
char_count: int = 0
|
||||||
|
"""Pre-computed character count for ETR estimation."""
|
||||||
|
|
||||||
|
replace_single_newlines: bool = True
|
||||||
|
save_chapters_separately: Optional[bool] = None
|
||||||
|
merge_chapters_at_end: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AppState:
|
||||||
|
"""
|
||||||
|
Single source of truth for one Flet session.
|
||||||
|
|
||||||
|
Instantiated once per ``ft.app()`` call on desktop, and once per browser
|
||||||
|
tab on web. All UI components receive a reference to this object and
|
||||||
|
read/write it to keep themselves in sync.
|
||||||
|
|
||||||
|
Thread-safety: mutation from background threads should be done via the
|
||||||
|
provided ``_lock``. The UI update callbacks (``on_log``,
|
||||||
|
``on_progress``, etc.) are always invoked on the Flet event loop via
|
||||||
|
``page.run_task()`` and must be set by the view layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Runtime identity
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Persisted user config (loaded once, written on every change)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
config: Dict[str, Any] = field(default_factory=_default_config)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# File / input state
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
selected_file: Optional[str] = None
|
||||||
|
"""Path to the processed text file (may be a temp cache copy for epub/pdf)."""
|
||||||
|
|
||||||
|
selected_file_type: Optional[str] = None
|
||||||
|
"""'txt' | 'epub' | 'pdf' | 'markdown' | None"""
|
||||||
|
|
||||||
|
selected_book_path: Optional[str] = None
|
||||||
|
"""Original epub/pdf path before being converted to txt."""
|
||||||
|
|
||||||
|
displayed_file_path: Optional[str] = None
|
||||||
|
"""Path shown in the UI drop-zone (original book or txt file)."""
|
||||||
|
|
||||||
|
selected_chapters: List[str] = field(default_factory=list)
|
||||||
|
"""Ordered list of selected chapter href tokens (or page numbers for PDFs)."""
|
||||||
|
|
||||||
|
save_chapters_separately: Optional[bool] = None
|
||||||
|
merge_chapters_at_end: Optional[bool] = None
|
||||||
|
save_as_project: bool = False
|
||||||
|
char_count: int = 0
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Voice / language
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
selected_voice: str = "af_heart"
|
||||||
|
selected_lang: str = "a"
|
||||||
|
selected_profile_name: Optional[str] = None
|
||||||
|
mixed_voice_state: Optional[List[Any]] = None
|
||||||
|
"""List of [voice_id, weight] pairs when the formula mixer is in use."""
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Conversion parameters
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
speed: float = 1.0
|
||||||
|
use_gpu: bool = True
|
||||||
|
selected_format: str = "wav"
|
||||||
|
subtitle_mode: str = "Sentence"
|
||||||
|
subtitle_format: str = "ass_centered_narrow"
|
||||||
|
replace_single_newlines: bool = True
|
||||||
|
save_option: str = "Save next to input file"
|
||||||
|
selected_output_folder: Optional[str] = None
|
||||||
|
silence_duration: float = 2.0
|
||||||
|
max_subtitle_words: int = 50
|
||||||
|
separate_chapters_format: str = "wav"
|
||||||
|
use_silent_gaps: bool = True
|
||||||
|
subtitle_speed_method: str = "tts"
|
||||||
|
use_spacy_segmentation: bool = True
|
||||||
|
chunk_level: str = "paragraph"
|
||||||
|
generate_epub3: bool = False
|
||||||
|
|
||||||
|
# TTS provider
|
||||||
|
tts_provider: str = "kokoro"
|
||||||
|
supertonic_total_steps: int = 5
|
||||||
|
|
||||||
|
# Chapter options
|
||||||
|
chapter_intro_delay: float = 0.5
|
||||||
|
read_title_intro: bool = False
|
||||||
|
read_closing_outro: bool = True
|
||||||
|
auto_prefix_chapter_titles: bool = True
|
||||||
|
normalize_chapter_opening_caps: bool = True
|
||||||
|
|
||||||
|
# Speaker analysis
|
||||||
|
speaker_analysis_threshold: int = 3
|
||||||
|
|
||||||
|
# Word substitutions
|
||||||
|
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
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Conversion runtime state
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
is_converting: bool = False
|
||||||
|
is_cancelled: bool = False
|
||||||
|
progress: float = 0.0
|
||||||
|
"""Fractional progress 0.0 – 1.0."""
|
||||||
|
etr_seconds: Optional[float] = None
|
||||||
|
"""Estimated seconds remaining, or None if unknown."""
|
||||||
|
last_output_path: Optional[str] = None
|
||||||
|
log_lines: List[str] = field(default_factory=list)
|
||||||
|
"""Buffered log messages, capped at LOG_MAX_LINES."""
|
||||||
|
|
||||||
|
LOG_MAX_LINES: int = 2000
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Queue
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
queued_items: List[ConversionJob] = field(default_factory=list)
|
||||||
|
current_queue_index: int = 0
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Callbacks (set by the view layer, not serialised)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
on_log: Optional[Callable[[str, str], None]] = field(default=None, repr=False, compare=False)
|
||||||
|
"""Called from any thread: ``on_log(message, level)``."""
|
||||||
|
|
||||||
|
on_progress: Optional[Callable[[float, Optional[float]], None]] = field(
|
||||||
|
default=None, repr=False, compare=False
|
||||||
|
)
|
||||||
|
"""Called from any thread: ``on_progress(fraction, etr_seconds)``."""
|
||||||
|
|
||||||
|
on_conversion_finished: Optional[Callable[[str, Optional[str]], None]] = field(
|
||||||
|
default=None, repr=False, compare=False
|
||||||
|
)
|
||||||
|
"""Called from any thread: ``on_conversion_finished(message, output_path)``."""
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Integrations
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
audiobookshelf_enabled: bool = False
|
||||||
|
audiobookshelf_base_url: str = ""
|
||||||
|
audiobookshelf_api_token: str = ""
|
||||||
|
audiobookshelf_library_id: str = ""
|
||||||
|
audiobookshelf_folder_id: str = ""
|
||||||
|
audiobookshelf_verify_ssl: bool = True
|
||||||
|
audiobookshelf_auto_send: bool = False
|
||||||
|
audiobookshelf_send_cover: bool = True
|
||||||
|
audiobookshelf_send_chapters: bool = True
|
||||||
|
audiobookshelf_send_subtitles: bool = False
|
||||||
|
audiobookshelf_timeout: float = 30.0
|
||||||
|
|
||||||
|
calibre_opds_enabled: bool = False
|
||||||
|
calibre_opds_base_url: str = ""
|
||||||
|
calibre_opds_username: str = ""
|
||||||
|
calibre_opds_password: str = ""
|
||||||
|
calibre_opds_verify_ssl: bool = True
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Public helpers
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_from_config(self) -> None:
|
||||||
|
"""
|
||||||
|
Populate all fields from the persisted JSON config file.
|
||||||
|
|
||||||
|
Called once at startup and whenever the settings page is saved.
|
||||||
|
Thread-safe.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
cfg = _default_config()
|
||||||
|
self.config = cfg
|
||||||
|
|
||||||
|
self.selected_voice = cfg.get("selected_voice", "af_heart")
|
||||||
|
self.selected_lang = self.selected_voice[0] if self.selected_voice else "a"
|
||||||
|
self.selected_profile_name = cfg.get("selected_profile_name")
|
||||||
|
self.speed = cfg.get("speed", 1.0)
|
||||||
|
self.use_gpu = cfg.get("use_gpu", True)
|
||||||
|
self.selected_format = cfg.get("selected_format", "wav")
|
||||||
|
self.subtitle_mode = cfg.get("subtitle_mode", "Sentence")
|
||||||
|
self.subtitle_format = cfg.get("subtitle_format", "ass_centered_narrow")
|
||||||
|
self.replace_single_newlines = cfg.get("replace_single_newlines", True)
|
||||||
|
self.save_option = cfg.get("save_option", "Save next to input file")
|
||||||
|
self.selected_output_folder = cfg.get("selected_output_folder")
|
||||||
|
self.silence_duration = cfg.get("silence_duration", 2.0)
|
||||||
|
self.max_subtitle_words = cfg.get("max_subtitle_words", 50)
|
||||||
|
self.separate_chapters_format = cfg.get("separate_chapters_format", "wav")
|
||||||
|
self.use_silent_gaps = cfg.get("use_silent_gaps", True)
|
||||||
|
self.subtitle_speed_method = cfg.get("subtitle_speed_method", "tts")
|
||||||
|
self.use_spacy_segmentation = cfg.get("use_spacy_segmentation", True)
|
||||||
|
self.chunk_level = cfg.get("chunk_level", "paragraph")
|
||||||
|
self.generate_epub3 = cfg.get("generate_epub3", False)
|
||||||
|
self.tts_provider = cfg.get("tts_provider", "kokoro")
|
||||||
|
self.supertonic_total_steps = cfg.get("supertonic_total_steps", 5)
|
||||||
|
self.chapter_intro_delay = cfg.get("chapter_intro_delay", 0.5)
|
||||||
|
self.read_title_intro = cfg.get("read_title_intro", False)
|
||||||
|
self.read_closing_outro = cfg.get("read_closing_outro", True)
|
||||||
|
self.auto_prefix_chapter_titles = cfg.get("auto_prefix_chapter_titles", True)
|
||||||
|
self.normalize_chapter_opening_caps = cfg.get("normalize_chapter_opening_caps", True)
|
||||||
|
self.speaker_analysis_threshold = cfg.get("speaker_analysis_threshold", 3)
|
||||||
|
self.word_substitutions_enabled = cfg.get("word_substitutions_enabled", False)
|
||||||
|
self.word_substitutions_list = cfg.get("word_substitutions_list", "")
|
||||||
|
self.case_sensitive_substitutions = cfg.get("case_sensitive_substitutions", False)
|
||||||
|
self.replace_all_caps = cfg.get("replace_all_caps", False)
|
||||||
|
self.replace_numerals = cfg.get("replace_numerals", False)
|
||||||
|
self.fix_nonstandard_punctuation = cfg.get("fix_nonstandard_punctuation", False)
|
||||||
|
|
||||||
|
# Integrations
|
||||||
|
integrations: Dict[str, Any] = cfg.get("integrations", {})
|
||||||
|
abs_cfg = integrations.get("audiobookshelf", {})
|
||||||
|
self.audiobookshelf_enabled = bool(abs_cfg.get("enabled", False))
|
||||||
|
self.audiobookshelf_base_url = str(abs_cfg.get("base_url", ""))
|
||||||
|
self.audiobookshelf_api_token = str(abs_cfg.get("api_token", ""))
|
||||||
|
self.audiobookshelf_library_id = str(abs_cfg.get("library_id", ""))
|
||||||
|
self.audiobookshelf_folder_id = str(abs_cfg.get("folder_id", ""))
|
||||||
|
self.audiobookshelf_verify_ssl = bool(abs_cfg.get("verify_ssl", True))
|
||||||
|
self.audiobookshelf_auto_send = bool(abs_cfg.get("auto_send", False))
|
||||||
|
self.audiobookshelf_send_cover = bool(abs_cfg.get("send_cover", True))
|
||||||
|
self.audiobookshelf_send_chapters = bool(abs_cfg.get("send_chapters", True))
|
||||||
|
self.audiobookshelf_send_subtitles = bool(abs_cfg.get("send_subtitles", False))
|
||||||
|
self.audiobookshelf_timeout = float(abs_cfg.get("timeout", 30.0))
|
||||||
|
|
||||||
|
cal_cfg = integrations.get("calibre_opds", {})
|
||||||
|
self.calibre_opds_enabled = bool(cal_cfg.get("enabled", False))
|
||||||
|
self.calibre_opds_base_url = str(cal_cfg.get("base_url", ""))
|
||||||
|
self.calibre_opds_username = str(cal_cfg.get("username", ""))
|
||||||
|
self.calibre_opds_password = str(cal_cfg.get("password", ""))
|
||||||
|
self.calibre_opds_verify_ssl = bool(cal_cfg.get("verify_ssl", True))
|
||||||
|
|
||||||
|
def persist_config(self) -> None:
|
||||||
|
"""
|
||||||
|
Write the current config snapshot back to disk.
|
||||||
|
|
||||||
|
Only the fields that map to the JSON config are written; runtime state
|
||||||
|
(progress, log_lines, callbacks) is not persisted.
|
||||||
|
Thread-safe.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
cfg = self.config.copy()
|
||||||
|
cfg["selected_voice"] = self.selected_voice
|
||||||
|
cfg["selected_profile_name"] = self.selected_profile_name
|
||||||
|
cfg["speed"] = self.speed
|
||||||
|
cfg["use_gpu"] = self.use_gpu
|
||||||
|
cfg["selected_format"] = self.selected_format
|
||||||
|
cfg["subtitle_mode"] = self.subtitle_mode
|
||||||
|
cfg["subtitle_format"] = self.subtitle_format
|
||||||
|
cfg["replace_single_newlines"] = self.replace_single_newlines
|
||||||
|
cfg["save_option"] = self.save_option
|
||||||
|
cfg["selected_output_folder"] = self.selected_output_folder
|
||||||
|
cfg["silence_duration"] = self.silence_duration
|
||||||
|
cfg["max_subtitle_words"] = self.max_subtitle_words
|
||||||
|
cfg["separate_chapters_format"] = self.separate_chapters_format
|
||||||
|
cfg["use_silent_gaps"] = self.use_silent_gaps
|
||||||
|
cfg["subtitle_speed_method"] = self.subtitle_speed_method
|
||||||
|
cfg["use_spacy_segmentation"] = self.use_spacy_segmentation
|
||||||
|
cfg["chunk_level"] = self.chunk_level
|
||||||
|
cfg["generate_epub3"] = self.generate_epub3
|
||||||
|
cfg["tts_provider"] = self.tts_provider
|
||||||
|
cfg["supertonic_total_steps"] = self.supertonic_total_steps
|
||||||
|
cfg["chapter_intro_delay"] = self.chapter_intro_delay
|
||||||
|
cfg["read_title_intro"] = self.read_title_intro
|
||||||
|
cfg["read_closing_outro"] = self.read_closing_outro
|
||||||
|
cfg["auto_prefix_chapter_titles"] = self.auto_prefix_chapter_titles
|
||||||
|
cfg["normalize_chapter_opening_caps"] = self.normalize_chapter_opening_caps
|
||||||
|
cfg["speaker_analysis_threshold"] = self.speaker_analysis_threshold
|
||||||
|
cfg["word_substitutions_enabled"] = self.word_substitutions_enabled
|
||||||
|
cfg["word_substitutions_list"] = self.word_substitutions_list
|
||||||
|
cfg["case_sensitive_substitutions"] = self.case_sensitive_substitutions
|
||||||
|
cfg["replace_all_caps"] = self.replace_all_caps
|
||||||
|
cfg["replace_numerals"] = self.replace_numerals
|
||||||
|
cfg["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
|
||||||
|
# Integrations
|
||||||
|
cfg.setdefault("integrations", {})
|
||||||
|
cfg["integrations"]["audiobookshelf"] = {
|
||||||
|
"enabled": self.audiobookshelf_enabled,
|
||||||
|
"base_url": self.audiobookshelf_base_url,
|
||||||
|
"api_token": self.audiobookshelf_api_token,
|
||||||
|
"library_id": self.audiobookshelf_library_id,
|
||||||
|
"folder_id": self.audiobookshelf_folder_id,
|
||||||
|
"verify_ssl": self.audiobookshelf_verify_ssl,
|
||||||
|
"auto_send": self.audiobookshelf_auto_send,
|
||||||
|
"send_cover": self.audiobookshelf_send_cover,
|
||||||
|
"send_chapters": self.audiobookshelf_send_chapters,
|
||||||
|
"send_subtitles": self.audiobookshelf_send_subtitles,
|
||||||
|
"timeout": self.audiobookshelf_timeout,
|
||||||
|
}
|
||||||
|
cfg["integrations"]["calibre_opds"] = {
|
||||||
|
"enabled": self.calibre_opds_enabled,
|
||||||
|
"base_url": self.calibre_opds_base_url,
|
||||||
|
"username": self.calibre_opds_username,
|
||||||
|
"password": self.calibre_opds_password,
|
||||||
|
"verify_ssl": self.calibre_opds_verify_ssl,
|
||||||
|
}
|
||||||
|
self.config = cfg
|
||||||
|
try:
|
||||||
|
save_config(cfg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def append_log(self, message: str, level: str = "info") -> None:
|
||||||
|
"""
|
||||||
|
Thread-safely append a log line and trigger the UI callback.
|
||||||
|
|
||||||
|
Caps the internal buffer at ``LOG_MAX_LINES`` to prevent unbounded
|
||||||
|
memory growth during very long conversion tasks.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self.log_lines.append(f"[{level.upper()}] {message}")
|
||||||
|
if len(self.log_lines) > self.LOG_MAX_LINES:
|
||||||
|
# Trim oldest 10 % to amortise the cost of trimming
|
||||||
|
trim = self.LOG_MAX_LINES // 10
|
||||||
|
self.log_lines = self.log_lines[trim:]
|
||||||
|
|
||||||
|
cb = self.on_log
|
||||||
|
if cb is not None:
|
||||||
|
try:
|
||||||
|
cb(message, level)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_progress(self, fraction: float, etr: Optional[float] = None) -> None:
|
||||||
|
"""
|
||||||
|
Update fractional progress and ETR, then notify the UI callback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fraction: Value in [0.0, 1.0].
|
||||||
|
etr: Estimated seconds remaining, or None.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self.progress = max(0.0, min(1.0, fraction))
|
||||||
|
self.etr_seconds = etr
|
||||||
|
|
||||||
|
cb = self.on_progress
|
||||||
|
if cb is not None:
|
||||||
|
try:
|
||||||
|
cb(fraction, etr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_voice_formula(self) -> str:
|
||||||
|
"""
|
||||||
|
Return the effective voice formula string.
|
||||||
|
|
||||||
|
Uses the mixed_voice_state if the formula mixer is active, otherwise
|
||||||
|
returns the raw selected_voice.
|
||||||
|
"""
|
||||||
|
if self.mixed_voice_state:
|
||||||
|
parts = [f"{name}*{weight}" for name, weight in self.mixed_voice_state]
|
||||||
|
return " + ".join(filter(None, parts))
|
||||||
|
return self.selected_voice or "af_heart"
|
||||||
|
|
||||||
|
def reset_file_state(self) -> None:
|
||||||
|
"""Clear all file-related fields without touching voice/settings."""
|
||||||
|
with self._lock:
|
||||||
|
self.selected_file = None
|
||||||
|
self.selected_file_type = None
|
||||||
|
self.selected_book_path = None
|
||||||
|
self.displayed_file_path = None
|
||||||
|
self.selected_chapters = []
|
||||||
|
self.save_chapters_separately = None
|
||||||
|
self.merge_chapters_at_end = None
|
||||||
|
self.save_as_project = False
|
||||||
|
self.char_count = 0
|
||||||
|
|
||||||
|
def reset_conversion_state(self) -> None:
|
||||||
|
"""Clear all runtime conversion fields to start fresh."""
|
||||||
|
with self._lock:
|
||||||
|
self.is_converting = False
|
||||||
|
self.is_cancelled = False
|
||||||
|
self.progress = 0.0
|
||||||
|
self.etr_seconds = None
|
||||||
|
self.last_output_path = None
|
||||||
|
self.log_lines = []
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Utils sub-package."""
|
||||||
|
from .helpers import (
|
||||||
|
human_readable_size,
|
||||||
|
format_duration,
|
||||||
|
format_etr,
|
||||||
|
detect_file_type,
|
||||||
|
is_supported_file,
|
||||||
|
is_book_type,
|
||||||
|
voice_lang_code,
|
||||||
|
language_label,
|
||||||
|
grouped_voices,
|
||||||
|
voice_display_name,
|
||||||
|
parse_voice_formula,
|
||||||
|
format_number,
|
||||||
|
safe_basename,
|
||||||
|
output_format_label,
|
||||||
|
subtitle_format_label,
|
||||||
|
SUPPORTED_EXTENSIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"human_readable_size",
|
||||||
|
"format_duration",
|
||||||
|
"format_etr",
|
||||||
|
"detect_file_type",
|
||||||
|
"is_supported_file",
|
||||||
|
"is_book_type",
|
||||||
|
"voice_lang_code",
|
||||||
|
"language_label",
|
||||||
|
"grouped_voices",
|
||||||
|
"voice_display_name",
|
||||||
|
"parse_voice_formula",
|
||||||
|
"format_number",
|
||||||
|
"safe_basename",
|
||||||
|
"output_format_label",
|
||||||
|
"subtitle_format_label",
|
||||||
|
"SUPPORTED_EXTENSIONS",
|
||||||
|
]
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
"""
|
||||||
|
Background conversion bridge for the Abogen Flet frontend.
|
||||||
|
|
||||||
|
This module wraps the existing ``abogen.webui.conversion_runner`` (and its
|
||||||
|
``ConversionService`` / ``Job`` machinery) in an async-friendly interface that
|
||||||
|
can push real-time progress and log updates back to the Flet event loop without
|
||||||
|
blocking the UI thread.
|
||||||
|
|
||||||
|
Key design decisions
|
||||||
|
--------------------
|
||||||
|
* All heavy work is offloaded to daemon threads. The Flet page event loop
|
||||||
|
is never blocked.
|
||||||
|
* Progress and log callbacks are scheduled back onto the Flet page via
|
||||||
|
``page.run_task()`` so Flet's session isolation remains intact.
|
||||||
|
* Cancellation is cooperative: the underlying job's ``cancel_requested``
|
||||||
|
flag is set, and the runner checks it at chunk boundaries.
|
||||||
|
* The module is a pure adapter – it does NOT duplicate any processing logic
|
||||||
|
from the core pipeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from abogen.utils import (
|
||||||
|
get_gpu_acceleration,
|
||||||
|
get_user_cache_path,
|
||||||
|
get_user_output_path,
|
||||||
|
load_numpy_kpipeline,
|
||||||
|
prevent_sleep_end,
|
||||||
|
prevent_sleep_start,
|
||||||
|
)
|
||||||
|
from abogen.webui.service import (
|
||||||
|
ConversionService,
|
||||||
|
Job,
|
||||||
|
JobStatus,
|
||||||
|
PendingJob,
|
||||||
|
build_service,
|
||||||
|
)
|
||||||
|
from abogen.webui.conversion_runner import run_conversion_job
|
||||||
|
|
||||||
|
from ..state import AppState
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level singleton ConversionService (shared across sessions, as in the
|
||||||
|
# web UI – but each job carries its own output folder keyed by session).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SERVICE_LOCK = threading.Lock()
|
||||||
|
_SERVICE: Optional[ConversionService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service() -> ConversionService:
|
||||||
|
"""
|
||||||
|
Return (creating if necessary) the module-level ConversionService.
|
||||||
|
|
||||||
|
The service manages the background worker thread and persistent job state.
|
||||||
|
Thread-safe via a module-level lock.
|
||||||
|
"""
|
||||||
|
global _SERVICE
|
||||||
|
with _SERVICE_LOCK:
|
||||||
|
if _SERVICE is None:
|
||||||
|
output_root = Path(get_user_output_path("frontend"))
|
||||||
|
uploads_root = Path(get_user_cache_path("frontend/uploads"))
|
||||||
|
_SERVICE = build_service(
|
||||||
|
runner=run_conversion_job,
|
||||||
|
output_root=output_root,
|
||||||
|
uploads_root=uploads_root,
|
||||||
|
)
|
||||||
|
return _SERVICE
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public conversion bridge
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ConversionBridge:
|
||||||
|
"""
|
||||||
|
Thin adapter between the Flet UI session and the core conversion pipeline.
|
||||||
|
|
||||||
|
One ``ConversionBridge`` instance is created per Flet page (session) and
|
||||||
|
is responsible for:
|
||||||
|
1. Accepting a conversion request from the UI.
|
||||||
|
2. Writing the input text to a temp file if needed.
|
||||||
|
3. Submitting the job to ``ConversionService``.
|
||||||
|
4. Polling the job from a daemon thread and forwarding progress/logs to
|
||||||
|
the Flet page via ``page.run_task()``.
|
||||||
|
5. Providing a ``cancel()`` method that sets the cooperative flag.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, page: ft.Page, state: AppState) -> None:
|
||||||
|
"""
|
||||||
|
Initialise the bridge.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: The Flet ``Page`` for this session. Used to schedule
|
||||||
|
UI callbacks on the correct event loop.
|
||||||
|
state: The session's ``AppState`` instance.
|
||||||
|
"""
|
||||||
|
self._page = page
|
||||||
|
self._state = state
|
||||||
|
self._current_job: Optional[Job] = None
|
||||||
|
self._poll_thread: Optional[threading.Thread] = None
|
||||||
|
self._stop_poll = threading.Event()
|
||||||
|
self._seen_log_count = 0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
input_file: str,
|
||||||
|
voice: str,
|
||||||
|
lang_code: str,
|
||||||
|
speed: float,
|
||||||
|
output_format: str,
|
||||||
|
subtitle_mode: str,
|
||||||
|
subtitle_format: str,
|
||||||
|
use_gpu: bool,
|
||||||
|
save_option: str,
|
||||||
|
output_folder: Optional[str],
|
||||||
|
replace_single_newlines: bool,
|
||||||
|
char_count: int,
|
||||||
|
chapters: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
save_chapters_separately: bool = False,
|
||||||
|
merge_chapters_at_end: bool = True,
|
||||||
|
separate_chapters_format: str = "wav",
|
||||||
|
silence_between_chapters: float = 2.0,
|
||||||
|
max_subtitle_words: int = 50,
|
||||||
|
chapter_intro_delay: float = 0.5,
|
||||||
|
read_title_intro: bool = False,
|
||||||
|
read_closing_outro: bool = True,
|
||||||
|
auto_prefix_chapter_titles: bool = True,
|
||||||
|
normalize_chapter_opening_caps: bool = True,
|
||||||
|
tts_provider: str = "kokoro",
|
||||||
|
supertonic_total_steps: int = 5,
|
||||||
|
chunk_level: str = "paragraph",
|
||||||
|
generate_epub3: bool = False,
|
||||||
|
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,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Submit a conversion job and begin the progress-polling loop.
|
||||||
|
|
||||||
|
This method returns immediately; all heavy work runs on daemon threads.
|
||||||
|
UI callbacks (``state.on_log``, ``state.on_progress``,
|
||||||
|
``state.on_conversion_finished``) are scheduled on the Flet event loop.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_file: Absolute path to the text/epub/pdf input file.
|
||||||
|
voice: Kokoro voice formula string.
|
||||||
|
lang_code: Single-char language code.
|
||||||
|
speed: Playback speed multiplier (0.1 – 2.0).
|
||||||
|
output_format: Audio container key (``'wav'``, ``'mp3'``, …).
|
||||||
|
subtitle_mode: Subtitle generation mode string.
|
||||||
|
subtitle_format: Subtitle container key (``'srt'``, ``'ass_wide'``, …).
|
||||||
|
use_gpu: Whether to request GPU acceleration.
|
||||||
|
save_option: Save-location strategy string.
|
||||||
|
output_folder: Explicit output folder or None.
|
||||||
|
replace_single_newlines: Pre-processing flag.
|
||||||
|
char_count: Pre-computed character count for ETR estimation.
|
||||||
|
chapters: Optional list of chapter dicts for epub/pdf.
|
||||||
|
save_chapters_separately: Split chapters into separate files.
|
||||||
|
merge_chapters_at_end: Merge chapter files into one after generation.
|
||||||
|
separate_chapters_format: Format for individual chapter files.
|
||||||
|
silence_between_chapters: Silence gap (seconds) between chapters.
|
||||||
|
max_subtitle_words: Maximum words per subtitle block.
|
||||||
|
chapter_intro_delay: Silence before chapter title announcement (s).
|
||||||
|
read_title_intro: Announce book title at the start.
|
||||||
|
read_closing_outro: Announce book title at the end.
|
||||||
|
auto_prefix_chapter_titles: Prepend "Chapter N." to titles.
|
||||||
|
normalize_chapter_opening_caps: Fix ALL-CAPS opening lines.
|
||||||
|
tts_provider: ``'kokoro'`` or ``'supertonic'``.
|
||||||
|
supertonic_total_steps: Quality steps for the Supertonic pipeline.
|
||||||
|
chunk_level: ``'paragraph'`` or ``'sentence'`` chunking granularity.
|
||||||
|
generate_epub3: Also produce an EPUB3 audiobook package.
|
||||||
|
word_substitutions_enabled: Toggle word-substitution pre-processing.
|
||||||
|
word_substitutions_list: Newline-delimited ``word|replacement`` rules.
|
||||||
|
case_sensitive_substitutions: Case-sensitive matching for substitutions.
|
||||||
|
replace_all_caps: Lowercase ALL-CAPS words.
|
||||||
|
replace_numerals: Convert digits to spoken words.
|
||||||
|
fix_nonstandard_punctuation: Normalise curly quotes etc.
|
||||||
|
"""
|
||||||
|
if self._state.is_converting:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Resolve the effective output folder
|
||||||
|
resolved_output: Optional[Path] = self._resolve_output_folder(
|
||||||
|
save_option=save_option,
|
||||||
|
output_folder=output_folder,
|
||||||
|
input_file=input_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the input file as a Path
|
||||||
|
stored_path = Path(input_file)
|
||||||
|
original_filename = stored_path.name
|
||||||
|
|
||||||
|
# Block signals until the job is submitted
|
||||||
|
prevent_sleep_start()
|
||||||
|
self._state.is_converting = True
|
||||||
|
self._state.is_cancelled = False
|
||||||
|
self._state.progress = 0.0
|
||||||
|
self._state.etr_seconds = None
|
||||||
|
self._state.log_lines = []
|
||||||
|
self._seen_log_count = 0
|
||||||
|
|
||||||
|
# Enqueue the job on the service
|
||||||
|
service = _get_service()
|
||||||
|
job = service.enqueue(
|
||||||
|
original_filename=original_filename,
|
||||||
|
stored_path=stored_path,
|
||||||
|
language=lang_code,
|
||||||
|
voice=voice,
|
||||||
|
speed=speed,
|
||||||
|
tts_provider=tts_provider,
|
||||||
|
supertonic_total_steps=supertonic_total_steps,
|
||||||
|
use_gpu=use_gpu,
|
||||||
|
subtitle_mode=subtitle_mode,
|
||||||
|
output_format=output_format,
|
||||||
|
save_mode=self._save_mode_key(save_option),
|
||||||
|
output_folder=resolved_output,
|
||||||
|
replace_single_newlines=replace_single_newlines,
|
||||||
|
subtitle_format=subtitle_format,
|
||||||
|
total_characters=char_count,
|
||||||
|
chapters=chapters or [],
|
||||||
|
save_chapters_separately=save_chapters_separately,
|
||||||
|
merge_chapters_at_end=merge_chapters_at_end,
|
||||||
|
separate_chapters_format=separate_chapters_format,
|
||||||
|
silence_between_chapters=silence_between_chapters,
|
||||||
|
max_subtitle_words=max_subtitle_words,
|
||||||
|
chapter_intro_delay=chapter_intro_delay,
|
||||||
|
read_title_intro=read_title_intro,
|
||||||
|
read_closing_outro=read_closing_outro,
|
||||||
|
auto_prefix_chapter_titles=auto_prefix_chapter_titles,
|
||||||
|
normalize_chapter_opening_caps=normalize_chapter_opening_caps,
|
||||||
|
chunk_level=chunk_level,
|
||||||
|
generate_epub3=generate_epub3,
|
||||||
|
)
|
||||||
|
self._current_job = job
|
||||||
|
|
||||||
|
# Persist word-substitution settings to config so the runner picks them up
|
||||||
|
self._state.word_substitutions_enabled = word_substitutions_enabled
|
||||||
|
self._state.word_substitutions_list = word_substitutions_list
|
||||||
|
self._state.case_sensitive_substitutions = case_sensitive_substitutions
|
||||||
|
self._state.replace_all_caps = replace_all_caps
|
||||||
|
self._state.replace_numerals = replace_numerals
|
||||||
|
self._state.fix_nonstandard_punctuation = fix_nonstandard_punctuation
|
||||||
|
self._state.persist_config()
|
||||||
|
|
||||||
|
# Start the poll thread
|
||||||
|
self._stop_poll.clear()
|
||||||
|
self._poll_thread = threading.Thread(
|
||||||
|
target=self._poll_job_loop, daemon=True, name="abogen-poll"
|
||||||
|
)
|
||||||
|
self._poll_thread.start()
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
"""
|
||||||
|
Request cancellation of the currently running job.
|
||||||
|
|
||||||
|
Sets the cooperative flag on the underlying ``Job`` object; the runner
|
||||||
|
will stop after completing the current text chunk.
|
||||||
|
"""
|
||||||
|
if self._current_job is not None:
|
||||||
|
self._state.is_cancelled = True
|
||||||
|
try:
|
||||||
|
_get_service().cancel(self._current_job.id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _save_mode_key(option: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert the human-readable save option to the service's internal key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
option: UI-facing string (``'Save next to input file'``, …).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Service key string.
|
||||||
|
"""
|
||||||
|
mapping = {
|
||||||
|
"Save next to input file": "save_next_to_input",
|
||||||
|
"Save to Desktop": "save_to_desktop",
|
||||||
|
"Choose output folder": "custom",
|
||||||
|
}
|
||||||
|
return mapping.get(option, "save_next_to_input")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_output_folder(
|
||||||
|
save_option: str,
|
||||||
|
output_folder: Optional[str],
|
||||||
|
input_file: str,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""
|
||||||
|
Return the output ``Path`` based on the save option, or None for
|
||||||
|
the "next to input" strategy (the runner handles that internally).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
save_option: UI-facing save strategy string.
|
||||||
|
output_folder: Explicit path when ``save_option`` is ``'Choose output folder'``.
|
||||||
|
input_file: Path to the source file for the ``'Save to Desktop'`` strategy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Resolved ``Path`` or ``None``.
|
||||||
|
"""
|
||||||
|
if save_option == "Choose output folder" and output_folder:
|
||||||
|
p = Path(output_folder)
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
if save_option == "Save to Desktop":
|
||||||
|
desktop = Path.home() / "Desktop"
|
||||||
|
desktop.mkdir(exist_ok=True)
|
||||||
|
return desktop
|
||||||
|
# "Save next to input file" – let the runner decide
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _poll_job_loop(self) -> None:
|
||||||
|
"""
|
||||||
|
Background daemon loop that polls the current Job for updates.
|
||||||
|
|
||||||
|
Runs until the job enters a terminal state or until ``_stop_poll``
|
||||||
|
is set. Uses ``page.run_task()`` to schedule UI updates on the Flet
|
||||||
|
event loop without triggering thread-safety violations.
|
||||||
|
"""
|
||||||
|
job = self._current_job
|
||||||
|
if job is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
service = _get_service()
|
||||||
|
POLL_INTERVAL = 0.25 # seconds
|
||||||
|
|
||||||
|
while not self._stop_poll.is_set():
|
||||||
|
# Re-fetch the current job state (it's mutated in-place by the runner)
|
||||||
|
current = service.get_job(job.id)
|
||||||
|
if current is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Forward new log lines
|
||||||
|
new_logs = current.logs[self._seen_log_count:]
|
||||||
|
self._seen_log_count += len(new_logs)
|
||||||
|
for log_entry in new_logs:
|
||||||
|
level = getattr(log_entry, "level", "info")
|
||||||
|
message = getattr(log_entry, "message", str(log_entry))
|
||||||
|
self._schedule_log(message, level)
|
||||||
|
|
||||||
|
# Forward progress
|
||||||
|
if current.progress is not None:
|
||||||
|
etr = getattr(current, "estimated_time_remaining", None)
|
||||||
|
self._schedule_progress(float(current.progress), etr)
|
||||||
|
|
||||||
|
# Check for terminal states
|
||||||
|
status = current.status
|
||||||
|
if status in (
|
||||||
|
JobStatus.COMPLETED,
|
||||||
|
JobStatus.FAILED,
|
||||||
|
JobStatus.CANCELLED,
|
||||||
|
):
|
||||||
|
output_path: Optional[str] = None
|
||||||
|
if current.result and current.result.audio_path:
|
||||||
|
output_path = str(current.result.audio_path)
|
||||||
|
if status == JobStatus.COMPLETED:
|
||||||
|
finish_msg = "Conversion completed successfully."
|
||||||
|
elif status == JobStatus.CANCELLED:
|
||||||
|
finish_msg = "Cancelled"
|
||||||
|
else:
|
||||||
|
finish_msg = f"Conversion failed: {current.error or 'Unknown error'}"
|
||||||
|
|
||||||
|
self._schedule_finished(finish_msg, output_path)
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
prevent_sleep_end()
|
||||||
|
self._state.is_converting = False
|
||||||
|
|
||||||
|
def _schedule_log(self, message: str, level: str) -> None:
|
||||||
|
"""Schedule a log update on the Flet event loop."""
|
||||||
|
state = self._state
|
||||||
|
page = self._page
|
||||||
|
state.append_log(message, level)
|
||||||
|
|
||||||
|
async def _update() -> None:
|
||||||
|
cb = state.on_log
|
||||||
|
if cb:
|
||||||
|
cb(message, level)
|
||||||
|
try:
|
||||||
|
page.update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.run_task(_update)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _schedule_progress(self, fraction: float, etr: Optional[float]) -> None:
|
||||||
|
"""Schedule a progress update on the Flet event loop."""
|
||||||
|
state = self._state
|
||||||
|
page = self._page
|
||||||
|
state.progress = max(0.0, min(1.0, fraction))
|
||||||
|
state.etr_seconds = etr
|
||||||
|
|
||||||
|
async def _update() -> None:
|
||||||
|
cb = state.on_progress
|
||||||
|
if cb:
|
||||||
|
cb(fraction, etr)
|
||||||
|
try:
|
||||||
|
page.update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.run_task(_update)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _schedule_finished(
|
||||||
|
self, message: str, output_path: Optional[str]
|
||||||
|
) -> None:
|
||||||
|
"""Schedule a completion notification on the Flet event loop."""
|
||||||
|
state = self._state
|
||||||
|
page = self._page
|
||||||
|
state.last_output_path = output_path
|
||||||
|
self._stop_poll.set()
|
||||||
|
|
||||||
|
async def _update() -> None:
|
||||||
|
state.is_converting = False
|
||||||
|
state.progress = 1.0
|
||||||
|
state.last_output_path = output_path
|
||||||
|
cb = state.on_conversion_finished
|
||||||
|
if cb:
|
||||||
|
cb(message, output_path)
|
||||||
|
try:
|
||||||
|
page.update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
page.run_task(_update)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""
|
||||||
|
Frontend-specific utilities for the Abogen Flet application.
|
||||||
|
|
||||||
|
Contains helpers for:
|
||||||
|
- Human-readable size / duration formatting
|
||||||
|
- Voice formula parsing and display
|
||||||
|
- File-type detection
|
||||||
|
- ETR (Estimated Time Remaining) formatting
|
||||||
|
- Path resolution that adapts to desktop vs. web context
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from abogen.constants import (
|
||||||
|
LANGUAGE_DESCRIPTIONS,
|
||||||
|
SUPPORTED_INPUT_FORMATS,
|
||||||
|
SUPPORTED_SOUND_FORMATS,
|
||||||
|
SUBTITLE_FORMATS,
|
||||||
|
VOICES_INTERNAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Size / duration helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def human_readable_size(size_bytes: int, decimal_places: int = 2) -> str:
|
||||||
|
"""
|
||||||
|
Convert a byte count into a human-readable string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
size_bytes: Number of bytes.
|
||||||
|
decimal_places: Significant decimal digits in the output.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A string like ``"3.14 MB"`` or ``"1.00 KB"``.
|
||||||
|
"""
|
||||||
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||||
|
if size_bytes < 1024.0:
|
||||||
|
return f"{size_bytes:.{decimal_places}f} {unit}"
|
||||||
|
size_bytes /= 1024.0 # type: ignore[assignment]
|
||||||
|
return f"{size_bytes:.{decimal_places}f} PB"
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: float) -> str:
|
||||||
|
"""
|
||||||
|
Format a duration in seconds as ``HH:MM:SS``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
seconds: Non-negative floating-point duration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A colon-delimited time string, e.g. ``"00:03:42"``.
|
||||||
|
"""
|
||||||
|
total = max(0, int(seconds))
|
||||||
|
h, remainder = divmod(total, 3600)
|
||||||
|
m, s = divmod(remainder, 60)
|
||||||
|
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_etr(etr_seconds: Optional[float]) -> str:
|
||||||
|
"""
|
||||||
|
Format an estimated time remaining value for the UI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
etr_seconds: Seconds remaining, or None when unknown.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Human-readable string such as ``"~3 min 42 sec"`` or ``"Calculating…"``.
|
||||||
|
"""
|
||||||
|
if etr_seconds is None:
|
||||||
|
return "Calculating…"
|
||||||
|
total = max(0, int(etr_seconds))
|
||||||
|
if total < 60:
|
||||||
|
return f"~{total} sec"
|
||||||
|
m, s = divmod(total, 60)
|
||||||
|
if m < 60:
|
||||||
|
return f"~{m} min {s} sec"
|
||||||
|
h, m = divmod(m, 60)
|
||||||
|
return f"~{h} h {m} min"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
SUPPORTED_EXTENSIONS: Tuple[str, ...] = (
|
||||||
|
".txt",
|
||||||
|
".epub",
|
||||||
|
".pdf",
|
||||||
|
".md",
|
||||||
|
".markdown",
|
||||||
|
".srt",
|
||||||
|
".ass",
|
||||||
|
".vtt",
|
||||||
|
)
|
||||||
|
"""All file extensions that the drop-zone accepts."""
|
||||||
|
|
||||||
|
|
||||||
|
def detect_file_type(file_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Return a normalised file-type token for the given path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Absolute or relative path to the input file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
One of ``'txt'``, ``'epub'``, ``'pdf'``, ``'markdown'``,
|
||||||
|
``'subtitle'``, or ``'unknown'``.
|
||||||
|
"""
|
||||||
|
ext = Path(file_path).suffix.lower()
|
||||||
|
if ext == ".epub":
|
||||||
|
return "epub"
|
||||||
|
if ext == ".pdf":
|
||||||
|
return "pdf"
|
||||||
|
if ext in (".md", ".markdown"):
|
||||||
|
return "markdown"
|
||||||
|
if ext in (".srt", ".ass", ".vtt"):
|
||||||
|
return "subtitle"
|
||||||
|
if ext == ".txt":
|
||||||
|
return "txt"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def is_supported_file(file_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Return True when the file extension is in the supported set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path whose extension is inspected.
|
||||||
|
"""
|
||||||
|
return Path(file_path).suffix.lower() in SUPPORTED_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def is_book_type(file_type: str) -> bool:
|
||||||
|
"""
|
||||||
|
Return True for file types that contain chapters / pages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_type: Token from ``detect_file_type()``.
|
||||||
|
"""
|
||||||
|
return file_type in ("epub", "pdf", "markdown")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Voice helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def voice_lang_code(voice: str) -> str:
|
||||||
|
"""
|
||||||
|
Extract the language code character from a Kokoro voice name.
|
||||||
|
|
||||||
|
The first character of every internal voice name encodes the language
|
||||||
|
(e.g. ``'a'`` for American English, ``'b'`` for British English).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
voice: Raw voice string like ``'af_heart'`` or a formula.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Single lowercase character, defaulting to ``'a'`` on failure.
|
||||||
|
"""
|
||||||
|
if not voice:
|
||||||
|
return "a"
|
||||||
|
# For plain voice IDs the first char is the language
|
||||||
|
if voice[0].isalpha() and "_" in voice[:4]:
|
||||||
|
return voice[0].lower()
|
||||||
|
# Formula: extract first alpha char
|
||||||
|
match = re.search(r"\b([a-z])", voice)
|
||||||
|
return match.group(1) if match else "a"
|
||||||
|
|
||||||
|
|
||||||
|
def language_label(lang_code: str) -> str:
|
||||||
|
"""
|
||||||
|
Return the human-readable label for a language code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lang_code: Single-character code (``'a'``, ``'b'``, …).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Display string, e.g. ``"American English"``.
|
||||||
|
"""
|
||||||
|
return LANGUAGE_DESCRIPTIONS.get(lang_code, lang_code.upper())
|
||||||
|
|
||||||
|
|
||||||
|
def grouped_voices() -> List[Tuple[str, List[str]]]:
|
||||||
|
"""
|
||||||
|
Return the internal voice list grouped by language for display.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ``(language_label, [voice_id, …])`` tuples.
|
||||||
|
"""
|
||||||
|
groups: dict[str, List[str]] = {}
|
||||||
|
for v in VOICES_INTERNAL:
|
||||||
|
lang = language_label(v[0])
|
||||||
|
groups.setdefault(lang, []).append(v)
|
||||||
|
return sorted(groups.items())
|
||||||
|
|
||||||
|
|
||||||
|
def voice_display_name(voice_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert a raw voice ID like ``'af_heart'`` to a prettier display name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
voice_id: Raw internal voice identifier.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string, e.g. ``"af_heart"`` (unchanged; may be enhanced later).
|
||||||
|
"""
|
||||||
|
return voice_id
|
||||||
|
|
||||||
|
|
||||||
|
def parse_voice_formula(formula: str) -> List[Tuple[str, float]]:
|
||||||
|
"""
|
||||||
|
Parse a Kokoro voice mix formula into a list of ``(voice_id, weight)`` tuples.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
``"af_heart*0.7+am_adam*0.3"`` → ``[('af_heart', 0.7), ('am_adam', 0.3)]``
|
||||||
|
|
||||||
|
Args:
|
||||||
|
formula: Space- or ``+``-joined mix formula string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed list; empty if parsing fails.
|
||||||
|
"""
|
||||||
|
parts: List[Tuple[str, float]] = []
|
||||||
|
for token in re.split(r"[+\s]+", formula.strip()):
|
||||||
|
token = token.strip()
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
if "*" in token:
|
||||||
|
name, _, weight_str = token.partition("*")
|
||||||
|
try:
|
||||||
|
parts.append((name.strip(), float(weight_str.strip())))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Bare voice id — assume full weight
|
||||||
|
if token in VOICES_INTERNAL:
|
||||||
|
parts.append((token, 1.0))
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Number formatting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def format_number(n: int) -> str:
|
||||||
|
"""
|
||||||
|
Format an integer with thousands separators.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
n: Integer value.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string, e.g. ``"1,234,567"``.
|
||||||
|
"""
|
||||||
|
return f"{n:,}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Path helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def safe_basename(path: Optional[str]) -> str:
|
||||||
|
"""
|
||||||
|
Return the basename of a path, or an empty string when path is None/empty.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Optional file-system path.
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
return os.path.basename(path)
|
||||||
|
|
||||||
|
|
||||||
|
def output_format_label(fmt: str) -> str:
|
||||||
|
"""
|
||||||
|
Return a display label for an audio output format key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fmt: Lowercase format key (``'wav'``, ``'mp3'``, …).
|
||||||
|
"""
|
||||||
|
labels = {
|
||||||
|
"wav": "WAV (lossless)",
|
||||||
|
"flac": "FLAC (lossless compressed)",
|
||||||
|
"mp3": "MP3",
|
||||||
|
"opus": "Opus (best compression)",
|
||||||
|
"m4b": "M4B (with chapters)",
|
||||||
|
}
|
||||||
|
return labels.get(fmt, fmt.upper())
|
||||||
|
|
||||||
|
|
||||||
|
def subtitle_format_label(key: str) -> str:
|
||||||
|
"""
|
||||||
|
Return the display label for a subtitle format key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Internal subtitle format key (e.g. ``'ass_centered_narrow'``).
|
||||||
|
"""
|
||||||
|
for k, label in SUBTITLE_FORMATS:
|
||||||
|
if k == key:
|
||||||
|
return label
|
||||||
|
return key
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""
|
||||||
|
Design tokens and theme configuration for the Abogen Flet frontend.
|
||||||
|
|
||||||
|
This module defines the application's complete colour palette, typography
|
||||||
|
scale, spacing constants, and border radii in one canonical place.
|
||||||
|
All component modules import from here; changing a value here propagates
|
||||||
|
instantly across the entire UI.
|
||||||
|
|
||||||
|
Flet's ``ft.Theme`` uses ``ColorScheme``, but for custom widgets we paint
|
||||||
|
directly with hex colours drawn from ``LIGHT`` and ``DARK`` palettes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Colour palettes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Palette:
|
||||||
|
"""A complete colour palette for one theme mode."""
|
||||||
|
|
||||||
|
# Backgrounds
|
||||||
|
bg_base: str # Deepest background (window / page)
|
||||||
|
bg_surface: str # Cards, panels, dialogs
|
||||||
|
bg_elevated: str # Slightly raised elements (toolbar, sidebar)
|
||||||
|
bg_input: str # Text-field / dropdown backgrounds
|
||||||
|
|
||||||
|
# Brand accent
|
||||||
|
accent: str # Primary interactive colour (buttons, links)
|
||||||
|
accent_muted: str # Hover tint over accents
|
||||||
|
accent_on: str # Text drawn on top of accent fills
|
||||||
|
|
||||||
|
# Semantic
|
||||||
|
success: str
|
||||||
|
error: str
|
||||||
|
warning: str
|
||||||
|
info: str
|
||||||
|
|
||||||
|
# Text hierarchy
|
||||||
|
text_primary: str
|
||||||
|
text_secondary: str
|
||||||
|
text_disabled: str
|
||||||
|
text_on_accent: str
|
||||||
|
|
||||||
|
# Borders / dividers
|
||||||
|
border: str
|
||||||
|
border_focused: str
|
||||||
|
divider: str
|
||||||
|
|
||||||
|
# Specific UI atoms
|
||||||
|
drop_zone_border: str
|
||||||
|
drop_zone_bg: str
|
||||||
|
drop_zone_active_border: str
|
||||||
|
drop_zone_active_bg: str
|
||||||
|
log_bg: str
|
||||||
|
log_text: str
|
||||||
|
progress_bar_bg: str
|
||||||
|
progress_bar_fill: str
|
||||||
|
sidebar_bg: str
|
||||||
|
sidebar_selected_bg: str
|
||||||
|
sidebar_selected_text: str
|
||||||
|
nav_indicator: str
|
||||||
|
|
||||||
|
|
||||||
|
DARK = _Palette(
|
||||||
|
bg_base="#0f1117",
|
||||||
|
bg_surface="#181b23",
|
||||||
|
bg_elevated="#1e2230",
|
||||||
|
bg_input="#252a38",
|
||||||
|
|
||||||
|
accent="#5b8af5",
|
||||||
|
accent_muted="#3a5fc4",
|
||||||
|
accent_on="#ffffff",
|
||||||
|
|
||||||
|
success="#42ad4a",
|
||||||
|
error="#e84e3c",
|
||||||
|
warning="#f5a623",
|
||||||
|
info="#5b8af5",
|
||||||
|
|
||||||
|
text_primary="#e8eaf0",
|
||||||
|
text_secondary="#9ba3b8",
|
||||||
|
text_disabled="#4e5568",
|
||||||
|
text_on_accent="#ffffff",
|
||||||
|
|
||||||
|
border="#2c3147",
|
||||||
|
border_focused="#5b8af5",
|
||||||
|
divider="#252a38",
|
||||||
|
|
||||||
|
drop_zone_border="#3a4466",
|
||||||
|
drop_zone_bg="#151928",
|
||||||
|
drop_zone_active_border="#42ad4a",
|
||||||
|
drop_zone_active_bg="#0d1f10",
|
||||||
|
log_bg="#0d1117",
|
||||||
|
log_text="#b0b8cc",
|
||||||
|
progress_bar_bg="#1e2230",
|
||||||
|
progress_bar_fill="#5b8af5",
|
||||||
|
sidebar_bg="#13161f",
|
||||||
|
sidebar_selected_bg="#252a38",
|
||||||
|
sidebar_selected_text="#5b8af5",
|
||||||
|
nav_indicator="#5b8af5",
|
||||||
|
)
|
||||||
|
|
||||||
|
LIGHT = _Palette(
|
||||||
|
bg_base="#f4f5f8",
|
||||||
|
bg_surface="#ffffff",
|
||||||
|
bg_elevated="#edf0f5",
|
||||||
|
bg_input="#f0f2f7",
|
||||||
|
|
||||||
|
accent="#3a5fc4",
|
||||||
|
accent_muted="#2a4fae",
|
||||||
|
accent_on="#ffffff",
|
||||||
|
|
||||||
|
success="#2e9437",
|
||||||
|
error="#c0392b",
|
||||||
|
warning="#d4870a",
|
||||||
|
info="#3a5fc4",
|
||||||
|
|
||||||
|
text_primary="#1a1d27",
|
||||||
|
text_secondary="#5a6172",
|
||||||
|
text_disabled="#9ba3b8",
|
||||||
|
text_on_accent="#ffffff",
|
||||||
|
|
||||||
|
border="#dce0ea",
|
||||||
|
border_focused="#3a5fc4",
|
||||||
|
divider="#e8ebf2",
|
||||||
|
|
||||||
|
drop_zone_border="#a8b4d0",
|
||||||
|
drop_zone_bg="#f7f8fd",
|
||||||
|
drop_zone_active_border="#2e9437",
|
||||||
|
drop_zone_active_bg="#f0fff1",
|
||||||
|
log_bg="#f8f9fc",
|
||||||
|
log_text="#3d4358",
|
||||||
|
progress_bar_bg="#e4e8f0",
|
||||||
|
progress_bar_fill="#3a5fc4",
|
||||||
|
sidebar_bg="#eff1f5",
|
||||||
|
sidebar_selected_bg="#dde3f2",
|
||||||
|
sidebar_selected_text="#3a5fc4",
|
||||||
|
nav_indicator="#3a5fc4",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Typography
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FONT_FAMILY = "Inter, Segoe UI, Roboto, system-ui, sans-serif"
|
||||||
|
FONT_SIZE_XS = 11
|
||||||
|
FONT_SIZE_SM = 12
|
||||||
|
FONT_SIZE_BASE = 14
|
||||||
|
FONT_SIZE_MD = 16
|
||||||
|
FONT_SIZE_LG = 20
|
||||||
|
FONT_SIZE_XL = 26
|
||||||
|
FONT_SIZE_DISPLAY = 34
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Spacing scale (pixels)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SPACE_XS = 4
|
||||||
|
SPACE_SM = 8
|
||||||
|
SPACE_MD = 12
|
||||||
|
SPACE_LG = 16
|
||||||
|
SPACE_XL = 24
|
||||||
|
SPACE_2XL = 32
|
||||||
|
SPACE_3XL = 48
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Border radii
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
RADIUS_SM = 6
|
||||||
|
RADIUS_MD = 10
|
||||||
|
RADIUS_LG = 16
|
||||||
|
RADIUS_FULL = 999 # Pill-shaped
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Flet ColorScheme builders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_color_scheme(palette: _Palette) -> ft.ColorScheme:
|
||||||
|
"""
|
||||||
|
Construct a ``ft.ColorScheme`` from a ``_Palette`` object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
palette: The ``DARK`` or ``LIGHT`` palette.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A fully-populated Flet ``ColorScheme``.
|
||||||
|
"""
|
||||||
|
return ft.ColorScheme(
|
||||||
|
primary=palette.accent,
|
||||||
|
on_primary=palette.accent_on,
|
||||||
|
primary_container=palette.accent_muted,
|
||||||
|
secondary=palette.accent,
|
||||||
|
on_secondary=palette.text_on_accent,
|
||||||
|
surface=palette.bg_surface,
|
||||||
|
on_surface=palette.text_primary,
|
||||||
|
on_surface_variant=palette.text_secondary,
|
||||||
|
error=palette.error,
|
||||||
|
on_error=palette.text_on_accent,
|
||||||
|
outline=palette.border,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_text_theme() -> ft.TextTheme:
|
||||||
|
"""
|
||||||
|
Construct a ``ft.TextTheme`` using the application's type scale.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A Flet ``TextTheme`` with consistent font-size assignments.
|
||||||
|
"""
|
||||||
|
return ft.TextTheme(
|
||||||
|
display_large=ft.TextStyle(size=FONT_SIZE_DISPLAY, weight=ft.FontWeight.W_700),
|
||||||
|
headline_large=ft.TextStyle(size=FONT_SIZE_XL, weight=ft.FontWeight.W_700),
|
||||||
|
headline_medium=ft.TextStyle(size=FONT_SIZE_LG, weight=ft.FontWeight.W_600),
|
||||||
|
title_large=ft.TextStyle(size=FONT_SIZE_MD, weight=ft.FontWeight.W_600),
|
||||||
|
title_medium=ft.TextStyle(size=FONT_SIZE_BASE, weight=ft.FontWeight.W_500),
|
||||||
|
body_large=ft.TextStyle(size=FONT_SIZE_BASE),
|
||||||
|
body_medium=ft.TextStyle(size=FONT_SIZE_SM),
|
||||||
|
label_large=ft.TextStyle(size=FONT_SIZE_SM, weight=ft.FontWeight.W_500),
|
||||||
|
label_medium=ft.TextStyle(size=FONT_SIZE_XS),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_theme(dark: bool) -> ft.Theme:
|
||||||
|
"""
|
||||||
|
Build a complete Flet ``Theme`` for the requested mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dark: True for dark-mode theme, False for light-mode theme.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A configured ``ft.Theme`` instance.
|
||||||
|
"""
|
||||||
|
palette = DARK if dark else LIGHT
|
||||||
|
return ft.Theme(
|
||||||
|
color_scheme=build_color_scheme(palette),
|
||||||
|
text_theme=build_text_theme(),
|
||||||
|
color_scheme_seed=palette.accent,
|
||||||
|
use_material3=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_palette(page: ft.Page) -> _Palette:
|
||||||
|
"""
|
||||||
|
Return the active colour palette for the given page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: The Flet ``Page`` instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``DARK`` or ``LIGHT`` depending on the page's theme mode.
|
||||||
|
"""
|
||||||
|
return DARK if page.theme_mode == ft.ThemeMode.DARK else LIGHT
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Views sub-package for the Abogen Flet frontend."""
|
||||||
|
from .dashboard import DashboardView
|
||||||
|
from .settings import SettingsView
|
||||||
|
from .queue_view import QueueView
|
||||||
|
|
||||||
|
__all__ = ["DashboardView", "SettingsView", "QueueView"]
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
"""
|
||||||
|
Dashboard view – the primary conversion screen.
|
||||||
|
|
||||||
|
Hosts the file drop-zone, voice/speed/format controls, real-time log
|
||||||
|
terminal, progress bar, and the Start/Cancel/Finish action row.
|
||||||
|
|
||||||
|
All heavy work is delegated to ConversionBridge which runs on daemon
|
||||||
|
threads and schedules UI updates back onto the Flet event loop.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from ..state import AppState
|
||||||
|
from ..utils.helpers import (
|
||||||
|
detect_file_type, human_readable_size, format_number,
|
||||||
|
format_etr, grouped_voices, output_format_label,
|
||||||
|
subtitle_format_label, is_book_type, voice_lang_code, SUPPORTED_EXTENSIONS
|
||||||
|
)
|
||||||
|
from ..utils.theme import get_palette, RADIUS_MD, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG, SPACE_XL
|
||||||
|
from ..utils.conversion_bridge import ConversionBridge
|
||||||
|
from ..components import (
|
||||||
|
build_drop_zone, build_log_terminal, log_entry,
|
||||||
|
build_primary_button, build_secondary_button,
|
||||||
|
build_card, build_section_header, labelled_row, show_snack,
|
||||||
|
)
|
||||||
|
from abogen.constants import (
|
||||||
|
SUBTITLE_FORMATS, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
|
LANGUAGE_DESCRIPTIONS, VOICES_INTERNAL,
|
||||||
|
)
|
||||||
|
from abogen.utils import get_gpu_acceleration, get_user_cache_path, calculate_text_length, clean_text
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardView:
|
||||||
|
"""
|
||||||
|
The main conversion dashboard.
|
||||||
|
|
||||||
|
Instantiated once per Flet session and mounted as a ``ft.Column``
|
||||||
|
inside the page's content area.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, page: ft.Page, state: AppState) -> None:
|
||||||
|
self._page = page
|
||||||
|
self._state = state
|
||||||
|
self._bridge = ConversionBridge(page, state)
|
||||||
|
|
||||||
|
# Internal refs
|
||||||
|
self._log_list: Optional[ft.ListView] = None
|
||||||
|
self._progress_bar: Optional[ft.ProgressBar] = None
|
||||||
|
self._etr_label: Optional[ft.Text] = None
|
||||||
|
self._drop_zone_ref: Optional[ft.GestureDetector] = None
|
||||||
|
self._drop_zone_container: Optional[ft.Container] = None
|
||||||
|
self._file_picker: Optional[ft.FilePicker] = None
|
||||||
|
|
||||||
|
# Wire state callbacks
|
||||||
|
state.on_log = self._on_log
|
||||||
|
state.on_progress = self._on_progress
|
||||||
|
state.on_conversion_finished = self._on_finished
|
||||||
|
|
||||||
|
# Build UI refs
|
||||||
|
self._voice_dd: Optional[ft.Dropdown] = None
|
||||||
|
self._speed_slider: Optional[ft.Slider] = None
|
||||||
|
self._speed_label: Optional[ft.Text] = None
|
||||||
|
self._format_dd: Optional[ft.Dropdown] = None
|
||||||
|
self._subtitle_dd: Optional[ft.Dropdown] = None
|
||||||
|
self._subtitle_fmt_dd: Optional[ft.Dropdown] = None
|
||||||
|
self._gpu_switch: Optional[ft.Switch] = None
|
||||||
|
self._start_btn: Optional[ft.ElevatedButton] = None
|
||||||
|
self._cancel_btn: Optional[ft.OutlinedButton] = None
|
||||||
|
self._finish_col: Optional[ft.Column] = None
|
||||||
|
self._controls_col: Optional[ft.Column] = None
|
||||||
|
self._log_section: Optional[ft.Container] = None
|
||||||
|
self._progress_col: Optional[ft.Column] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Build
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build(self) -> ft.Column:
|
||||||
|
"""Return the complete dashboard column."""
|
||||||
|
p = self._page
|
||||||
|
dark = p.theme_mode == ft.ThemeMode.DARK
|
||||||
|
pal = get_palette(p)
|
||||||
|
if self._file_picker is None:
|
||||||
|
self._file_picker = ft.FilePicker()
|
||||||
|
|
||||||
|
# --- Drop zone ---
|
||||||
|
self._drop_zone_container = ft.Container()
|
||||||
|
self._refresh_drop_zone()
|
||||||
|
|
||||||
|
# --- Voice selector ---
|
||||||
|
voice_items = []
|
||||||
|
for lang_label, voices in grouped_voices():
|
||||||
|
voice_items.append(ft.dropdown.Option(key=f"__hdr_{lang_label}", text=f"── {lang_label} ──", disabled=True))
|
||||||
|
for v in voices:
|
||||||
|
voice_items.append(ft.dropdown.Option(key=v, text=v))
|
||||||
|
|
||||||
|
self._voice_dd = ft.Dropdown(
|
||||||
|
options=voice_items,
|
||||||
|
value=self._state.selected_voice,
|
||||||
|
on_select=self._on_voice_changed,
|
||||||
|
dense=True,
|
||||||
|
expand=True,
|
||||||
|
border_radius=RADIUS_SM,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Speed slider ---
|
||||||
|
self._speed_label = ft.Text(f"{self._state.speed:.2f}", size=13, width=40)
|
||||||
|
self._speed_slider = ft.Slider(
|
||||||
|
min=0.1, max=2.0, value=self._state.speed,
|
||||||
|
divisions=190, label="{value}",
|
||||||
|
on_change=self._on_speed_changed,
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Format ---
|
||||||
|
self._format_dd = ft.Dropdown(
|
||||||
|
options=[ft.dropdown.Option(key=k, text=output_format_label(k))
|
||||||
|
for k in ("wav", "flac", "mp3", "opus", "m4b")],
|
||||||
|
value=self._state.selected_format,
|
||||||
|
on_select=lambda e: self._set_field("selected_format", e.control.value),
|
||||||
|
dense=True, expand=True, border_radius=RADIUS_SM,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Subtitle mode ---
|
||||||
|
sub_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma",
|
||||||
|
"Sentence + Highlighting"] + [f"{i} word{'s' if i > 1 else ''}" for i in range(1, 11)]
|
||||||
|
self._subtitle_dd = ft.Dropdown(
|
||||||
|
options=[ft.dropdown.Option(m) for m in sub_modes],
|
||||||
|
value=self._state.subtitle_mode,
|
||||||
|
on_select=lambda e: self._set_field("subtitle_mode", e.control.value),
|
||||||
|
dense=True, expand=True, border_radius=RADIUS_SM,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Subtitle format ---
|
||||||
|
self._subtitle_fmt_dd = ft.Dropdown(
|
||||||
|
options=[ft.dropdown.Option(key=k, text=lbl) for k, lbl in SUBTITLE_FORMATS],
|
||||||
|
value=self._state.subtitle_format,
|
||||||
|
on_select=lambda e: self._set_field("subtitle_format", e.control.value),
|
||||||
|
dense=True, expand=True, border_radius=RADIUS_SM,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- GPU ---
|
||||||
|
self._gpu_switch = ft.Switch(
|
||||||
|
value=self._state.use_gpu, label="",
|
||||||
|
on_change=lambda e: self._set_field("use_gpu", e.control.value),
|
||||||
|
active_color="#5b8af5" if dark else "#3a5fc4",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Log ---
|
||||||
|
log_lv = ft.ListView(expand=True, auto_scroll=True, spacing=1, padding=ft.Padding.all(8))
|
||||||
|
self._log_list = log_lv
|
||||||
|
bg_log = "#0d1117" if dark else "#f8f9fc"
|
||||||
|
bd_log = "#252a38" if dark else "#dce0ea"
|
||||||
|
self._log_section = ft.Container(
|
||||||
|
content=log_lv, bgcolor=bg_log,
|
||||||
|
border=ft.Border.all(1, bd_log),
|
||||||
|
border_radius=RADIUS_SM, height=220,
|
||||||
|
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||||
|
visible=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Progress ---
|
||||||
|
fill = "#5b8af5" if dark else "#3a5fc4"
|
||||||
|
bg_p = "#1e2230" if dark else "#e4e8f0"
|
||||||
|
self._progress_bar = ft.ProgressBar(
|
||||||
|
value=0, color=fill, bgcolor=bg_p, height=8,
|
||||||
|
border_radius=ft.BorderRadius.all(4), expand=True,
|
||||||
|
)
|
||||||
|
self._etr_label = ft.Text("", size=11, color=pal.text_secondary, text_align=ft.TextAlign.CENTER)
|
||||||
|
self._progress_col = ft.Column([
|
||||||
|
ft.Row([self._progress_bar], spacing=0),
|
||||||
|
self._etr_label,
|
||||||
|
], spacing=SPACE_SM, horizontal_alignment=ft.CrossAxisAlignment.CENTER, visible=False)
|
||||||
|
|
||||||
|
# --- Buttons ---
|
||||||
|
self._start_btn = build_primary_button(
|
||||||
|
"Start Conversion",
|
||||||
|
icon="play_arrow",
|
||||||
|
on_click=self._on_start,
|
||||||
|
page=p,
|
||||||
|
)
|
||||||
|
self._cancel_btn = build_secondary_button(
|
||||||
|
"Cancel", icon="stop",
|
||||||
|
on_click=self._on_cancel, page=p,
|
||||||
|
)
|
||||||
|
self._cancel_btn.visible = False
|
||||||
|
|
||||||
|
# --- Finish row ---
|
||||||
|
self._finish_col = ft.Column([
|
||||||
|
ft.Row([
|
||||||
|
build_secondary_button("Open File", icon="open_in_new",
|
||||||
|
on_click=self._on_open_file, page=p),
|
||||||
|
build_secondary_button("Go to Folder", icon="folder_open",
|
||||||
|
on_click=self._on_go_folder, page=p),
|
||||||
|
build_secondary_button("New Conversion", icon="refresh",
|
||||||
|
on_click=self._on_reset, page=p),
|
||||||
|
], wrap=True, spacing=SPACE_SM, run_spacing=SPACE_SM),
|
||||||
|
], visible=False)
|
||||||
|
|
||||||
|
# --- Controls column ---
|
||||||
|
self._controls_col = ft.Column([
|
||||||
|
build_section_header("Voice & Speed", icon="record_voice_over", page=p),
|
||||||
|
labelled_row("Voice", self._voice_dd, page=p),
|
||||||
|
labelled_row("Speed", ft.Row([self._speed_slider, self._speed_label], expand=True, spacing=SPACE_SM), page=p),
|
||||||
|
ft.Divider(height=1, color=pal.divider),
|
||||||
|
build_section_header("Output", icon="audio_file", page=p),
|
||||||
|
labelled_row("Format", self._format_dd, page=p),
|
||||||
|
labelled_row("Subtitles", self._subtitle_dd, page=p),
|
||||||
|
labelled_row("Subtitle Format", self._subtitle_fmt_dd, page=p),
|
||||||
|
ft.Divider(height=1, color=pal.divider),
|
||||||
|
build_section_header("Processing", icon="memory", page=p),
|
||||||
|
labelled_row("GPU Acceleration", self._gpu_switch, page=p),
|
||||||
|
], spacing=SPACE_MD)
|
||||||
|
|
||||||
|
outer = ft.Column([
|
||||||
|
self._drop_zone_container,
|
||||||
|
ft.Container(height=SPACE_MD),
|
||||||
|
build_card(self._controls_col, page=p),
|
||||||
|
ft.Container(height=SPACE_SM),
|
||||||
|
self._log_section,
|
||||||
|
self._progress_col,
|
||||||
|
ft.Row([self._start_btn, self._cancel_btn], spacing=SPACE_SM, wrap=True),
|
||||||
|
self._finish_col,
|
||||||
|
], spacing=SPACE_MD, expand=True, scroll=ft.ScrollMode.AUTO)
|
||||||
|
|
||||||
|
return outer
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Drop-zone management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _refresh_drop_zone(self, *, accent: bool = False, error: bool = False, err_msg: str = "") -> None:
|
||||||
|
"""Rebuild the drop-zone widget and update its container."""
|
||||||
|
p = self._page
|
||||||
|
s = self._state
|
||||||
|
fname = None; fsize = None; fchars = None
|
||||||
|
if s.selected_file and os.path.exists(s.selected_file):
|
||||||
|
disp = s.displayed_file_path or s.selected_file
|
||||||
|
fname = os.path.basename(disp)
|
||||||
|
try:
|
||||||
|
fsize = human_readable_size(os.path.getsize(s.selected_file))
|
||||||
|
except Exception:
|
||||||
|
fsize = ""
|
||||||
|
if s.char_count:
|
||||||
|
fchars = format_number(s.char_count)
|
||||||
|
|
||||||
|
label = err_msg if error else "Drag & drop your file here or click to browse"
|
||||||
|
sub = "Supports .txt · .epub · .pdf · .md · .srt · .ass · .vtt"
|
||||||
|
|
||||||
|
dz = build_drop_zone(
|
||||||
|
on_pick=self._open_file_picker,
|
||||||
|
label=label, sub_label=sub,
|
||||||
|
accent=accent, error=error,
|
||||||
|
filename=fname, file_size=fsize, char_count=fchars,
|
||||||
|
page=p,
|
||||||
|
)
|
||||||
|
if self._drop_zone_container is not None:
|
||||||
|
self._drop_zone_container.content = dz
|
||||||
|
self._drop_zone_ref = dz
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# File picking
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _open_file_picker(self) -> None:
|
||||||
|
"""Open the native file picker dialog."""
|
||||||
|
self._page.run_task(self._pick_files_async)
|
||||||
|
|
||||||
|
async def _pick_files_async(self) -> None:
|
||||||
|
"""Run the file picker using Flet's async service API."""
|
||||||
|
picker = self._file_picker
|
||||||
|
if picker is None:
|
||||||
|
picker = ft.FilePicker()
|
||||||
|
self._file_picker = picker
|
||||||
|
|
||||||
|
try:
|
||||||
|
files = await picker.pick_files(
|
||||||
|
dialog_title="Select Input File",
|
||||||
|
file_type=ft.FilePickerFileType.CUSTOM,
|
||||||
|
allowed_extensions=["txt", "epub", "pdf", "md", "markdown", "srt", "ass", "vtt"],
|
||||||
|
allow_multiple=False,
|
||||||
|
)
|
||||||
|
except Exception as ex:
|
||||||
|
self._refresh_drop_zone(error=True, err_msg="Could not open file picker.")
|
||||||
|
show_snack(self._page, f"File picker error: {ex}", error=True)
|
||||||
|
self._page.update()
|
||||||
|
return
|
||||||
|
if not files:
|
||||||
|
return
|
||||||
|
file_path = files[0].path
|
||||||
|
if not file_path or not os.path.exists(file_path):
|
||||||
|
return
|
||||||
|
self._load_file(file_path)
|
||||||
|
|
||||||
|
def _load_file(self, file_path: str) -> None:
|
||||||
|
"""Validate and load a file into the session state."""
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
ext = _Path(file_path).suffix.lower()
|
||||||
|
if ext not in SUPPORTED_EXTENSIONS:
|
||||||
|
self._state.reset_file_state()
|
||||||
|
self._refresh_drop_zone(error=True, err_msg=f"Unsupported file type: {ext}")
|
||||||
|
self._page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
ftype = detect_file_type(file_path)
|
||||||
|
s = self._state
|
||||||
|
|
||||||
|
if ftype in ("epub", "pdf", "markdown"):
|
||||||
|
# For book types: extract text to temp cache
|
||||||
|
self._handle_book_file(file_path, ftype)
|
||||||
|
else:
|
||||||
|
# Plain text / subtitle files
|
||||||
|
s.selected_file = file_path
|
||||||
|
s.selected_file_type = ftype
|
||||||
|
s.displayed_file_path = file_path
|
||||||
|
try:
|
||||||
|
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
|
text = f.read()
|
||||||
|
s.char_count = calculate_text_length(clean_text(text))
|
||||||
|
except Exception:
|
||||||
|
s.char_count = 0
|
||||||
|
self._refresh_drop_zone(accent=True)
|
||||||
|
self._update_subtitle_availability()
|
||||||
|
self._page.update()
|
||||||
|
|
||||||
|
def _handle_book_file(self, book_path: str, ftype: str) -> None:
|
||||||
|
"""Extract text from epub/pdf/markdown and store as temp txt."""
|
||||||
|
import threading as _t
|
||||||
|
s = self._state
|
||||||
|
|
||||||
|
def _extract():
|
||||||
|
try:
|
||||||
|
from abogen.text_extractor import extract_from_path
|
||||||
|
chapters = extract_from_path(book_path, file_type=ftype)
|
||||||
|
combined = "\n\n".join(ch.text for ch in chapters if ch.text.strip())
|
||||||
|
cache_dir = get_user_cache_path()
|
||||||
|
base = os.path.splitext(os.path.basename(book_path))[0]
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix=f"{base}_", suffix=".txt", dir=cache_dir)
|
||||||
|
os.close(fd)
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
f.write(combined)
|
||||||
|
|
||||||
|
s.selected_file = tmp
|
||||||
|
s.selected_file_type = ftype
|
||||||
|
s.selected_book_path = book_path
|
||||||
|
s.displayed_file_path = book_path
|
||||||
|
s.char_count = calculate_text_length(clean_text(combined))
|
||||||
|
s.selected_chapters = [f"ch_{i}" for i in range(len(chapters))]
|
||||||
|
|
||||||
|
self._refresh_drop_zone(accent=True)
|
||||||
|
self._update_subtitle_availability()
|
||||||
|
self._page.update()
|
||||||
|
except Exception as ex:
|
||||||
|
s.reset_file_state()
|
||||||
|
self._refresh_drop_zone(error=True, err_msg=f"Could not parse file: {ex}")
|
||||||
|
self._page.update()
|
||||||
|
|
||||||
|
_t.Thread(target=_extract, daemon=True).start()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Control event handlers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _set_field(self, attr: str, value) -> None:
|
||||||
|
setattr(self._state, attr, value)
|
||||||
|
self._state.persist_config()
|
||||||
|
|
||||||
|
def _on_voice_changed(self, e: ft.ControlEvent) -> None:
|
||||||
|
v = e.control.value or "af_heart"
|
||||||
|
self._state.selected_voice = v
|
||||||
|
self._state.selected_lang = voice_lang_code(v)
|
||||||
|
self._state.persist_config()
|
||||||
|
self._update_subtitle_availability()
|
||||||
|
self._page.update()
|
||||||
|
|
||||||
|
def _on_speed_changed(self, e: ft.ControlEvent) -> None:
|
||||||
|
val = round(float(e.control.value), 2)
|
||||||
|
self._state.speed = val
|
||||||
|
if self._speed_label:
|
||||||
|
self._speed_label.value = f"{val:.2f}"
|
||||||
|
self._state.persist_config()
|
||||||
|
self._page.update()
|
||||||
|
|
||||||
|
def _update_subtitle_availability(self) -> None:
|
||||||
|
"""Enable or disable subtitle controls based on selected language."""
|
||||||
|
lang = self._state.selected_lang
|
||||||
|
enabled = lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
||||||
|
if self._subtitle_dd:
|
||||||
|
self._subtitle_dd.disabled = not enabled
|
||||||
|
if self._subtitle_fmt_dd:
|
||||||
|
self._subtitle_fmt_dd.disabled = not enabled
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Conversion control
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_start(self, _: ft.ControlEvent) -> None:
|
||||||
|
"""Validate inputs and kick off conversion."""
|
||||||
|
s = self._state
|
||||||
|
if not s.selected_file or not os.path.exists(s.selected_file):
|
||||||
|
self._refresh_drop_zone(error=True, err_msg="Please select an input file first.")
|
||||||
|
self._page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Transition UI to converting state
|
||||||
|
self._set_converting_ui(True)
|
||||||
|
|
||||||
|
self._bridge.start(
|
||||||
|
input_file=s.selected_file,
|
||||||
|
voice=s.get_voice_formula(),
|
||||||
|
lang_code=s.selected_lang,
|
||||||
|
speed=s.speed,
|
||||||
|
output_format=s.selected_format,
|
||||||
|
subtitle_mode=s.subtitle_mode,
|
||||||
|
subtitle_format=s.subtitle_format,
|
||||||
|
use_gpu=s.use_gpu,
|
||||||
|
save_option=s.save_option,
|
||||||
|
output_folder=s.selected_output_folder,
|
||||||
|
replace_single_newlines=s.replace_single_newlines,
|
||||||
|
char_count=s.char_count,
|
||||||
|
save_chapters_separately=s.save_chapters_separately or False,
|
||||||
|
merge_chapters_at_end=True if s.merge_chapters_at_end is None else s.merge_chapters_at_end,
|
||||||
|
separate_chapters_format=s.separate_chapters_format,
|
||||||
|
silence_between_chapters=s.silence_duration,
|
||||||
|
max_subtitle_words=s.max_subtitle_words,
|
||||||
|
chapter_intro_delay=s.chapter_intro_delay,
|
||||||
|
read_title_intro=s.read_title_intro,
|
||||||
|
read_closing_outro=s.read_closing_outro,
|
||||||
|
auto_prefix_chapter_titles=s.auto_prefix_chapter_titles,
|
||||||
|
normalize_chapter_opening_caps=s.normalize_chapter_opening_caps,
|
||||||
|
tts_provider=s.tts_provider,
|
||||||
|
supertonic_total_steps=s.supertonic_total_steps,
|
||||||
|
chunk_level=s.chunk_level,
|
||||||
|
generate_epub3=s.generate_epub3,
|
||||||
|
word_substitutions_enabled=s.word_substitutions_enabled,
|
||||||
|
word_substitutions_list=s.word_substitutions_list,
|
||||||
|
case_sensitive_substitutions=s.case_sensitive_substitutions,
|
||||||
|
replace_all_caps=s.replace_all_caps,
|
||||||
|
replace_numerals=s.replace_numerals,
|
||||||
|
fix_nonstandard_punctuation=s.fix_nonstandard_punctuation,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_cancel(self, _: ft.ControlEvent) -> None:
|
||||||
|
self._bridge.cancel()
|
||||||
|
|
||||||
|
def _set_converting_ui(self, converting: bool) -> None:
|
||||||
|
"""Toggle UI between idle and converting states."""
|
||||||
|
if self._start_btn:
|
||||||
|
self._start_btn.visible = not converting
|
||||||
|
if self._cancel_btn:
|
||||||
|
self._cancel_btn.visible = converting
|
||||||
|
if self._controls_col:
|
||||||
|
self._controls_col.visible = not converting
|
||||||
|
if self._log_section:
|
||||||
|
self._log_section.visible = converting
|
||||||
|
if self._log_list:
|
||||||
|
self._log_list.controls.clear()
|
||||||
|
if self._progress_col:
|
||||||
|
self._progress_col.visible = converting
|
||||||
|
if self._progress_bar:
|
||||||
|
self._progress_bar.value = 0
|
||||||
|
if self._etr_label:
|
||||||
|
self._etr_label.value = "Estimating…"
|
||||||
|
if self._finish_col:
|
||||||
|
self._finish_col.visible = False
|
||||||
|
self._page.update()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# State callbacks (called from background thread via page.run_task)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_log(self, message: str, level: str) -> None:
|
||||||
|
if self._log_list is None:
|
||||||
|
return
|
||||||
|
entry = log_entry(message, level, self._page)
|
||||||
|
self._log_list.controls.append(entry)
|
||||||
|
# Cap log lines
|
||||||
|
if len(self._log_list.controls) > 2000:
|
||||||
|
self._log_list.controls = self._log_list.controls[-1800:]
|
||||||
|
try:
|
||||||
|
self._page.update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_progress(self, fraction: float, etr: Optional[float]) -> None:
|
||||||
|
if self._progress_bar:
|
||||||
|
self._progress_bar.value = min(fraction, 0.99)
|
||||||
|
if self._etr_label:
|
||||||
|
self._etr_label.value = format_etr(etr)
|
||||||
|
try:
|
||||||
|
self._page.update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_finished(self, message: str, output_path: Optional[str]) -> None:
|
||||||
|
if self._progress_bar:
|
||||||
|
self._progress_bar.value = 1.0
|
||||||
|
if self._cancel_btn:
|
||||||
|
self._cancel_btn.visible = False
|
||||||
|
|
||||||
|
if message == "Cancelled":
|
||||||
|
# Restore idle state
|
||||||
|
self._set_converting_ui(False)
|
||||||
|
show_snack(self._page, "Conversion cancelled.", error=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if "failed" in message.lower() or "error" in message.lower():
|
||||||
|
self._log_on_log(message, "error")
|
||||||
|
self._set_converting_ui(False)
|
||||||
|
show_snack(self._page, f"Error: {message}", error=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Success
|
||||||
|
if self._log_section:
|
||||||
|
self._log_section.visible = True
|
||||||
|
if self._progress_col:
|
||||||
|
self._progress_col.visible = False
|
||||||
|
if self._controls_col:
|
||||||
|
self._controls_col.visible = False
|
||||||
|
if self._finish_col:
|
||||||
|
self._finish_col.visible = True
|
||||||
|
if self._start_btn:
|
||||||
|
self._start_btn.visible = False
|
||||||
|
show_snack(self._page, "Conversion completed!")
|
||||||
|
try:
|
||||||
|
self._page.update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _log_on_log(self, message: str, level: str) -> None:
|
||||||
|
self._on_log(message, level)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Finish actions
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_open_file(self, _: ft.ControlEvent) -> None:
|
||||||
|
path = self._state.last_output_path
|
||||||
|
if path and os.path.exists(path):
|
||||||
|
import subprocess, platform
|
||||||
|
try:
|
||||||
|
if platform.system() == "Darwin":
|
||||||
|
subprocess.Popen(["open", path])
|
||||||
|
elif platform.system() == "Windows":
|
||||||
|
os.startfile(path)
|
||||||
|
else:
|
||||||
|
subprocess.Popen(["xdg-open", path])
|
||||||
|
except Exception as ex:
|
||||||
|
show_snack(self._page, f"Cannot open file: {ex}", error=True)
|
||||||
|
else:
|
||||||
|
show_snack(self._page, "Output file not found.", error=True)
|
||||||
|
|
||||||
|
def _on_go_folder(self, _: ft.ControlEvent) -> None:
|
||||||
|
path = self._state.last_output_path
|
||||||
|
folder = os.path.dirname(path) if path and os.path.isfile(path) else path
|
||||||
|
if folder and os.path.isdir(folder):
|
||||||
|
import subprocess, platform
|
||||||
|
try:
|
||||||
|
if platform.system() == "Darwin":
|
||||||
|
subprocess.Popen(["open", folder])
|
||||||
|
elif platform.system() == "Windows":
|
||||||
|
subprocess.Popen(["explorer", folder])
|
||||||
|
else:
|
||||||
|
subprocess.Popen(["xdg-open", folder])
|
||||||
|
except Exception as ex:
|
||||||
|
show_snack(self._page, f"Cannot open folder: {ex}", error=True)
|
||||||
|
else:
|
||||||
|
show_snack(self._page, "Output folder not found.", error=True)
|
||||||
|
|
||||||
|
def _on_reset(self, _: ft.ControlEvent) -> None:
|
||||||
|
self._state.reset_file_state()
|
||||||
|
self._state.reset_conversion_state()
|
||||||
|
self._refresh_drop_zone()
|
||||||
|
self._set_converting_ui(False)
|
||||||
|
if self._finish_col:
|
||||||
|
self._finish_col.visible = False
|
||||||
|
if self._controls_col:
|
||||||
|
self._controls_col.visible = True
|
||||||
|
if self._start_btn:
|
||||||
|
self._start_btn.visible = True
|
||||||
|
self._page.update()
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
Queue management view.
|
||||||
|
|
||||||
|
Displays the current conversion queue, allowing the user to reorder,
|
||||||
|
remove, and inspect queued items before starting batch processing.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from ..state import AppState, ConversionJob
|
||||||
|
from ..utils.theme import get_palette, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG
|
||||||
|
from ..utils.helpers import safe_basename, output_format_label, format_number
|
||||||
|
from ..components import (
|
||||||
|
build_card, build_section_header, build_primary_button,
|
||||||
|
build_secondary_button, show_snack, build_divider,
|
||||||
|
resolve_icon,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QueueView:
|
||||||
|
"""Queue manager view."""
|
||||||
|
|
||||||
|
def __init__(self, page: ft.Page, state: AppState) -> None:
|
||||||
|
self._page = page
|
||||||
|
self._state = state
|
||||||
|
self._list_col: Optional[ft.Column] = None
|
||||||
|
|
||||||
|
def build(self) -> ft.Column:
|
||||||
|
p = self._page
|
||||||
|
s = self._state
|
||||||
|
pal = get_palette(p)
|
||||||
|
dark = p.theme_mode == ft.ThemeMode.DARK
|
||||||
|
|
||||||
|
self._list_col = ft.Column(spacing=SPACE_SM)
|
||||||
|
self._refresh_list()
|
||||||
|
|
||||||
|
header = build_section_header("Conversion Queue",
|
||||||
|
icon="list_alt", page=p)
|
||||||
|
|
||||||
|
action_row = ft.Row([
|
||||||
|
build_primary_button(
|
||||||
|
"Start Queue",
|
||||||
|
icon="play_arrow",
|
||||||
|
on_click=self._on_start_queue,
|
||||||
|
page=p,
|
||||||
|
disabled=not s.queued_items,
|
||||||
|
),
|
||||||
|
build_secondary_button(
|
||||||
|
"Clear All",
|
||||||
|
icon="delete_sweep",
|
||||||
|
on_click=self._on_clear_queue,
|
||||||
|
page=p,
|
||||||
|
),
|
||||||
|
], spacing=SPACE_SM, wrap=True)
|
||||||
|
|
||||||
|
queue_card = build_card(ft.Column([
|
||||||
|
header,
|
||||||
|
ft.Divider(height=1, color=pal.divider),
|
||||||
|
self._list_col,
|
||||||
|
ft.Container(height=SPACE_SM),
|
||||||
|
action_row,
|
||||||
|
], spacing=SPACE_MD), page=p)
|
||||||
|
|
||||||
|
return ft.Column([queue_card], scroll=ft.ScrollMode.AUTO, expand=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _refresh_list(self) -> None:
|
||||||
|
if self._list_col is None:
|
||||||
|
return
|
||||||
|
self._list_col.controls.clear()
|
||||||
|
s = self._state
|
||||||
|
pal = get_palette(self._page)
|
||||||
|
dark = self._page.theme_mode == ft.ThemeMode.DARK
|
||||||
|
|
||||||
|
if not s.queued_items:
|
||||||
|
self._list_col.controls.append(
|
||||||
|
ft.Text("No items in the queue.", size=13,
|
||||||
|
color=pal.text_secondary,
|
||||||
|
text_align=ft.TextAlign.CENTER)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for idx, job in enumerate(s.queued_items):
|
||||||
|
tile = self._build_job_tile(idx, job, dark, pal)
|
||||||
|
self._list_col.controls.append(tile)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._page.update()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _build_job_tile(self, idx: int, job: ConversionJob, dark: bool, pal) -> ft.Container:
|
||||||
|
"""Build a single queue-item tile."""
|
||||||
|
bg = pal.bg_elevated
|
||||||
|
border_clr = pal.border
|
||||||
|
accent = "#5b8af5" if dark else "#3a5fc4"
|
||||||
|
text_primary = pal.text_primary
|
||||||
|
text_secondary = pal.text_secondary
|
||||||
|
|
||||||
|
def _remove(_):
|
||||||
|
self._state.queued_items.pop(idx)
|
||||||
|
self._refresh_list()
|
||||||
|
|
||||||
|
name = safe_basename(job.display_name or job.file_path)
|
||||||
|
details = (
|
||||||
|
f"Voice: {job.voice} · Format: {output_format_label(job.output_format)}"
|
||||||
|
f" · Speed: {job.speed:.2f}x · Chars: {format_number(job.char_count)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.Container(
|
||||||
|
content=ft.Row([
|
||||||
|
ft.Container(
|
||||||
|
content=ft.Text(str(idx + 1), size=12, weight=ft.FontWeight.W_700,
|
||||||
|
color=accent),
|
||||||
|
width=32,
|
||||||
|
),
|
||||||
|
ft.Column([
|
||||||
|
ft.Text(name, size=13, weight=ft.FontWeight.W_600, color=text_primary,
|
||||||
|
no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS),
|
||||||
|
ft.Text(details, size=11, color=text_secondary),
|
||||||
|
], expand=True, tight=True, spacing=2),
|
||||||
|
ft.IconButton(
|
||||||
|
icon=resolve_icon("delete_outline"),
|
||||||
|
icon_color=pal.error if hasattr(pal, "error") else "#e84e3c",
|
||||||
|
icon_size=18,
|
||||||
|
tooltip="Remove",
|
||||||
|
on_click=_remove,
|
||||||
|
),
|
||||||
|
], vertical_alignment=ft.CrossAxisAlignment.CENTER, spacing=SPACE_SM),
|
||||||
|
bgcolor=bg,
|
||||||
|
border=ft.Border.all(1, border_clr),
|
||||||
|
border_radius=RADIUS_SM,
|
||||||
|
padding=ft.Padding.symmetric(horizontal=SPACE_MD, vertical=SPACE_SM),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_start_queue(self, _: ft.ControlEvent) -> None:
|
||||||
|
if not self._state.queued_items:
|
||||||
|
show_snack(self._page, "Queue is empty.", error=True)
|
||||||
|
return
|
||||||
|
# Navigate to dashboard and trigger queue start
|
||||||
|
# This is wired in main.py via the nav controller
|
||||||
|
self._page.pubsub.send_all("start_queue")
|
||||||
|
|
||||||
|
def _on_clear_queue(self, _: ft.ControlEvent) -> None:
|
||||||
|
if not self._state.queued_items:
|
||||||
|
return
|
||||||
|
self._state.queued_items.clear()
|
||||||
|
self._refresh_list()
|
||||||
|
show_snack(self._page, "Queue cleared.")
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
Settings view – a categorised, scrollable settings page.
|
||||||
|
|
||||||
|
Groups settings into collapsible cards:
|
||||||
|
- Output (format, save location, chapters)
|
||||||
|
- Text processing (newlines, caps, substitutions, numerals)
|
||||||
|
- Subtitle options
|
||||||
|
- TTS pipeline (provider, GPU, chunking)
|
||||||
|
- Integrations (Audiobookshelf, Calibre OPDS)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import flet as ft
|
||||||
|
|
||||||
|
from ..state import AppState
|
||||||
|
from ..utils.theme import get_palette, RADIUS_MD, RADIUS_SM, SPACE_SM, SPACE_MD, SPACE_LG
|
||||||
|
from ..utils.helpers import output_format_label, subtitle_format_label, SUPPORTED_EXTENSIONS
|
||||||
|
from ..components import (
|
||||||
|
build_card, build_section_header, labelled_row, show_snack, build_divider,
|
||||||
|
build_primary_button,
|
||||||
|
)
|
||||||
|
from abogen.constants import SUBTITLE_FORMATS
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _dd(options, value, on_change, **kw):
|
||||||
|
"""Compact dropdown factory."""
|
||||||
|
return ft.Dropdown(
|
||||||
|
options=[ft.dropdown.Option(key=k, text=v) for k, v in options],
|
||||||
|
value=value, on_select=on_change, dense=True,
|
||||||
|
border_radius=RADIUS_SM, expand=True, **kw
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sw(value, on_change, label=""):
|
||||||
|
return ft.Switch(value=value, on_change=on_change, label=label)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsView:
|
||||||
|
"""The full settings panel."""
|
||||||
|
|
||||||
|
def __init__(self, page: ft.Page, state: AppState) -> None:
|
||||||
|
self._page = page
|
||||||
|
self._state = state
|
||||||
|
|
||||||
|
def build(self) -> ft.Column:
|
||||||
|
p = self._page
|
||||||
|
s = self._state
|
||||||
|
pal = get_palette(p)
|
||||||
|
|
||||||
|
# ── Output card ──────────────────────────────────────────────
|
||||||
|
format_dd = _dd(
|
||||||
|
[(k, output_format_label(k)) for k in ("wav", "flac", "mp3", "opus", "m4b")],
|
||||||
|
s.selected_format,
|
||||||
|
lambda e: self._save("selected_format", e.control.value),
|
||||||
|
)
|
||||||
|
save_dd = _dd(
|
||||||
|
[
|
||||||
|
("Save next to input file", "Save next to input file"),
|
||||||
|
("Save to Desktop", "Save to Desktop"),
|
||||||
|
("Choose output folder", "Choose output folder"),
|
||||||
|
],
|
||||||
|
s.save_option,
|
||||||
|
lambda e: self._save("save_option", e.control.value),
|
||||||
|
)
|
||||||
|
chapters_sw = _sw(s.save_chapters_separately or False,
|
||||||
|
lambda e: self._save("save_chapters_separately", e.control.value))
|
||||||
|
merge_sw = _sw(True if s.merge_chapters_at_end is None else s.merge_chapters_at_end,
|
||||||
|
lambda e: self._save("merge_chapters_at_end", e.control.value))
|
||||||
|
sep_fmt_dd = _dd(
|
||||||
|
[(k, output_format_label(k)) for k in ("wav", "flac", "mp3", "opus")],
|
||||||
|
s.separate_chapters_format,
|
||||||
|
lambda e: self._save("separate_chapters_format", e.control.value),
|
||||||
|
)
|
||||||
|
epub3_sw = _sw(s.generate_epub3, lambda e: self._save("generate_epub3", e.control.value))
|
||||||
|
|
||||||
|
output_card = build_card(ft.Column([
|
||||||
|
build_section_header("Output", icon="audio_file", page=p),
|
||||||
|
labelled_row("Audio Format", format_dd, page=p),
|
||||||
|
labelled_row("Save Location", save_dd, page=p),
|
||||||
|
build_divider(p),
|
||||||
|
labelled_row("Save Chapters Separately", chapters_sw, page=p),
|
||||||
|
labelled_row("Merge at End", merge_sw, page=p),
|
||||||
|
labelled_row("Chapter Format", sep_fmt_dd, page=p),
|
||||||
|
labelled_row("Generate EPUB3", epub3_sw, page=p),
|
||||||
|
], spacing=SPACE_MD), page=p)
|
||||||
|
|
||||||
|
# ── Text processing card ─────────────────────────────────────
|
||||||
|
newlines_sw = _sw(s.replace_single_newlines,
|
||||||
|
lambda e: self._save("replace_single_newlines", e.control.value))
|
||||||
|
caps_sw = _sw(s.replace_all_caps, lambda e: self._save("replace_all_caps", e.control.value))
|
||||||
|
norm_sw = _sw(s.normalize_chapter_opening_caps,
|
||||||
|
lambda e: self._save("normalize_chapter_opening_caps", e.control.value))
|
||||||
|
numerals_sw = _sw(s.replace_numerals, lambda e: self._save("replace_numerals", e.control.value))
|
||||||
|
punct_sw = _sw(s.fix_nonstandard_punctuation,
|
||||||
|
lambda e: self._save("fix_nonstandard_punctuation", e.control.value))
|
||||||
|
wordsub_sw = _sw(s.word_substitutions_enabled,
|
||||||
|
lambda e: self._save("word_substitutions_enabled", e.control.value))
|
||||||
|
wordsub_tf = ft.TextField(
|
||||||
|
value=s.word_substitutions_list,
|
||||||
|
multiline=True, min_lines=3, max_lines=6,
|
||||||
|
hint_text="word|replacement (one per line)",
|
||||||
|
on_change=lambda e: self._save("word_substitutions_list", e.control.value),
|
||||||
|
expand=True, border_radius=RADIUS_SM, text_size=12,
|
||||||
|
)
|
||||||
|
case_sw = _sw(s.case_sensitive_substitutions,
|
||||||
|
lambda e: self._save("case_sensitive_substitutions", e.control.value))
|
||||||
|
spacy_sw = _sw(s.use_spacy_segmentation,
|
||||||
|
lambda e: self._save("use_spacy_segmentation", e.control.value))
|
||||||
|
chunk_dd = _dd(
|
||||||
|
[("paragraph", "Paragraph"), ("sentence", "Sentence")],
|
||||||
|
s.chunk_level,
|
||||||
|
lambda e: self._save("chunk_level", e.control.value),
|
||||||
|
)
|
||||||
|
title_intro_sw = _sw(s.read_title_intro, lambda e: self._save("read_title_intro", e.control.value))
|
||||||
|
outro_sw = _sw(s.read_closing_outro, lambda e: self._save("read_closing_outro", e.control.value))
|
||||||
|
prefix_sw = _sw(s.auto_prefix_chapter_titles,
|
||||||
|
lambda e: self._save("auto_prefix_chapter_titles", e.control.value))
|
||||||
|
|
||||||
|
text_card = build_card(ft.Column([
|
||||||
|
build_section_header("Text Processing", icon="text_fields", page=p),
|
||||||
|
labelled_row("Replace Single Newlines", newlines_sw,
|
||||||
|
tooltip="Replace single newlines with spaces before processing.", page=p),
|
||||||
|
labelled_row("Replace ALL CAPS Words", caps_sw, page=p),
|
||||||
|
labelled_row("Normalize Opening CAPS", norm_sw, page=p),
|
||||||
|
labelled_row("Replace Numerals (spoken)", numerals_sw, page=p),
|
||||||
|
labelled_row("Fix Non-standard Punctuation", punct_sw, page=p),
|
||||||
|
build_divider(p),
|
||||||
|
labelled_row("Word Substitutions", wordsub_sw, page=p),
|
||||||
|
labelled_row("Case Sensitive", case_sw, page=p),
|
||||||
|
ft.Text("Substitution rules (word|replacement, one per line):",
|
||||||
|
size=12, color=pal.text_secondary),
|
||||||
|
wordsub_tf,
|
||||||
|
build_divider(p),
|
||||||
|
build_section_header("Chapter Options", icon="library_books", page=p),
|
||||||
|
labelled_row("Announce Book Title (intro)", title_intro_sw, page=p),
|
||||||
|
labelled_row("Announce Book Title (outro)", outro_sw, page=p),
|
||||||
|
labelled_row("Auto-prefix Chapter Titles", prefix_sw, page=p),
|
||||||
|
labelled_row("Chunk Level", chunk_dd, page=p),
|
||||||
|
labelled_row("Use spaCy Segmentation", spacy_sw, page=p),
|
||||||
|
], spacing=SPACE_MD), page=p)
|
||||||
|
|
||||||
|
# ── Subtitle card ─────────────────────────────────────────────
|
||||||
|
sub_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma",
|
||||||
|
"Sentence + Highlighting"] + [f"{i} word{'s' if i > 1 else ''}" for i in range(1, 11)]
|
||||||
|
sub_mode_dd = _dd(
|
||||||
|
[(m, m) for m in sub_modes],
|
||||||
|
s.subtitle_mode,
|
||||||
|
lambda e: self._save("subtitle_mode", e.control.value),
|
||||||
|
)
|
||||||
|
sub_fmt_dd = _dd(
|
||||||
|
[(k, lbl) for k, lbl in SUBTITLE_FORMATS],
|
||||||
|
s.subtitle_format,
|
||||||
|
lambda e: self._save("subtitle_format", e.control.value),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mk_mw_slider():
|
||||||
|
lbl = ft.Text(str(s.max_subtitle_words), size=12, width=36)
|
||||||
|
sl = ft.Slider(
|
||||||
|
min=1, max=200, value=s.max_subtitle_words, divisions=199, label="{value}",
|
||||||
|
expand=True,
|
||||||
|
on_change=lambda e: (self._save("max_subtitle_words", int(e.control.value)),
|
||||||
|
setattr(lbl, "value", str(int(e.control.value))),
|
||||||
|
self._page.update()),
|
||||||
|
)
|
||||||
|
return ft.Row([sl, lbl], expand=True, spacing=SPACE_SM)
|
||||||
|
|
||||||
|
sub_speed_dd = _dd(
|
||||||
|
[("tts", "TTS duration"), ("silence", "Silence detection")],
|
||||||
|
s.subtitle_speed_method,
|
||||||
|
lambda e: self._save("subtitle_speed_method", e.control.value),
|
||||||
|
)
|
||||||
|
silent_gaps_sw = _sw(s.use_silent_gaps,
|
||||||
|
lambda e: self._save("use_silent_gaps", e.control.value))
|
||||||
|
|
||||||
|
subtitle_card = build_card(ft.Column([
|
||||||
|
build_section_header("Subtitles", icon="subtitles", page=p),
|
||||||
|
labelled_row("Mode", sub_mode_dd, page=p),
|
||||||
|
labelled_row("Format", sub_fmt_dd, page=p),
|
||||||
|
labelled_row("Max Words / Block", _mk_mw_slider(), page=p),
|
||||||
|
labelled_row("Speed Method", sub_speed_dd, page=p),
|
||||||
|
labelled_row("Silent Gaps", silent_gaps_sw, page=p),
|
||||||
|
], spacing=SPACE_MD), page=p)
|
||||||
|
|
||||||
|
# ── Pipeline card ─────────────────────────────────────────────
|
||||||
|
provider_dd = _dd(
|
||||||
|
[("kokoro", "Kokoro (default)"), ("supertonic", "Supertonic")],
|
||||||
|
s.tts_provider,
|
||||||
|
lambda e: self._save("tts_provider", e.control.value),
|
||||||
|
)
|
||||||
|
gpu_sw = _sw(s.use_gpu, lambda e: self._save("use_gpu", e.control.value),
|
||||||
|
label="GPU acceleration (if available)")
|
||||||
|
|
||||||
|
def _mk_steps_slider():
|
||||||
|
lbl = ft.Text(str(s.supertonic_total_steps), size=12, width=28)
|
||||||
|
sl = ft.Slider(
|
||||||
|
min=2, max=15, value=s.supertonic_total_steps, divisions=13,
|
||||||
|
label="{value}", expand=True,
|
||||||
|
on_change=lambda e: (self._save("supertonic_total_steps", int(e.control.value)),
|
||||||
|
setattr(lbl, "value", str(int(e.control.value))),
|
||||||
|
self._page.update()),
|
||||||
|
)
|
||||||
|
return ft.Row([sl, lbl], expand=True, spacing=SPACE_SM)
|
||||||
|
|
||||||
|
thresh_tf = ft.TextField(
|
||||||
|
value=str(s.speaker_analysis_threshold), width=80,
|
||||||
|
keyboard_type=ft.KeyboardType.NUMBER, border_radius=RADIUS_SM,
|
||||||
|
on_change=lambda e: self._save_int("speaker_analysis_threshold", e.control.value, 1, 25),
|
||||||
|
)
|
||||||
|
silence_tf = ft.TextField(
|
||||||
|
value=str(s.silence_duration), width=80,
|
||||||
|
keyboard_type=ft.KeyboardType.NUMBER, border_radius=RADIUS_SM,
|
||||||
|
on_change=lambda e: self._save_float("silence_duration", e.control.value, 0.0),
|
||||||
|
)
|
||||||
|
intro_tf = ft.TextField(
|
||||||
|
value=str(s.chapter_intro_delay), width=80,
|
||||||
|
keyboard_type=ft.KeyboardType.NUMBER, border_radius=RADIUS_SM,
|
||||||
|
on_change=lambda e: self._save_float("chapter_intro_delay", e.control.value, 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline_card = build_card(ft.Column([
|
||||||
|
build_section_header("TTS Pipeline", icon="settings", page=p),
|
||||||
|
labelled_row("Provider", provider_dd, page=p),
|
||||||
|
labelled_row("GPU Acceleration", gpu_sw, page=p),
|
||||||
|
labelled_row("Supertonic Steps", _mk_steps_slider(), page=p),
|
||||||
|
build_divider(p),
|
||||||
|
labelled_row("Speaker Analysis Threshold", thresh_tf, page=p),
|
||||||
|
labelled_row("Silence Between Chapters (s)", silence_tf, page=p),
|
||||||
|
labelled_row("Chapter Intro Delay (s)", intro_tf, page=p),
|
||||||
|
], spacing=SPACE_MD), page=p)
|
||||||
|
|
||||||
|
# ── Integration card (Audiobookshelf) ─────────────────────────
|
||||||
|
abs_enabled_sw = _sw(s.audiobookshelf_enabled,
|
||||||
|
lambda e: self._save("audiobookshelf_enabled", e.control.value))
|
||||||
|
abs_url_tf = ft.TextField(value=s.audiobookshelf_base_url, hint_text="http://abs-server:13378",
|
||||||
|
expand=True, border_radius=RADIUS_SM, text_size=12,
|
||||||
|
on_change=lambda e: self._save("audiobookshelf_base_url", e.control.value))
|
||||||
|
abs_token_tf = ft.TextField(value=s.audiobookshelf_api_token, password=True,
|
||||||
|
can_reveal_password=True, expand=True,
|
||||||
|
border_radius=RADIUS_SM, text_size=12,
|
||||||
|
on_change=lambda e: self._save("audiobookshelf_api_token", e.control.value))
|
||||||
|
abs_lib_tf = ft.TextField(value=s.audiobookshelf_library_id, hint_text="Library ID",
|
||||||
|
expand=True, border_radius=RADIUS_SM, text_size=12,
|
||||||
|
on_change=lambda e: self._save("audiobookshelf_library_id", e.control.value))
|
||||||
|
abs_auto_sw = _sw(s.audiobookshelf_auto_send,
|
||||||
|
lambda e: self._save("audiobookshelf_auto_send", e.control.value))
|
||||||
|
|
||||||
|
integ_card = build_card(ft.Column([
|
||||||
|
build_section_header("Audiobookshelf Integration",
|
||||||
|
icon="cloud_upload", page=p),
|
||||||
|
labelled_row("Enabled", abs_enabled_sw, page=p),
|
||||||
|
labelled_row("Server URL", abs_url_tf, page=p),
|
||||||
|
labelled_row("API Token", abs_token_tf, page=p),
|
||||||
|
labelled_row("Library ID", abs_lib_tf, page=p),
|
||||||
|
labelled_row("Auto-upload on finish", abs_auto_sw, page=p),
|
||||||
|
], spacing=SPACE_MD), page=p)
|
||||||
|
|
||||||
|
save_btn = build_primary_button(
|
||||||
|
"Save Settings", icon="save",
|
||||||
|
on_click=self._on_save, page=p,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.Column([
|
||||||
|
output_card,
|
||||||
|
ft.Container(height=SPACE_MD),
|
||||||
|
text_card,
|
||||||
|
ft.Container(height=SPACE_MD),
|
||||||
|
subtitle_card,
|
||||||
|
ft.Container(height=SPACE_MD),
|
||||||
|
pipeline_card,
|
||||||
|
ft.Container(height=SPACE_MD),
|
||||||
|
integ_card,
|
||||||
|
ft.Container(height=SPACE_LG),
|
||||||
|
save_btn,
|
||||||
|
ft.Container(height=SPACE_LG),
|
||||||
|
], spacing=0, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _save(self, attr: str, value) -> None:
|
||||||
|
setattr(self._state, attr, value)
|
||||||
|
|
||||||
|
def _save_int(self, attr: str, raw: str, lo: int, hi: int) -> None:
|
||||||
|
try:
|
||||||
|
v = max(lo, min(hi, int(raw)))
|
||||||
|
setattr(self._state, attr, v)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _save_float(self, attr: str, raw: str, lo: float) -> None:
|
||||||
|
try:
|
||||||
|
v = max(lo, float(raw))
|
||||||
|
setattr(self._state, attr, v)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _on_save(self, _: ft.ControlEvent) -> None:
|
||||||
|
self._state.persist_config()
|
||||||
|
show_snack(self._page, "Settings saved.")
|
||||||
+6
-3837
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Integration clients for external services."""
|
||||||
@@ -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.
@@ -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")
|
||||||
+27
-142
@@ -1,93 +1,36 @@
|
|||||||
import os
|
"""Backwards-compatible entry point that now launches the web UI."""
|
||||||
import sys
|
|
||||||
import platform
|
from __future__ import annotations
|
||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
import signal
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
# Fix PyTorch DLL loading issue ([WinError 1114]) on Windows before importing PyQt6
|
from abogen.utils import load_config, prevent_sleep_end
|
||||||
if platform.system() == "Windows":
|
from abogen.webui.app import main as _run_web_ui
|
||||||
import ctypes
|
|
||||||
from importlib.util import find_spec
|
|
||||||
|
|
||||||
try:
|
# Configure Hugging Face Hub behaviour (mirrors legacy GUI defaults).
|
||||||
if (
|
os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
|
||||||
(spec := find_spec("torch"))
|
os.environ.setdefault("HF_HUB_ETAG_TIMEOUT", "10")
|
||||||
and spec.origin
|
os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "10")
|
||||||
and os.path.exists(
|
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
|
||||||
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.")
|
|
||||||
|
|
||||||
# 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__)))
|
|
||||||
|
|
||||||
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
|
|
||||||
if load_config().get("disable_kokoro_internet", False):
|
if load_config().get("disable_kokoro_internet", False):
|
||||||
print("INFO: Kokoro's internet access is disabled.")
|
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||||
os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access
|
|
||||||
|
|
||||||
from abogen.gui import abogen
|
# Prefer faster ROCm tuning defaults when available.
|
||||||
from abogen.constants import PROGRAM_NAME, VERSION
|
os.environ.setdefault("MIOPEN_FIND_MODE", "FAST")
|
||||||
|
os.environ.setdefault("MIOPEN_CONV_PRECISE_ROCM_TUNING", "0")
|
||||||
|
|
||||||
# Set environment variables for AMD ROCm
|
# Enable MPS GPU acceleration on Apple Silicon.
|
||||||
os.environ["MIOPEN_FIND_MODE"] = "FAST"
|
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||||
os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0"
|
os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
|
||||||
|
|
||||||
# Reset sleep states
|
|
||||||
atexit.register(prevent_sleep_end)
|
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()
|
prevent_sleep_end()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
@@ -95,70 +38,12 @@ def _cleanup_sleep(signum, frame):
|
|||||||
signal.signal(signal.SIGINT, _cleanup_sleep)
|
signal.signal(signal.SIGINT, _cleanup_sleep)
|
||||||
signal.signal(signal.SIGTERM, _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
|
def main() -> None:
|
||||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
"""Launch the Flask-based web UI."""
|
||||||
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
|
|
||||||
|
_run_web_ui()
|
||||||
|
|
||||||
|
|
||||||
# Custom message handler to filter out specific Qt warnings
|
if __name__ == "__main__": # pragma: no cover - manual execution hook
|
||||||
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()
|
main()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
-803
@@ -1,807 +1,11 @@
|
|||||||
# a simple window with a list of items in the queue, no checkboxes
|
"""Backwards-compatible re-export of the PyQt queue manager.
|
||||||
# button to remove an item from the queue
|
|
||||||
# button to clear the queue
|
|
||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
The actual implementation lives in abogen.pyqt.queue_manager_gui.
|
||||||
QDialog,
|
"""
|
||||||
QVBoxLayout,
|
|
||||||
QHBoxLayout,
|
|
||||||
QDialogButtonBox,
|
|
||||||
QPushButton,
|
|
||||||
QListWidget,
|
|
||||||
QListWidgetItem,
|
|
||||||
QFileIconProvider,
|
|
||||||
QLabel,
|
|
||||||
QWidget,
|
|
||||||
QSizePolicy,
|
|
||||||
QAbstractItemView,
|
|
||||||
)
|
|
||||||
from PyQt6.QtCore import QFileInfo, Qt
|
|
||||||
from abogen.constants import COLORS
|
|
||||||
from copy import deepcopy
|
|
||||||
from PyQt6.QtGui import QFontMetrics
|
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
class ElidedLabel(QLabel):
|
from abogen.pyqt.queue_manager_gui import * # noqa: F401, F403
|
||||||
def __init__(self, text):
|
from abogen.pyqt.queue_manager_gui import QueueManager
|
||||||
super().__init__(text)
|
|
||||||
self._full_text = text
|
|
||||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
|
||||||
self.setTextFormat(Qt.TextFormat.PlainText)
|
|
||||||
|
|
||||||
def setText(self, text):
|
__all__ = ["QueueManager"]
|
||||||
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
|
|
||||||
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. "
|
|
||||||
"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.AlignmentFlag.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 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()
|
|
||||||
icon_provider = QFileIconProvider()
|
|
||||||
for item in self.queue:
|
|
||||||
# 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()
|
|
||||||
# Set tooltip with detailed info
|
|
||||||
output_folder = getattr(item, "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> {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)}<br>"
|
|
||||||
f"<b>Use Silent Gaps:</b> {getattr(item, 'use_silent_gaps', False)}<br>"
|
|
||||||
f"<b>Speed Method:</b> {getattr(item, 'subtitle_speed_method', 'tts')}"
|
|
||||||
)
|
|
||||||
# Add book handler options if present
|
|
||||||
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", False
|
|
||||||
)
|
|
||||||
# use_silent_gaps
|
|
||||||
attrs["use_silent_gaps"] = getattr(parent, "use_silent_gaps", False)
|
|
||||||
# subtitle_speed_method
|
|
||||||
attrs["subtitle_speed_method"] = getattr(
|
|
||||||
parent, "subtitle_speed_method", "tts"
|
|
||||||
)
|
|
||||||
# book handler options
|
|
||||||
attrs["save_chapters_separately"] = getattr(
|
|
||||||
parent, "save_chapters_separately", None
|
|
||||||
)
|
|
||||||
attrs["merge_chapters_at_end"] = getattr(
|
|
||||||
parent, "merge_chapters_at_end", None
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# fallback: empty values
|
|
||||||
attrs = {
|
|
||||||
k: ""
|
|
||||||
for k in [
|
|
||||||
"lang_code",
|
|
||||||
"speed",
|
|
||||||
"voice",
|
|
||||||
"save_option",
|
|
||||||
"output_folder",
|
|
||||||
"subtitle_mode",
|
|
||||||
"output_format",
|
|
||||||
"total_char_count",
|
|
||||||
"replace_single_newlines",
|
|
||||||
]
|
|
||||||
}
|
|
||||||
attrs["save_chapters_separately"] = None
|
|
||||||
attrs["merge_chapters_at_end"] = None
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
def add_files_from_paths(self, file_paths):
|
|
||||||
from abogen.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", False)
|
|
||||||
== getattr(item, "replace_single_newlines", False)
|
|
||||||
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
|
|
||||||
from abogen.utils import calculate_text_length # import the function
|
|
||||||
|
|
||||||
# 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):
|
|
||||||
# Accept: keep changes
|
|
||||||
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)
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class QueuedItem:
|
|||||||
subtitle_mode: str
|
subtitle_mode: str
|
||||||
output_format: str
|
output_format: str
|
||||||
total_char_count: int
|
total_char_count: int
|
||||||
replace_single_newlines: bool = False
|
replace_single_newlines: bool = True
|
||||||
use_silent_gaps: bool = False
|
use_silent_gaps: bool = False
|
||||||
subtitle_speed_method: str = "tts"
|
subtitle_speed_method: str = "tts"
|
||||||
save_base_path: str = None
|
save_base_path: str = None
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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
@@ -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)
|
||||||
+236
-53
@@ -1,24 +1,52 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
import json
|
import json
|
||||||
import warnings
|
import logging
|
||||||
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import re
|
import sys
|
||||||
|
import warnings
|
||||||
from threading import Thread
|
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")
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
|
||||||
def detect_encoding(file_path):
|
def detect_encoding(file_path):
|
||||||
import chardet
|
try:
|
||||||
import charset_normalizer
|
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:
|
with open(file_path, "rb") as f:
|
||||||
raw_data = f.read()
|
raw_data = f.read()
|
||||||
detected_encoding = None
|
detected_encoding = None
|
||||||
for detectors in (charset_normalizer, chardet):
|
for detectors in (charset_normalizer, chardet):
|
||||||
|
if detectors is None:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
result = detectors.detect(raw_data)["encoding"]
|
result = detectors.detect(raw_data)["encoding"]
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -77,50 +105,200 @@ def get_resource_path(package, resource):
|
|||||||
def get_version():
|
def get_version():
|
||||||
"""Return the current version of the application."""
|
"""Return the current version of the application."""
|
||||||
try:
|
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()
|
return f.read().strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
|
|
||||||
|
|
||||||
# Define config path
|
# 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
|
from platformdirs import user_config_dir
|
||||||
|
|
||||||
# TODO Config directory is changed for Linux and MacOS. But if old config exists, it will be used.
|
|
||||||
# On non‑Windows, prefer ~/.config/abogen if it already exists
|
|
||||||
if platform.system() != "Windows":
|
if platform.system() != "Windows":
|
||||||
custom_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen")
|
legacy_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen")
|
||||||
if os.path.exists(custom_dir):
|
if os.path.exists(legacy_dir):
|
||||||
config_dir = custom_dir
|
return ensure_directory(legacy_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
|
|
||||||
)
|
|
||||||
|
|
||||||
return os.path.join(config_dir, "config.json")
|
config_dir = user_config_dir(
|
||||||
|
"abogen", appauthor=False, roaming=True, ensure_exists=True
|
||||||
|
)
|
||||||
|
return ensure_directory(config_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_config_path():
|
||||||
|
return os.path.join(get_user_settings_dir(), "config.json")
|
||||||
|
|
||||||
|
|
||||||
# Define cache path
|
# Define cache path
|
||||||
def get_user_cache_path(folder=None):
|
@lru_cache(maxsize=1)
|
||||||
from platformdirs import user_cache_dir
|
def get_user_cache_root():
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
cache_dir = user_cache_dir(
|
def _try_paths(*paths):
|
||||||
"abogen", appauthor=False, opinion=True, ensure_exists=True
|
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:
|
if folder:
|
||||||
cache_dir = os.path.join(cache_dir, folder)
|
return ensure_directory(os.path.join(base, folder))
|
||||||
# Ensure the directory exists
|
return base
|
||||||
os.makedirs(cache_dir, exist_ok=True)
|
|
||||||
return cache_dir
|
|
||||||
|
|
||||||
|
|
||||||
_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):
|
def clean_text(text, *args, **kwargs):
|
||||||
@@ -157,6 +335,11 @@ def create_process(cmd, stdin=None, text=True, capture_output=False):
|
|||||||
|
|
||||||
# Determine shell usage: use shell only for string commands
|
# Determine shell usage: use shell only for string commands
|
||||||
use_shell = isinstance(cmd, str)
|
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 = {
|
kwargs = {
|
||||||
"shell": use_shell,
|
"shell": use_shell,
|
||||||
"stdout": subprocess.PIPE,
|
"stdout": subprocess.PIPE,
|
||||||
@@ -179,11 +362,14 @@ def create_process(cmd, stdin=None, text=True, capture_output=False):
|
|||||||
kwargs["stdin"] = stdin
|
kwargs["stdin"] = stdin
|
||||||
|
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
startupinfo = subprocess.STARTUPINFO()
|
startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined]
|
||||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined]
|
||||||
startupinfo.wShowWindow = subprocess.SW_HIDE
|
startupinfo.wShowWindow = subprocess.SW_HIDE # type: ignore[attr-defined]
|
||||||
kwargs.update(
|
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
|
# Print the command being executed
|
||||||
@@ -257,15 +443,9 @@ def calculate_text_length(text):
|
|||||||
|
|
||||||
|
|
||||||
def get_gpu_acceleration(enabled):
|
def get_gpu_acceleration(enabled):
|
||||||
"""
|
|
||||||
Check GPU acceleration availability.
|
|
||||||
|
|
||||||
Note: On Windows, torch DLLs must be pre-loaded in main.py before PyQt6
|
|
||||||
to avoid DLL initialization errors.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
import torch
|
import torch # type: ignore[import-not-found]
|
||||||
from torch.cuda import is_available as cuda_available
|
from torch.cuda import is_available as cuda_available # type: ignore[import-not-found]
|
||||||
|
|
||||||
if not enabled:
|
if not enabled:
|
||||||
return "GPU available but using CPU.", False
|
return "GPU available but using CPU.", False
|
||||||
@@ -303,7 +483,7 @@ def prevent_sleep_start():
|
|||||||
if system == "Windows":
|
if system == "Windows":
|
||||||
import ctypes
|
import ctypes
|
||||||
|
|
||||||
ctypes.windll.kernel32.SetThreadExecutionState(
|
ctypes.windll.kernel32.SetThreadExecutionState( # type: ignore[attr-defined]
|
||||||
0x80000000 | 0x00000001 | 0x00000040
|
0x80000000 | 0x00000001 | 0x00000040
|
||||||
)
|
)
|
||||||
elif system == "Darwin":
|
elif system == "Darwin":
|
||||||
@@ -337,18 +517,21 @@ def prevent_sleep_end():
|
|||||||
if system == "Windows":
|
if system == "Windows":
|
||||||
import ctypes
|
import ctypes
|
||||||
|
|
||||||
ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # ES_CONTINUOUS
|
ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # type: ignore[attr-defined]
|
||||||
elif system in ("Darwin", "Linux") and _sleep_procs[system]:
|
elif system in ("Darwin", "Linux"):
|
||||||
try:
|
proc = _sleep_procs.get(system)
|
||||||
_sleep_procs[system].terminate()
|
if proc:
|
||||||
_sleep_procs[system] = None
|
try:
|
||||||
except Exception:
|
proc.terminate()
|
||||||
pass
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_sleep_procs[system] = None
|
||||||
|
|
||||||
|
|
||||||
def load_numpy_kpipeline():
|
def load_numpy_kpipeline():
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from kokoro import KPipeline
|
from kokoro import KPipeline # type: ignore[import-not-found]
|
||||||
|
|
||||||
return np, KPipeline
|
return np, KPipeline
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+7
-1517
File diff suppressed because it is too large
Load Diff
+46
-22
@@ -1,4 +1,6 @@
|
|||||||
import re
|
import re
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
from abogen.constants import VOICES_INTERNAL
|
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)}")
|
raise ValueError(f"Failed to create voice: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
# Parse the formula and get the combined voice tensor
|
def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
|
||||||
def parse_voice_formula(pipeline, formula):
|
if not formula or not formula.strip():
|
||||||
if not formula.strip():
|
|
||||||
raise ValueError("Empty voice formula")
|
raise ValueError("Empty voice formula")
|
||||||
|
|
||||||
# Initialize the weighted sum
|
terms: List[Tuple[str, float]] = []
|
||||||
weighted_sum = None
|
for segment in formula.split("+"):
|
||||||
|
part = segment.strip()
|
||||||
total_weight = calculate_sum_from_formula(formula)
|
if not part:
|
||||||
|
continue
|
||||||
# Split the formula into terms
|
if "*" not in part:
|
||||||
voices = formula.split("+")
|
raise ValueError("Each component must be in the form voice*weight")
|
||||||
|
voice_name, raw_weight = part.split("*", 1)
|
||||||
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
|
|
||||||
voice_name = voice_name.strip()
|
voice_name = voice_name.strip()
|
||||||
|
|
||||||
# Get the voice tensor
|
|
||||||
if voice_name not in VOICES_INTERNAL:
|
if voice_name not in VOICES_INTERNAL:
|
||||||
raise ValueError(f"Unknown voice: {voice_name}")
|
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)
|
voice_tensor = pipeline.load_single_voice(voice_name)
|
||||||
|
|
||||||
# Add to weighted sum
|
|
||||||
if weighted_sum is None:
|
if weighted_sum is None:
|
||||||
weighted_sum = weight * voice_tensor
|
weighted_sum = normalized_weight * voice_tensor
|
||||||
else:
|
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
|
return weighted_sum
|
||||||
|
|
||||||
@@ -55,3 +75,7 @@ def calculate_sum_from_formula(formula):
|
|||||||
weights = re.findall(r"\* *([\d.]+)", formula)
|
weights = re.findall(r"\* *([\d.]+)", formula)
|
||||||
total_sum = sum(float(weight) for weight in weights)
|
total_sum = sum(float(weight) for weight in weights)
|
||||||
return total_sum
|
return total_sum
|
||||||
|
|
||||||
|
|
||||||
|
def extract_voice_ids(formula: str) -> List[str]:
|
||||||
|
return [voice for voice, _ in parse_formula_terms(formula)]
|
||||||
|
|||||||
+171
-1
@@ -1,5 +1,9 @@
|
|||||||
import os
|
|
||||||
import json
|
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
|
from abogen.utils import get_user_config_path
|
||||||
|
|
||||||
|
|
||||||
@@ -57,3 +61,169 @@ def export_profiles(export_path):
|
|||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
with open(export_path, "w", encoding="utf-8") as f:
|
with open(export_path, "w", encoding="utf-8") as f:
|
||||||
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
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}
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
__all__ = ["create_app"]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
if name == "create_app":
|
||||||
|
from .app import create_app
|
||||||
|
|
||||||
|
return create_app
|
||||||
|
raise AttributeError(name)
|
||||||
@@ -0,0 +1,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
@@ -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
|
||||||
Executable
+36
@@ -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 "$@"
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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"))
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()]
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user