mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
Compare commits
501
Commits
v1.1.8
..
a299947bb1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a299947bb1 | ||
|
|
957c6778f6 | ||
|
|
fcdaf2b2a8 | ||
|
|
d8634f812d | ||
|
|
85b5851786 | ||
|
|
e77c8b3372 | ||
|
|
294069e53e | ||
|
|
4ff09be664 | ||
|
|
a1d93820b1 | ||
|
|
0c1a3c1904 | ||
|
|
2228f37c06 | ||
|
|
832e2c5197 | ||
|
|
17229b2390 | ||
|
|
c2c584e741 | ||
|
|
8ccdc85ccb | ||
|
|
ef07a8b5b2 | ||
|
|
1268a83cff | ||
|
|
ef6faff2e8 | ||
|
|
da9d5e7eb9 | ||
|
|
acb000b9e6 | ||
|
|
d6c66dc18a | ||
|
|
0d46076bf6 | ||
|
|
7fef9c1d93 | ||
|
|
56cfd0810d | ||
|
|
7bd3177241 | ||
|
|
d5c2a81733 | ||
|
|
514e29a761 | ||
|
|
86042a3315 | ||
|
|
50d75eb2fc | ||
|
|
ae9ab70421 | ||
|
|
4364276a5b | ||
|
|
914e77de46 | ||
|
|
1d7a2aeed6 | ||
|
|
a26e02b017 | ||
|
|
c94347b33b | ||
|
|
b7a48e3204 | ||
|
|
feb38a24ec | ||
|
|
f63590932d | ||
|
|
7777e58f1d | ||
|
|
364c179bd6 | ||
|
|
60ba01557e | ||
|
|
39eac9b032 | ||
|
|
1499a3b426 | ||
|
|
013c80b92c | ||
|
|
62f42a9f79 | ||
|
|
2c4d13bf56 | ||
|
|
b7026a666d | ||
|
|
c380a58496 | ||
|
|
b8386b43f7 | ||
|
|
26e71cc2ac | ||
|
|
d8fcfb1cce | ||
|
|
c85ea9d64f | ||
|
|
f151a1ae0d | ||
|
|
096ea58d74 | ||
|
|
65cb0c75e5 | ||
|
|
780e9bd780 | ||
|
|
c094b94704 | ||
|
|
735098d7cd | ||
|
|
5d1e7165bb | ||
|
|
9150a80459 | ||
|
|
a76d338931 | ||
|
|
985e16f1f8 | ||
|
|
25d45ffd36 | ||
|
|
6284c501ed | ||
|
|
23f1efcc62 | ||
|
|
a05357bab9 | ||
|
|
d129b0abe8 | ||
|
|
6eda8516cc | ||
|
|
0f568120f4 | ||
|
|
79b3d26f66 | ||
|
|
f1cc6deae8 | ||
|
|
6f25fc06d0 | ||
|
|
32c4d533c9 | ||
|
|
146000886d | ||
|
|
31f95137dd | ||
|
|
6f02fda41c | ||
|
|
a3c3462348 | ||
|
|
79332204d3 | ||
|
|
6deec3b9b6 | ||
|
|
c4d14112d4 | ||
|
|
f4cb2c2329 | ||
|
|
783738882f | ||
|
|
e94ba5257e | ||
|
|
49d66839dc | ||
|
|
d0e316ea7b | ||
|
|
bb96ae502c | ||
|
|
a4d25accc1 | ||
|
|
66964bfd0b | ||
|
|
f8f72624f8 | ||
|
|
e7a88a513a | ||
|
|
2277f16d0a | ||
|
|
1d50429b87 | ||
|
|
29681a5fbb | ||
|
|
50fa2e5b9e | ||
|
|
5816feb6da | ||
|
|
b95df8f217 | ||
|
|
245e67284e | ||
|
|
e2557d961b | ||
|
|
9c6b3774b4 | ||
|
|
fd9fe5579a | ||
|
|
f079373821 | ||
|
|
fbb5d4e368 | ||
|
|
57fec453e2 | ||
|
|
58fe22e3d5 | ||
|
|
ab8cbc4911 | ||
|
|
5e2048072a | ||
|
|
66ed2a202d | ||
|
|
45e859dac4 | ||
|
|
56d3e414b3 | ||
|
|
b942bcb820 | ||
|
|
47efcb4420 | ||
|
|
7b3f9d8615 | ||
|
|
9833bb0843 | ||
|
|
cbc05ead42 | ||
|
|
50b4d6872a | ||
|
|
da68f38b9b | ||
|
|
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 | ||
|
|
6b3125b624 | ||
|
|
bb5c4204de | ||
|
|
43f5589a9d | ||
|
|
10d3f470e7 | ||
|
|
c72c8e777c | ||
|
|
b51e094af1 | ||
|
|
04844997d5 | ||
|
|
7835211a25 | ||
|
|
6bb6fcb406 | ||
|
|
ad8a6e520e | ||
|
|
fe22fe83b0 | ||
|
|
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 | ||
|
|
afbd5203fe | ||
|
|
4b175c7cf2 | ||
|
|
334c0654ca | ||
|
|
42b7979585 | ||
|
|
b8a6ca7091 | ||
|
|
cf6ccf0171 | ||
|
|
eb4704bc26 | ||
|
|
0fc32e9f3a | ||
|
|
67f4493d06 | ||
|
|
de0f17cd57 | ||
|
|
c994c2eeb0 | ||
|
|
6e536c6e3b | ||
|
|
e4fe8122ff | ||
|
|
4cef65e70e | ||
|
|
24d5588f09 | ||
|
|
7883b8865b | ||
|
|
47fe6341b4 | ||
|
|
9d2e8bed00 | ||
|
|
eedf347866 | ||
|
|
9d35e39e89 | ||
|
|
a81ed70b14 | ||
|
|
7951a4d992 | ||
|
|
10cd2c993c | ||
|
|
0259963eb8 | ||
|
|
6b5255a80d | ||
|
|
0d0d3b7871 | ||
|
|
f80c86b00d | ||
|
|
f5d1def330 | ||
|
|
4a28bfb859 | ||
|
|
4adda262c0 | ||
|
|
1d02802172 | ||
|
|
28c0b2c401 | ||
|
|
1e89b566d1 | ||
|
|
ccb350fa08 | ||
|
|
42336145dd | ||
|
|
5d2da7e91c | ||
|
|
e69195e6ef | ||
|
|
1520b971ac | ||
|
|
d426bac87e | ||
|
|
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 | ||
|
|
866746c5a1 | ||
|
|
58889d47b0 | ||
|
|
ae148eb591 | ||
|
|
14ed98c316 | ||
|
|
b35ab7b002 | ||
|
|
eacbe05934 | ||
|
|
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 | ||
|
|
e7fb89f137 | ||
|
|
033c809b95 | ||
|
|
f492cc2506 | ||
|
|
495a50f666 | ||
|
|
8c6e292f4f | ||
|
|
1d8cb8eabd | ||
|
|
a3ee95e83f | ||
|
|
a98f75c1a9 | ||
|
|
f4f3a4c207 |
@@ -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 @@
|
||||
*.py text eol=lf
|
||||
*.md text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.json text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.html text eol=lf
|
||||
*.css text eol=lf
|
||||
*.js text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.cfg text eol=lf
|
||||
*.ini text eol=lf
|
||||
*.svg text eol=lf
|
||||
*.j2 text eol=lf
|
||||
@@ -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']
|
||||
@@ -1,7 +1,9 @@
|
||||
name: pip install
|
||||
run-name: pip install
|
||||
on:
|
||||
name: CI
|
||||
run-name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '**.py'
|
||||
- 'pyproject.toml'
|
||||
@@ -11,23 +13,41 @@ on:
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
install-and-run:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
os: [ubuntu-latest, macos-14, windows-latest]
|
||||
python-version: ['3.12']
|
||||
fail-fast: false
|
||||
continue-on-error: true
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install from repository
|
||||
run: python -m pip install .
|
||||
#- name: Run abogen
|
||||
# run: abogen
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.3.1
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
cache-dependency-glob: pyproject.toml
|
||||
|
||||
- name: Install system dependencies (Ubuntu)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y libegl1
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv pip install --system .[dev]
|
||||
env:
|
||||
UV_LINK_MODE: copy
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
run: pytest tests/ -v --tb=short
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Login to Github Container Registry
|
||||
# Only if we need to push an image
|
||||
|
||||
@@ -19,6 +19,7 @@ __pycache__/
|
||||
env/
|
||||
venv/
|
||||
.env/
|
||||
.env
|
||||
.venv/
|
||||
test/
|
||||
|
||||
@@ -30,6 +31,11 @@ python_embedded/
|
||||
|
||||
# abogen
|
||||
*config.json
|
||||
config/
|
||||
storage/
|
||||
build/
|
||||
dist/
|
||||
.old/
|
||||
test_assets/
|
||||
dev_notes/
|
||||
.claude/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
+75
-1
@@ -1,3 +1,77 @@
|
||||
# 1.3.0
|
||||
- Special thanks to [@jeremiahsb](https://github.com/jeremiahsb) for his [massive contribution](https://github.com/denizsafak/abogen/pull/120) (>55k lines!) that brought the Web UI, EPUB 3 pipeline, and core architectural improvements to life.
|
||||
- Added an EPUB 3 packaging pipeline that builds media-overlay EPUBs from generated audio and chunk metadata.
|
||||
- Persisted chunk timing metadata in job artifacts and exercised the exporter with automated tests.
|
||||
- Added Flask-based Web UI (`abogen-web`) for Docker and headless server deployments.
|
||||
- Reorganized codebase to support both PyQt6 desktop GUI and Web UI from a shared core.
|
||||
- Added Supertonic TTS engine support with GPU acceleration.
|
||||
- Added entity analysis and pronunciation override system for proper nouns.
|
||||
- Added speaker/role assignment for multi-voice "theatrical" audiobooks.
|
||||
- Added Calibre OPDS and Audiobookshelf integration.
|
||||
|
||||
# 1.2.5
|
||||
- Added new option: `Override item settings with current selection` in the queue manager. When enabled, all items in the queue will be processed using the current global settings selected in the main GUI, overriding their individual settings. When disabled, each item will retain its own specific settings.
|
||||
- Fixed `Error "Could not load the Qt platform plugin "xcb"` error that occurred in some Linux distributions due to missing `libxcb-cursor0` library by conditionally loading the bundled library when the system version is unavailable, issue mentioned by @bmcgonag in #101.
|
||||
- Fixed the `No module named pip` error that occurred for users who installed Abogen via the [**uv**](https://github.com/astral-sh/uv) installer.
|
||||
- Fixed defaults for `replace_single_newlines` not being applied correctly in some cases.
|
||||
- Fixed `Save chapters separately for queued epubs is ignored`, issue mentioned by @dymas-cz in #109.
|
||||
- Fixed incorrect sentence segmentation when using spaCy, where text would erroneously split after opening parentheses.
|
||||
- Improvements in code and documentation.
|
||||
|
||||
# 1.2.4
|
||||
- **Subtitle generation is now available for all languages!** Abogen now supports subtitle generation for non-English languages using audio duration-based timing. Available modes include `Line`, `Sentence`, and `Sentence + Comma`. (Note: Word-level subtitle modes remain English-only due to Kokoro's timestamp token limitations.)
|
||||
- New option: **"Use spaCy for sentence segmentation"** You can now use [spaCy](https://spacy.io/) to automatically detect sentence boundaries and produce cleaner, more readable subtitles. Quick summary:
|
||||
- **What it does:** Splits text into natural sentences so subtitle entries read better and align more naturally with speech.
|
||||
- **Why this helps:** The previous punctuation-based splitting could break sentences incorrectly at common abbreviations (e.g. "Mr.", "Dr.", "Prof.") or initials, producing wrong subtitle breaks. spaCy avoids those false splits by using linguistic rules to detect real sentence boundaries.
|
||||
- **For Non-English:** spaCy runs **before** audio generation to create better sentence chunks for TTS.
|
||||
- **For English:** spaCy runs **during** subtitle generation to find accurate sentence breaks after TTS.
|
||||
- **Note:** spaCy segmentation is only applied when subtitle mode is `Sentence` or `Sentence + Comma`. When turned off, it falls back to simple punctuation-based splitting.
|
||||
- New option: **Pre-download models and voices for offline use** You can now pre-download all required Kokoro models, voices, and spaCy language models using this option in the settings menu. Allowing you to use Abogen completely offline without any internet connection.
|
||||
- Added support for `.` separator in timestamps (e.g. `HH:MM:SS.ms`) for timestamp-based text files.
|
||||
- Optimized regex compilation and eliminated busy-wait loops.
|
||||
- Possibly fixed `Silent truncation of long paragraphs` issue mentioned in [#91](https://github.com/denizsafak/abogen/issues/91) by [@xklzlxr](https://github.com/xklzlxr)
|
||||
- Fixed unused regex patterns and variable naming conventions.
|
||||
- Improvements in code and documentation.
|
||||
|
||||
# 1.2.3
|
||||
- Same as 1.2.2, re-released to fix an issue with subtitle timing when using timestamp-based text files.
|
||||
|
||||
# 1.2.2
|
||||
- **You can now voice your subtitle files!** Simply add `.srt`, `.ass` or `.vtt` files to generate timed audio. Alternatively, add a text file with timestamps in `HH:MM:SS` or `HH:MM:SS,ms` format to generate audio that matches the timestamps. See [here](https://github.com/denizsafak/abogen?tab=readme-ov-file#about-timestamp-based-text-files) for detailed instructions.
|
||||
- New option: **"Use silent gaps between subtitles"**: Prevents unnecessary audio speed-up by letting speech continue into the silent gaps between subtitles.
|
||||
- New option: **"Subtitle speed adjustment method"**: Choose how to speed up audio when needed:
|
||||
- **TTS Regeneration (better quality):** Re-generates the audio at a faster speed for more natural sound.
|
||||
- **FFmpeg Time-stretch (better speed):** Quickly speeds up the generated audio.
|
||||
- Added support for embedding cover images in M4B files. Abogen now automatically extracts cover images from EPUB and PDF files. You can also manually specify a cover image using the `<<METADATA_COVER_PATH:path>>` tag in your text file. (To prevent MPV from showing the cover image, you can add `audio-display=no` to your MPV config file.)
|
||||
- Fixed `[WinError 1114] A dynamic link library (DLL) initialization routine failed` error on Windows, pre-loading PyTorch DLLs before initializing PyQt6 to avoid DLL initialization errors, mentioned in #98 by @ephr0n.
|
||||
- Potential fix for `CUDA GPU is not available` issue, by ensuring PyTorch is installed correctly with CUDA support on Windows using the installer script.
|
||||
- Improvements in code and documentation.
|
||||
|
||||
# 1.2.1
|
||||
- Upgraded Abogen's interface from PyQt5 to PyQt6 for better compatibility and long-term support.
|
||||
- Added tooltip indicators in queue manager to display book handler options (`Save chapters separately` and `Merge chapters at the end`) for queued items.
|
||||
- Added `Open processed file` and `Open input file` options for items in the queue manager, instead of just `Open file` option.
|
||||
- Added loading gif animation to book handler window.
|
||||
- Fixed light theme slider colors in voice mixer for better visibility (for non-Windows users).
|
||||
- Fixed subtitle word-count splitting logic for more accurate segmentation.
|
||||
- Improvements in code and documentation.
|
||||
|
||||
# 1.2.0
|
||||
- Added `Line` option to subtitle generation modes, allowing subtitles to be generated based on line breaks in the text, by @mleg in #94.
|
||||
- Added a loading indicator to the book handler window for better user experience during book preprocessing.
|
||||
- Fixed `cannot access local variable 'is_narrow'` error when subtitle format `SRT` was selected, mentioned by @Kinasa0096 in #88.
|
||||
- Fixed folder and filename sanitization to properly handle OS-specific illegal characters (Windows, Linux, macOS), ensuring compatibility across all platforms when creating chapter folders and files.
|
||||
- Fixed `/` and `\` path display by normalizing paths.
|
||||
- Fixed book reprocessing issue where books were being processed every time the chapters window was opened, improving performance when reopening the same book.
|
||||
- Fixed taskbar icon not appearing correctly in Windows.
|
||||
- Fixed "Go to folder" button not opening the chapter output directory when only separate chapters were generated.
|
||||
- Improvements in code and documentation.
|
||||
|
||||
# 1.1.9
|
||||
- Fixed the issue where spaces were deleted before punctuation marks while generating subtitles.
|
||||
- Fixed markdown TOC generation breaks when "Replace single newlines" is enabled.
|
||||
- Improvements in code and documentation.
|
||||
|
||||
# 1.1.8
|
||||
- Added `.md` (Markdown) file extension support by @brianxiadong in PR #75
|
||||
- Added new option `Configure silence between chapters` that lets you configure the silence between chapters, mentioned by @lfperez1982 in #79
|
||||
@@ -119,7 +193,7 @@
|
||||
- Improved invalid profile handling in the voice mixer.
|
||||
|
||||
# v1.0.3
|
||||
- Added voice mixing, allowing multiple voices to be combined into a single “Mixed Voice”, a feature mentioned by @PulsarFTW in #1. Special thanks to @jborza for making this possible through his contributions in #5.
|
||||
- Added voice mixing, allowing multiple voices to be combined into a single "Mixed Voice", a feature mentioned by @PulsarFTW in #1. Special thanks to @jborza for making this possible through his contributions in #5.
|
||||
- Added profile system to voice mixer, allowing users to create and manage multiple voice profiles.
|
||||
- Improvements in the voice mixer, mostly for organizing controls and enhancing user experience.
|
||||
- Added icons for flags and genders in the GUI, making it easier to identify different options.
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
[](https://github.com/denizsafak/abogen/releases/latest)
|
||||
[](https://pypi.org/project/abogen/)
|
||||
[](https://github.com/denizsafak/abogen/releases/latest)
|
||||
[&color=blue)](https://pypi.org/project/abogen/)
|
||||
[](https://github.com/psf/black)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
Abogen is a powerful text-to-speech conversion tool that makes it easy to turn ePub, PDF, text or markdown files into high-quality audio with matching subtitles in seconds. Use it for audiobooks, voiceovers for Instagram, YouTube, TikTok, or any project that needs natural-sounding text-to-speech, using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M).
|
||||
<a href="https://trendshift.io/repositories/14433" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14433" alt="denizsafak%2Fabogen | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
Abogen is a powerful text-to-speech conversion tool that makes it easy to turn ePub, PDF, text, markdown, or subtitle files into high-quality audio with matching subtitles in seconds. Use it for audiobooks, voiceovers for Instagram, YouTube, TikTok, or any project that needs natural-sounding text-to-speech, using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M).
|
||||
|
||||
<img title="Abogen Main" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen.png' width="380"> <img title="Abogen Processing" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen2.png' width="380">
|
||||
|
||||
@@ -15,14 +18,14 @@ Abogen is a powerful text-to-speech conversion tool that makes it easy to turn e
|
||||
|
||||
https://github.com/user-attachments/assets/094ba3df-7d66-494a-bc31-0e4b41d0b865
|
||||
|
||||
> This demo was generated in just 5 seconds, producing ∼1 minute of audio with perfectly synced subtitles. To create a similar video, see [the demo guide](https://github.com/denizsafak/abogen/tree/main/demo).
|
||||
> This demo was generated in just 5 seconds, producing ∼1 minute of audio with perfectly synced subtitles. To create a similar video, see [the demo guide](https://github.com/denizsafak/abogen/tree/main/demo).
|
||||
|
||||
## `How to install?` <a href="https://pypi.org/project/abogen/" target="_blank"><img src="https://img.shields.io/pypi/pyversions/abogen" alt="Abogen Compatible PyPi Python Versions" align="right" style="margin-top:6px;"></a>
|
||||
|
||||
### Windows
|
||||
### `Windows`
|
||||
Go to [espeak-ng latest release](https://github.com/espeak-ng/espeak-ng/releases/latest) download and run the *.msi file.
|
||||
|
||||
#### OPTION 1: Install using script
|
||||
#### <b>OPTION 1: Install using script</b>
|
||||
1. [Download](https://github.com/denizsafak/abogen/archive/refs/heads/main.zip) the repository
|
||||
2. Extract the ZIP file
|
||||
3. Run `WINDOWS_INSTALL.bat` by double-clicking it
|
||||
@@ -32,7 +35,26 @@ This method handles everything automatically - installing all dependencies inclu
|
||||
> [!NOTE]
|
||||
> You don't need to install Python separately. The script will install Python automatically.
|
||||
|
||||
#### OPTION 2: Install using pip
|
||||
#### <b>OPTION 2: Install using uv</b>
|
||||
First, [install uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already.
|
||||
|
||||
```bash
|
||||
# For NVIDIA GPUs (CUDA 12.8) - Recommended
|
||||
uv tool install --python 3.12 abogen[cuda] --extra-index-url https://download.pytorch.org/whl/cu128 --index-strategy unsafe-best-match
|
||||
|
||||
# For NVIDIA GPUs (CUDA 12.6) - Older drivers
|
||||
uv tool install --python 3.12 abogen[cuda126] --extra-index-url https://download.pytorch.org/whl/cu126 --index-strategy unsafe-best-match
|
||||
|
||||
# For NVIDIA GPUs (CUDA 13.0) - Newer drivers
|
||||
uv tool install --python 3.12 abogen[cuda130] --extra-index-url https://download.pytorch.org/whl/cu130 --index-strategy unsafe-best-match
|
||||
|
||||
# For AMD GPUs or without GPU - If you have AMD GPU, you need to use Linux for GPU acceleration, because ROCm is not available on Windows.
|
||||
uv tool install --python 3.12 abogen
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Alternative: Install using pip (click to expand)</b></summary>
|
||||
|
||||
```bash
|
||||
# Create a virtual environment (optional)
|
||||
mkdir abogen && cd abogen
|
||||
@@ -40,7 +62,8 @@ python -m venv venv
|
||||
venv\Scripts\activate
|
||||
|
||||
# For NVIDIA GPUs:
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
|
||||
# We need to use an older version of PyTorch (2.8.0) until this issue is fixed: https://github.com/pytorch/pytorch/issues/166628
|
||||
pip install torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128
|
||||
|
||||
# For AMD GPUs:
|
||||
# Not supported yet, because ROCm is not available on Windows. Use Linux if you have AMD GPU.
|
||||
@@ -49,7 +72,26 @@ pip install torch torchvision torchaudio --index-url https://download.pytorch.or
|
||||
pip install abogen
|
||||
```
|
||||
|
||||
### Mac
|
||||
</details>
|
||||
|
||||
### `Mac`
|
||||
|
||||
First, [install uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already.
|
||||
|
||||
```bash
|
||||
# Install espeak-ng
|
||||
brew install espeak-ng
|
||||
|
||||
# For Silicon Mac (M1, M2 etc.)
|
||||
uv tool install --python 3.13 abogen --with "kokoro @ git+https://github.com/hexgrad/kokoro.git,numpy<2"
|
||||
|
||||
# For Intel Mac
|
||||
uv tool install --python 3.12 abogen --with "kokoro @ git+https://github.com/hexgrad/kokoro.git,numpy<2"
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Alternative: Install using pip (click to expand)</b></summary>
|
||||
|
||||
```bash
|
||||
# Install espeak-ng
|
||||
brew install espeak-ng
|
||||
@@ -66,7 +108,29 @@ pip3 install abogen
|
||||
# After installing abogen, we need to install Kokoro's development version which includes MPS support.
|
||||
pip3 install git+https://github.com/hexgrad/kokoro.git
|
||||
```
|
||||
### Linux
|
||||
|
||||
</details>
|
||||
|
||||
### `Linux`
|
||||
|
||||
First, [install uv](https://docs.astral.sh/uv/getting-started/installation/) if you haven't already.
|
||||
|
||||
```bash
|
||||
# Install espeak-ng
|
||||
sudo apt install espeak-ng # Ubuntu/Debian
|
||||
sudo pacman -S espeak-ng # Arch Linux
|
||||
sudo dnf install espeak-ng # Fedora
|
||||
|
||||
# For NVIDIA GPUs or without GPU - No need to include [cuda] in here.
|
||||
uv tool install --python 3.12 abogen
|
||||
|
||||
# For AMD GPUs (ROCm 6.4)
|
||||
uv tool install --python 3.12 abogen[rocm] --extra-index-url https://download.pytorch.org/whl/nightly/rocm6.4 --index-strategy unsafe-best-match
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Alternative: Install using pip (click to expand)</b></summary>
|
||||
|
||||
```bash
|
||||
# Install espeak-ng
|
||||
sudo apt install espeak-ng # Ubuntu/Debian
|
||||
@@ -89,26 +153,48 @@ pip3 install abogen
|
||||
pip3 uninstall torch
|
||||
pip3 install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm6.4
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
> See [How to fix "CUDA GPU is not available. Using CPU" warning?](#cuda-warning)
|
||||
|
||||
> See [How to fix "WARNING: The script abogen-cli is installed in '/home/username/.local/bin' which is not on PATH" error in Linux?](#path-warning)
|
||||
|
||||
> See [How to fix "No matching distribution found" error?](#no-matching-distribution-found)
|
||||
|
||||
> See [How to fix "CUDA GPU is not available. Using CPU" warning?](#cuda-warning)
|
||||
> See [How to fix "[WinError 1114] A dynamic link library (DLL) initialization routine failed" error?](#WinError-1114)
|
||||
|
||||
> Special thanks to [@hg000125](https://github.com/hg000125) for his contribution in [#23](https://github.com/denizsafak/abogen/issues/23). AMD GPU support is possible thanks to his work.
|
||||
|
||||
|
||||
## Interfaces
|
||||
|
||||
Abogen offers **two interfaces**, but currently they have different feature sets. The **Web UI** contains newer features that are still being integrated into the desktop application.
|
||||
|
||||
| Command | Interface | Features |
|
||||
|---------|-----------|----------|
|
||||
| `abogen` | PyQt6 Desktop GUI | Stable core features |
|
||||
| `abogen-web` | Flask Web UI | Core features + **Supertonic TTS**, **LLM Normalization**, **Audiobookshelf Integration** and more! |
|
||||
|
||||
> **Note:** The Web UI is under active development. We are working to integrate these new features into the PyQt desktop app. until then, the Web UI provides the most feature-rich experience.
|
||||
|
||||
> Special thanks to [@jeremiahsb](https://github.com/jeremiahsb) for making this possible! I was honestly surprised by his [massive contribution](https://github.com/denizsafak/abogen/pull/120) (>55,000 lines!) that brought the entire Web UI to life.
|
||||
|
||||
# 🖥️ Desktop Application (PyQt)
|
||||
|
||||
## `How to run?`
|
||||
If you installed using pip, you can simply run the following command to start Abogen:
|
||||
|
||||
You can simply run this command to start Abogen Desktop GUI:
|
||||
|
||||
```bash
|
||||
abogen
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> If you installed Abogen using the Windows installer `(WINDOWS_INSTALL.bat)`, It should have created a shortcut in the same folder, or your desktop. You can run it from there. If you lost the shortcut, Abogen is located in `python_embedded/Scripts/abogen.exe`. You can run it from there directly.
|
||||
|
||||
## `How to use?`
|
||||
1) Drag and drop any ePub, PDF, text or markdown file (or use the built-in text editor)
|
||||
1) Drag and drop any ePub, PDF, text, markdown, or subtitle file (or use the built-in text editor)
|
||||
2) Configure the settings:
|
||||
- Set speech speed
|
||||
- Select a voice (or create a custom voice using voice mixer)
|
||||
@@ -120,27 +206,30 @@ abogen
|
||||
## `In action`
|
||||
<img title="Abogen in action" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen.gif'>
|
||||
|
||||
Here’s Abogen in action: in this demo, it processes ∼3,000 characters of text in just 11 seconds and turns it into 3 minutes and 28 seconds of audio, and I have a low-end **RTX 2060 Mobile laptop GPU**. Your results may vary depending on your hardware.
|
||||
Here’s Abogen in action: in this demo, it processes ∼3,000 characters of text in just 11 seconds and turns it into 3 minutes and 28 seconds of audio, and I have a low-end **RTX 2060 Mobile laptop GPU**. Your results may vary depending on your hardware.
|
||||
|
||||
## `Configuration`
|
||||
|
||||
| Options | Description |
|
||||
|---------|-------------|
|
||||
| **Input Box** | Drag and drop `ePub`, `PDF`, `.TXT` or `.MD` files (or use built-in text editor) |
|
||||
| **Input Box** | Drag and drop `ePub`, `PDF`, `.TXT`, `.MD`, `.SRT`, `.ASS` or `.VTT` files (or use built-in text editor) |
|
||||
| **Queue options** | Add multiple files to a queue and process them in batch, with individual settings for each file. See [Queue mode](#queue-mode) for more details. |
|
||||
| **Speed** | Adjust speech rate from `0.1x` to `2.0x` |
|
||||
| **Select Voice** | First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female. |
|
||||
| **Voice mixer** | Create custom voices by mixing different voice models with a profile system. See [Voice Mixer](#voice-mixer) for more details. |
|
||||
| **Voice preview** | Listen to the selected voice before processing. |
|
||||
| **Generate subtitles** | `Disabled`, `Sentence`, `Sentence + Comma`, `Sentence + Highlighting`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry) |
|
||||
| **Generate subtitles** | `Disabled`, `Line`, `Sentence`, `Sentence + Comma`, `Sentence + Highlighting`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry) |
|
||||
| **Output voice format** | `.WAV`, `.FLAC`, `.MP3`, `.OPUS (best compression)` and `M4B (with chapters)` |
|
||||
| **Output subtitle format** | Configures the subtitle format as `SRT (standard)`, `ASS (wide)`, `ASS (narrow)`, `ASS (centered wide)`, or `ASS (centered narrow)`. |
|
||||
| **Replace single newlines with spaces** | Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks. |
|
||||
| **Save location** | `Save next to input file`, `Save to desktop`, or `Choose output folder` |
|
||||
|
||||
> Special thanks to [@brianxiadong](https://github.com/brianxiadong) for adding markdown support in PR [#75](https://github.com/denizsafak/abogen/pull/75)
|
||||
|
||||
> Special thanks to [@jborza](https://github.com/jborza) for chapter support in PR [#10](https://github.com/denizsafak/abogen/pull/10)
|
||||
|
||||
> Special thanks to [@mleg](https://github.com/mleg) for adding `Line` option in subtitle generation in PR [#94](https://github.com/denizsafak/abogen/pull/94)
|
||||
|
||||
| Book handler options | Description |
|
||||
|---------|-------------|
|
||||
| **Chapter Control** | Select specific `chapters` from ePUBs or markdown files or `chapters + pages` from PDFs. |
|
||||
@@ -159,8 +248,12 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex
|
||||
| **Open config directory** | Opens the directory where the configuration file is stored. |
|
||||
| **Open cache directory** | Opens the cache directory where converted text files are stored. |
|
||||
| **Clear cache files** | Deletes cache files created during the conversion or preview. |
|
||||
| **Check for updates at startup** | Automatically checks for updates when the program starts. |
|
||||
| **Use silent gaps between subtitles** | Prevents unnecessary audio speed-up by letting speech continue into the silent gaps between subtitle etries. In short, it ignores the end times in subtitle entries and uses the silent space until the beginning of the next subtitle entry. When disabled, it speeds up the audio to fit the exact time interval specified in the subtitle. (for subtitle files). |
|
||||
| **Subtitle speed adjustment method** | Choose how to speed up audio when needed: `TTS Regeneration (better quality)` re-generates the audio at a faster speed, while `FFmpeg Time-stretch (better speed)` quickly speeds up the generated audio. (for subtitle files). |
|
||||
| **Use spaCy for sentence segmentation** | When this option is enabled, Abogen uses [spaCy](https://spacy.io/) to detect sentence boundaries more accurately, instead of using punctuation marks (like periods, question marks, etc.) to split sentences, which could incorrectly cut off phrases like "Mr." or "Dr.". With spaCy, sentences are divided more accurately. For non-English text, spaCy runs **before** audio generation to create sentence chunks. For English text, spaCy runs **during** subtitle generation to improve timing and readability. spaCy is only used when subtitle mode is `Sentence` or `Sentence + Comma`. If you prefer the old punctuation splitting method, you can turn this option off. |
|
||||
| **Pre-download models and voices for offline use** | Opens a window that displays the available models and voices. Click `Download all` button to download all required models and voices, allowing you to use Abogen completely offline without any internet connection. |
|
||||
| **Disable Kokoro's internet access** | Prevents Kokoro from downloading models or voices from HuggingFace Hub, useful for offline use. |
|
||||
| **Check for updates at startup** | Automatically checks for updates when the program starts. |
|
||||
| **Reset to default settings** | Resets all settings to their default values. |
|
||||
|
||||
> Special thanks to [@robmckinnon](https://github.com/robmckinnon) for adding Sentence + Highlighting feature in PR [#65](https://github.com/denizsafak/abogen/pull/65)
|
||||
@@ -177,14 +270,181 @@ With voice mixer, you can create custom voices by mixing different voice models.
|
||||
|
||||
Abogen supports **queue mode**, allowing you to add multiple files to a processing queue. This is useful if you want to convert several files in one batch.
|
||||
|
||||
- You can add text files (`.txt`) directly using the **Add files** button in the Queue Manager. To add PDF, EPUB, or markdown files, use the input box in the main window and click the **Add to Queue** button.
|
||||
- You can add text files (`.txt`) and subtitle files (`.srt`, `.ass`, `.vtt`) directly using the **Add files** button in the Queue Manager or by dragging and dropping them into the queue list. To add PDF, EPUB, or markdown files, use the input box in the main window and click the **Add to Queue** button.
|
||||
- Each file in the queue keeps the configuration settings that were active when it was added. Changing the main window configuration afterward does **not** affect files already in the queue.
|
||||
- You can enable the **Override item settings with current selection** option to force all items in the queue to use the configuration currently selected in the main window, overriding their saved settings.
|
||||
- You can view each file's configuration by hovering over them.
|
||||
|
||||
Abogen will process each item in the queue automatically, saving outputs as configured.
|
||||
|
||||
> Special thanks to [@jborza](https://github.com/jborza) for adding queue mode in PR [#35](https://github.com/denizsafak/abogen/pull/35)
|
||||
|
||||
---
|
||||
# 🌐 Web Application (WebUI)
|
||||
|
||||
## `How to run?`
|
||||
|
||||
Run this command to start the Web UI:
|
||||
|
||||
```bash
|
||||
abogen-web
|
||||
```
|
||||
Then open http://localhost:8808 and drag in your documents. Jobs run in the background worker and the browser updates automatically.
|
||||
|
||||
<img title="Abogen in action" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen-webui.png'>
|
||||
|
||||
## `Using the web UI`
|
||||
1. Upload a document (drag & drop or use the upload button).
|
||||
2. Choose voice, language, speed, subtitle style, and output format.
|
||||
3. Click **Create job**. The job immediately appears in the queue.
|
||||
4. Watch progress and logs update live. Download audio/subtitle assets when complete.
|
||||
5. Cancel or delete jobs any time. Download logs for troubleshooting.
|
||||
|
||||
Multiple jobs can run sequentially; the worker processes them in order.
|
||||
|
||||
## `Container image`
|
||||
You can build a lightweight container image directly from the repository root:
|
||||
|
||||
```bash
|
||||
docker build -t abogen .
|
||||
mkdir -p ~/abogen-data/uploads ~/abogen-data/outputs
|
||||
docker run --rm \
|
||||
-p 8808:8808 \
|
||||
-v ~/abogen-data:/data \
|
||||
--name abogen \
|
||||
abogen
|
||||
```
|
||||
|
||||
Browse to http://localhost:8808. Uploaded source files are stored in `/data/uploads` and rendered audio/subtitles appear in `/data/outputs`.
|
||||
|
||||
### Container environment variables
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `ABOGEN_HOST` | `0.0.0.0` | Bind address for the Flask server |
|
||||
| `ABOGEN_PORT` | `8808` | HTTP port |
|
||||
| `ABOGEN_DEBUG` | `false` | Enable Flask debug mode |
|
||||
| `ABOGEN_UPLOAD_ROOT` | `/data/uploads` | Directory where uploaded files are stored |
|
||||
| `ABOGEN_OUTPUT_ROOT` | `/data/outputs` | Directory for generated audio and subtitles (legacy alias of `ABOGEN_OUTPUT_DIR`) |
|
||||
| `ABOGEN_OUTPUT_DIR` | `/data/outputs` | Container path for rendered audio/subtitles |
|
||||
| `ABOGEN_SETTINGS_DIR` | `/config` | Container path for JSON settings/configuration |
|
||||
| `ABOGEN_TEMP_DIR` | `/data/cache` (Docker) or platform cache dir | Container path for temporary audio working files |
|
||||
| `ABOGEN_UID` | `1000` | UID that the container should run as (matches host user) |
|
||||
| `ABOGEN_GID` | `1000` | GID that the container should run as (matches host group) |
|
||||
| `ABOGEN_LLM_BASE_URL` | `""` | OpenAI-compatible endpoint used to seed the Settings → LLM panel |
|
||||
| `ABOGEN_LLM_API_KEY` | `""` | API key passed to the endpoint above |
|
||||
| `ABOGEN_LLM_MODEL` | `""` | Default model selected when you refresh the model list |
|
||||
| `ABOGEN_LLM_TIMEOUT` | `30` | Timeout (seconds) for server-side LLM requests |
|
||||
| `ABOGEN_LLM_CONTEXT_MODE` | `sentence` | Default prompt context window (`sentence`, `paragraph`, `document`) |
|
||||
| `ABOGEN_LLM_PROMPT` | `""` | Custom normalization prompt template seeded into the UI |
|
||||
|
||||
Set any of these with `-e VAR=value` when starting the container.
|
||||
|
||||
To discover your local UID/GID for matching file permissions inside the container, run:
|
||||
|
||||
```bash
|
||||
id -u
|
||||
id -g
|
||||
```
|
||||
|
||||
Use those values to populate `ABOGEN_UID` / `ABOGEN_GID` in your `.env` file.
|
||||
|
||||
When running via Docker Compose, set `ABOGEN_SETTINGS_DIR`,
|
||||
`ABOGEN_OUTPUT_DIR`, and `ABOGEN_TEMP_DIR` in your `.env` file to the host
|
||||
directories you want mounted into the container. Compose maps them to
|
||||
`/config`, `/data/outputs`, and `/data/cache` respectively while exporting
|
||||
those in-container paths to the application. Non-audio caches (e.g., Hugging
|
||||
Face downloads) stick to the container's internal cache under `/tmp/abogen-home/.cache`
|
||||
by default, so only conversion scratch data touches the mounted `ABOGEN_TEMP_DIR`.
|
||||
Ensure each host directory exists and is writable by the UID/GID you configure
|
||||
before starting the stack.
|
||||
|
||||
### Docker Compose (GPU by default)
|
||||
The repo includes `docker-compose.yaml`, which targets GPU hosts out of the box. Install the NVIDIA Container Toolkit and run:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Key build/runtime knobs:
|
||||
|
||||
- `TORCH_VERSION` – pin a specific PyTorch release that matches your driver (leave blank for the latest on the configured index).
|
||||
- `TORCH_INDEX_URL` – swap out the PyTorch download index when targeting a different CUDA build.
|
||||
- `ABOGEN_DATA` – host path that stores uploads/outputs (defaults to `./data`).
|
||||
|
||||
CPU-only deployment: comment out the `deploy.resources.reservations.devices` block (and the optional `runtime: nvidia` line) inside the compose file. Compose will then run without requesting a GPU. If you prefer the classic CLI:
|
||||
|
||||
```bash
|
||||
docker build -f abogen/Dockerfile -t abogen-gpu .
|
||||
docker run --rm \
|
||||
--gpus all \
|
||||
-p 8808:8808 \
|
||||
-v ~/abogen-data:/data \
|
||||
abogen-gpu
|
||||
```
|
||||
|
||||
## `LLM-assisted text normalization`
|
||||
Abogen can hand tricky apostrophes and contractions to an OpenAI-compatible large language model. Configure it from **Settings → LLM**:
|
||||
|
||||
1. Enter the base URL for your endpoint (Ollama, OpenAI proxy, etc.) and an API key if required. Use the server root (for Ollama: `http://localhost:11434`)—Abogen appends `/v1/...` automatically, but it also accepts inputs that already end in `/v1`.
|
||||
2. Click **Refresh models** to load the catalog, pick a default model, and adjust the timeout or prompt template.
|
||||
3. Use the preview box to test the prompt, then save the settings. The Normalization panel can synthesize a short audio preview with the current configuration.
|
||||
|
||||
When you are running inside Docker or a CI pipeline, seed the form automatically with `ABOGEN_LLM_*` variables in your `.env` file. The `.env.example` file includes sample values for a local Ollama server.
|
||||
|
||||
## `Audiobookshelf integration`
|
||||
Abogen can push finished audiobooks directly into Audiobookshelf. Configure this under **Settings → Integrations → Audiobookshelf** by providing:
|
||||
|
||||
- **Base URL** – the HTTPS origin (and optional path prefix) where your Audiobookshelf server is reachable, for example `https://abs.example.com` or `https://media.example.com/abs`. Do **not** append `/api`.
|
||||
- **Library ID** – the identifier of the target Audiobookshelf library (copy it from the library’s settings page in ABS).
|
||||
- **Folder (name or ID)** – the destination folder inside that library. Enter the folder name exactly as it appears in Audiobookshelf (Abogen resolves it to the correct ID automatically), paste the raw `folderId`, or click **Browse folders** to fetch the available folders and populate the field.
|
||||
- **API token** – a personal access token generated in Audiobookshelf under *Account → API tokens*.
|
||||
|
||||
You can enable automatic uploads for future jobs or trigger individual uploads from the queue once the connection succeeds.
|
||||
|
||||
### Reverse proxy checklist (Nginx Proxy Manager)
|
||||
When Audiobookshelf sits behind Nginx Proxy Manager (NPM), make sure the API paths and headers reach the backend untouched:
|
||||
|
||||
1. Create a **Proxy Host** that points to your ABS container or host (default forward port `13378`).
|
||||
2. Under the **SSL** tab, enable your certificate and tick **Force SSL** if you want HTTPS only.
|
||||
3. In the **Advanced** tab, append the snippet below so bearer tokens, client IPs, and large uploads survive the proxy hop:
|
||||
```nginx
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
client_max_body_size 5g;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 300s;
|
||||
```
|
||||
4. Disable **Block Common Exploits** (it strips Authorization headers in some NPM builds).
|
||||
5. Enable **Websockets Support** on the main proxy screen (Audiobookshelf uses it for the web UI, and it keeps the reverse proxy configuration consistent).
|
||||
6. If you publish Audiobookshelf under a path prefix (for example `/abs`), add a **Custom Location** with `Location: /abs/` and set the **Forward Path** to `/`. That rewrite strips the `/abs` prefix before traffic reaches Audiobookshelf so `/abs/api/...` on the internet becomes `/api/...` on the backend. Use the same prefixed URL in Abogen’s “Base URL” field.
|
||||
|
||||
After saving the proxy host, test the API from the machine running Abogen:
|
||||
|
||||
```bash
|
||||
curl -i "https://abs.example.com/api/libraries" \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN"
|
||||
```
|
||||
|
||||
If you still receive `Cannot GET /api/...`, the proxy is rewriting paths. Double-check the **Custom Locations** table (the `Forward Path` column should be empty for `/abs/`) and review the NPM access/error logs while issuing the curl request to confirm the backend sees the full `/api/libraries` URL.
|
||||
|
||||
A JSON response confirming the libraries list means the proxy is routing API calls correctly. You can then use **Browse folders** to confirm the library contents, run **Test connection** in Abogen’s settings (it verifies the library and resolves the folder), and use the “Send to Audiobookshelf” button on completed jobs.
|
||||
|
||||
## `JSON endpoints`
|
||||
Need machine-readable status updates? The dashboard calls a small set of helper endpoints you can reuse:
|
||||
- `GET /api/jobs/<id>` returns job metadata, progress, and log lines in JSON.
|
||||
- `GET /partials/jobs` renders the live job list as HTML (htmx uses this for polling).
|
||||
- `GET /partials/jobs/<id>/logs` renders just the log window.
|
||||
|
||||
More automation hooks are planned; contributions are very welcome if you need additional routes.
|
||||
|
||||
---
|
||||
# Core Features (Available in Both)
|
||||
|
||||
## `About Chapter Markers`
|
||||
When you process ePUB, PDF or markdown files, Abogen converts them into text files stored in your cache directory. When you click "Edit," you're actually modifying these converted text files. In these text files, you'll notice tags that look like this:
|
||||
|
||||
@@ -218,7 +478,30 @@ Similar to chapter markers, it is possible to add metadata tags for `M4B` files.
|
||||
<<METADATA_ALBUM_ARTIST:Album Artist>>
|
||||
<<METADATA_COMPOSER:Narrator>>
|
||||
<<METADATA_GENRE:Audiobook>>
|
||||
<<METADATA_COVER_PATH:path/to/cover.jpg>>
|
||||
```
|
||||
> Note: `METADATA_COVER_PATH` is used to embed a cover image into the generated M4B file. Abogen automatically extracts the cover from EPUB and PDF files and adds this tag for you.
|
||||
|
||||
## `About Timestamp-based Text Files`
|
||||
Similar to converting subtitle files to audio, Abogen can automatically detect text files that contain timestamps in `HH:MM:SS`, `HH:MM:SS,ms` or `HH:MM:SS.ms` format. When timestamps are found inside your text file, Abogen will ask if you want to use them for audio timing. This is useful for creating timed narrations, scripts, or transcripts where you need exact control over when each segment is spoken.
|
||||
|
||||
Format your text file like this:
|
||||
```
|
||||
00:00:00
|
||||
This is the first segment of text.
|
||||
|
||||
00:00:15
|
||||
This is the second segment, starting at 15 seconds.
|
||||
|
||||
00:00:45
|
||||
And this is the third segment, starting at 45 seconds.
|
||||
```
|
||||
|
||||
**Important notes:**
|
||||
- Timestamps must be in `HH:MM:SS`, `HH:MM:SS,ms` or `HH:MM:SS.ms` format (e.g., `00:05:30` for 5 minutes 30 seconds, or `00:05:30.500` for 5 minutes 30.5 seconds)
|
||||
- Milliseconds are optional and provide precision up to 1/1000th of a second
|
||||
- Text before the first timestamp (if any) will automatically start at `00:00:00`
|
||||
- When using timestamps, the subtitle generation mode setting is ignored
|
||||
|
||||
## `Supported Languages`
|
||||
```
|
||||
@@ -235,12 +518,16 @@ For a complete list of supported languages and voices, refer to Kokoro's [VOICES
|
||||
|
||||
> See [How to fix Japanese audio not working?](#japanese-audio-not-working)
|
||||
|
||||
---
|
||||
# Guides & Troubleshooting
|
||||
|
||||
## `MPV Config`
|
||||
I highly recommend using [MPV](https://mpv.io/installation/) to play your audio files, as it supports displaying subtitles even without a video track. Here's my `mpv.conf`:
|
||||
```
|
||||
# --- MPV Settings ---
|
||||
save-position-on-quit
|
||||
keep-open=yes
|
||||
audio-display=no
|
||||
# --- Subtitle ---
|
||||
sub-ass-override=no
|
||||
sub-margin-y=50
|
||||
@@ -252,43 +539,6 @@ audio-samplerate=48000
|
||||
volume-max=200
|
||||
```
|
||||
|
||||
## `Docker Guide`
|
||||
If you want to run Abogen in a Docker container:
|
||||
1) [Download the repository](https://github.com/denizsafak/abogen/archive/refs/heads/main.zip) and extract, or clone it using git.
|
||||
2) Go to `abogen` folder. You should see `Dockerfile` there.
|
||||
3) Open your termminal in that directory and run the following commands:
|
||||
|
||||
```bash
|
||||
# Build the Docker image:
|
||||
docker build --progress plain -t abogen .
|
||||
|
||||
# Note that building the image may take a while.
|
||||
# After building is complete, run the Docker container:
|
||||
|
||||
# Windows
|
||||
docker run --name abogen -v %cd%:/shared -p 5800:5800 -p 5900:5900 --gpus all abogen
|
||||
|
||||
# Linux
|
||||
docker run --name abogen -v $(pwd):/shared -p 5800:5800 -p 5900:5900 --gpus all abogen
|
||||
|
||||
# MacOS
|
||||
docker run --name abogen -v $(pwd):/shared -p 5800:5800 -p 5900:5900 abogen
|
||||
|
||||
# We expose port 5800 for use by a web browser, 5900 if you want to connect with a VNC client.
|
||||
```
|
||||
|
||||
Abogen launches automatically inside the container.
|
||||
- You can access it via a web browser at [http://localhost:5800](http://localhost:5800) or connect to it using a VNC client at `localhost:5900`.
|
||||
- You can use `/shared` directory to share files between your host and the container.
|
||||
- For later use, start it with `docker start abogen` and stop it with `docker stop abogen`.
|
||||
- Pass in `-e WEB_AUDIO="1"` for `docker run` to enable audio.
|
||||
|
||||
Known issues:
|
||||
- Audio preview is not working inside container (ALSA error) if using a VNC client.
|
||||
- `Open cache directory` and `Open configuration directory` options in settings not working. (Tried pcmanfm, did not work with Abogen).
|
||||
|
||||
> Special thanks to [@geo38](https://www.reddit.com/user/geo38/) from Reddit, who provided the Dockerfile and instructions in [this comment](https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/).
|
||||
|
||||
## `Similar Projects`
|
||||
Abogen is a standalone project, but it is inspired by and shares some similarities with other projects. Here are a few:
|
||||
- [audiblez](https://github.com/santinic/audiblez): Generate audiobooks from e-books. **(Has CLI and GUI support)**
|
||||
@@ -318,20 +568,48 @@ abogen-cli.exe
|
||||
|
||||
This will start Abogen in command-line mode and display detailed error messages. Please open a new issue on the [Issues](https://github.com/denizsafak/abogen/issues) page with the error message and a description of your problem.
|
||||
|
||||
## `Tips and Solutions`
|
||||
## `Common Issues & Solutions`
|
||||
|
||||
<details><summary><b>
|
||||
<a name="about-abogen">About the name "abogen"</a>
|
||||
</b></summary>
|
||||
|
||||
> The name **"abogen"** comes from a shortened form of **"audiobook generator"**, which is the purpose of this project.
|
||||
>
|
||||
> After releasing the project, I learned from [community feedback](https://news.ycombinator.com/item?id=44853064#44857237) that the prefix *"abo"* can unfortunately be understood as an ethnic slur in certain regions (particularly Australia and New Zealand). This was something I was not aware of when naming the project, as English is not my first language.
|
||||
>
|
||||
> I want to make it clear that the name was chosen only for its technical meaning, with **no offensive intent**. I’m grateful to those who kindly pointed this out, as it helps ensure the project remains respectful and welcoming to everyone.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><b>
|
||||
<a name="cuda-warning">How to fix "CUDA GPU is not available. Using CPU" warning?</a>
|
||||
</b></summary>
|
||||
|
||||
> This message means PyTorch couldn't use your GPU. On Windows, Abogen supports NVIDIA GPUs with CUDA. AMD GPUs are supported only on Linux. Abogen will still run on the CPU, but it will be slower.
|
||||
> This message means PyTorch could not use your GPU and has fallen back to the CPU. On Windows, Abogen only supports NVIDIA GPUs with CUDA. AMD GPUs are not supported on Windows (they are only supported on Linux with ROCm). Abogen will still work on the CPU, but processing will be slower compared to a supported GPU.
|
||||
>
|
||||
> If you have a compatible NVIDIA GPU on Windows and still see this warning:
|
||||
> Open your terminal in the Abogen folder (the folder that contains `python_embedded`) and type:
|
||||
> ```bash
|
||||
> python_embedded\python.exe -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
|
||||
> python_embedded\python.exe -m pip install --force-reinstall torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128
|
||||
> ```
|
||||
>
|
||||
> If this does not resolve the issue and you are using an older NVIDIA GPU that does not support CUDA 12.8, you can try installing an older version of PyTorch that supports your GPU. For example, for CUDA 12.6, run:
|
||||
> ```bash
|
||||
> python_embedded\python.exe -m pip install --force-reinstall torch==2.8.0+cu126 torchvision==0.23.0+cu126 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu126
|
||||
> ```
|
||||
>
|
||||
> If you have an AMD GPU, you need to use Linux and follow the Linux/ROCm [instructions](#linux). If you want to keep running on CPU, no action is required, but performance will just be reduced. See [#32](https://github.com/denizsafak/abogen/issues/32) for more details.
|
||||
>
|
||||
> If you used `uv` to install Abogen, you can uninstall and try reinstalling with another CUDA version:
|
||||
> ```bash
|
||||
> # First uninstall Abogen
|
||||
> uv tool uninstall abogen
|
||||
> # Try CUDA 12.6 for older drivers
|
||||
> uv tool install --python 3.12 abogen[cuda126] --extra-index-url https://download.pytorch.org/whl/cu126 --index-strategy unsafe-best-match
|
||||
> # If that doesn't work, try CUDA 13.0 for newer drivers
|
||||
> uv tool install --python 3.12 abogen[cuda130] --extra-index-url https://download.pytorch.org/whl/cu130 --index-strategy unsafe-best-match
|
||||
> ```
|
||||
> If you have an AMD GPU, use Linux and follow the Linux/ROCm [instructions](#how-to-install-). If you want to keep running on CPU, no action is required, but performance will just be reduced. See [#32](https://github.com/denizsafak/abogen/issues/32) for more details.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -350,7 +628,23 @@ This will start Abogen in command-line mode and display detailed error messages.
|
||||
<a name="no-matching-distribution-found">How to fix "No matching distribution found" error?<a>
|
||||
</b></summary>
|
||||
|
||||
> Try installing Abogen on supported Python (3.10 to 3.12) versions. You can use [pyenv](https://github.com/pyenv/pyenv) to manage multiple Python versions easily in Linux. Watch this [video](https://www.youtube.com/watch?v=MVyb-nI4KyI) by NetworkChuck for a quick guide.
|
||||
> Try installing Abogen on supported Python (3.10 to 3.12) versions. I recommend installing with [uv](https://docs.astral.sh/uv/getting-started/installation/). You can also use [pyenv](https://github.com/pyenv/pyenv) to manage multiple Python versions easily on Linux. Watch this [video](https://www.youtube.com/watch?v=MVyb-nI4KyI) by NetworkChuck for a quick guide.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><b>
|
||||
<a name="WinError-1114">How to fix "[WinError 1114] A dynamic link library (DLL) initialization routine failed" error?</a>
|
||||
</b></summary>
|
||||
|
||||
> I faced this error when trying to run Abogen in a virtual Windows machine without GPU support. Here's how I fixed it:
|
||||
> If you installed Abogen using the Windows installer `(WINDOWS_INSTALL.bat)`, go to Abogen's folder (that contains `python_embedded`), open your terminal there and run:
|
||||
> ```bash
|
||||
> python_embedded\python.exe -m pip install --force-reinstall torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128
|
||||
> ```
|
||||
> If you installed Abogen using pip, open your terminal in the virtual environment and run:
|
||||
> ```bash
|
||||
> pip install torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0 --index-url https://download.pytorch.org/whl/cu128
|
||||
> ```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -363,21 +657,60 @@ This will start Abogen in command-line mode and display detailed error messages.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><b>
|
||||
<a name="use-uv-instead-of-pip">How to uninstall Abogen?</a>
|
||||
</b></summary>
|
||||
|
||||
> - From the settings menu, go to `Open configuration directory` and delete the directory.
|
||||
> - From the settings menu, go to `Open cache directory` and delete the directory.
|
||||
> - If you installed Abogen using pip, type:
|
||||
>```bash
|
||||
>pip uninstall abogen # uninstalls abogen
|
||||
>pip cache purge # removes pip cache
|
||||
>```
|
||||
>- If you installed Abogen using uv, type:
|
||||
>```bash
|
||||
>uv tool uninstall abogen # uninstalls abogen
|
||||
>uv cache clear # removes uv cache
|
||||
>```
|
||||
> - If you installed Abogen using the Windows installer (WINDOWS_INSTALL.bat), just remove the folder that contains Abogen. It installs everything inside `python_embedded` folder, no other directories are created.
|
||||
> - If you installed espeak-ng, you need to remove it separately.
|
||||
|
||||
</details>
|
||||
|
||||
## `Contributing`
|
||||
I welcome contributions! If you have ideas for new features, improvements, or bug fixes, please fork the repository and submit a pull request.
|
||||
|
||||
### For developers and contributors
|
||||
If you'd like to modify the code and contribute to development, you can [download the repository](https://github.com/denizsafak/abogen/archive/refs/heads/main.zip), extract it and run the following commands to build **or** install the package:
|
||||
```bash
|
||||
# Go to the directory where you extracted the repository and run:
|
||||
pip install -e . # Installs the package in editable mode
|
||||
pip install build # Install the build package
|
||||
python -m build # Builds the package in dist folder (optional)
|
||||
abogen # Opens the GUI
|
||||
pip install -e .[dev] # Installs the package in editable mode with build dependencies
|
||||
python -m build # Builds the package in dist folder (optional)
|
||||
abogen # Opens the GUI
|
||||
```
|
||||
> Make sure you are using Python 3.10 to 3.12. You need to create a virtual environment if needed.
|
||||
|
||||
<details>
|
||||
<summary><b>Alternative: Using uv (click to expand)</b></summary>
|
||||
|
||||
```bash
|
||||
# Go to the directory where you extracted the repository and run:
|
||||
uv venv --python 3.12 # Creates a virtual environment with Python 3.12
|
||||
# After activating the virtual environment, run:
|
||||
uv pip install -e . # Installs the package in editable mode
|
||||
uv build # Builds the package in dist folder (optional)
|
||||
abogen # Opens the GUI
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Feel free to explore the code and make any changes you like.
|
||||
|
||||
## `Credits`
|
||||
- Web UI implementation by [@jeremiahsb](https://github.com/jeremiahsb)
|
||||
- Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible.
|
||||
- Thanks to the [spaCy](https://spacy.io/) project for its sentence-segmentation tools, which help Abogen produce cleaner, more natural sentence segmentation.
|
||||
- Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows.
|
||||
- Thanks to creators of [EbookLib](https://github.com/aerkalov/ebooklib), a Python library for reading and writing ePub files, which is used for extracting text from ePub files.
|
||||
- Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface.
|
||||
@@ -387,7 +720,10 @@ Feel free to explore the code and make any changes you like.
|
||||
This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details.
|
||||
[Kokoro](https://github.com/hexgrad/kokoro) is licensed under [Apache-2.0](https://github.com/hexgrad/kokoro/blob/main/LICENSE) which allows commercial use, modification, distribution, and private use.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Subtitle generation currently works only for English. This is because Kokoro provides timestamp tokens only for English text. If you want subtitles in other languages, please request this feature in the [Kokoro project](https://github.com/hexgrad/kokoro). For more technical details, see [this line](https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py#L383) in the Kokoro's code.
|
||||
## `Star History`
|
||||
[](https://www.star-history.com/#denizsafak/abogen&Date)
|
||||
|
||||
> Tags: audiobook, kokoro, text-to-speech, TTS, audiobook generator, audiobooks, text to speech, audiobook maker, audiobook creator, audiobook generator, voice-synthesis, text to audio, text to audio converter, text to speech converter, text to speech generator, text to speech software, text to speech app, epub to audio, pdf to audio, markdown to audio, content-creation, media-generation
|
||||
> [!NOTE]
|
||||
> Abogen supports subtitle generation for all languages. However, word-level subtitle modes (e.g., "1 word", "2 words", "3 words", etc.) are only available for English because [Kokoro provides timestamp tokens only for English text](https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py#L383). For non-English languages, Abogen uses a duration-based fallback that supports sentence-level and comma-based subtitle modes ("Line", "Sentence", "Sentence + Comma"). If you need word-level subtitles for other languages, please request that feature in the [Kokoro project](https://github.com/hexgrad/kokoro).
|
||||
|
||||
> Tags: audiobook, kokoro, text-to-speech, TTS, audiobook generator, audiobooks, text to speech, audiobook maker, audiobook creator, audiobook generator, voice-synthesis, text to audio, text to audio converter, text to speech converter, text to speech generator, text to speech software, text to speech app, epub to audio, pdf to audio, markdown to audio, subtitle to audio, srt to audio, ass to audio, vtt to audio, webvtt to audio, content-creation, media-generation
|
||||
|
||||
+63
-27
@@ -8,9 +8,6 @@ cd /d "%~dp0"
|
||||
:: Japanese: "ja"
|
||||
set MISAKI_LANG=en
|
||||
|
||||
:: Set PyTorch CUDA version
|
||||
set CUDA_VERSION=128
|
||||
|
||||
:::
|
||||
::: _ ____ ___ ____ _____ _ _
|
||||
::: / \ | __ ) / _ \ / ___|| ____| \ | |
|
||||
@@ -23,6 +20,7 @@ set CUDA_VERSION=128
|
||||
for /f "delims=: tokens=*" %%A in ('findstr /b ::: "%~f0"') do @echo(%%A
|
||||
|
||||
set CURRENT_DIR="%CD%"
|
||||
set "UV_CACHE_DIR=%~dp0.uv_cache"
|
||||
set NAME=abogen
|
||||
set PROJECTFOLDER=abogen
|
||||
set RUN=python_embedded\Scripts\abogen.exe
|
||||
@@ -32,6 +30,28 @@ set refrenv=%PROJECTFOLDER%\refrenv.bat
|
||||
set PYTHON_PATH=python_embedded\pythonw.exe
|
||||
set PYTHON_CONSOLE_PATH=python_embedded\python.exe
|
||||
|
||||
:: ---------------------------------------------------------
|
||||
:: Version Selection
|
||||
:: ---------------------------------------------------------
|
||||
echo.
|
||||
echo Select installation version:
|
||||
echo [1] Stable (PyPI) - Safer, recommended for most users.
|
||||
echo [2] Dev (Local) - Install from current folder (may include commits after the latest release).
|
||||
echo.
|
||||
choice /C 12 /M "Your choice"
|
||||
|
||||
if errorlevel 2 (
|
||||
set INSTALL_SOURCE=dev
|
||||
echo.
|
||||
echo Selected: Dev - Local Editable
|
||||
) else (
|
||||
set INSTALL_SOURCE=pypi
|
||||
echo.
|
||||
echo Selected: Stable - PyPI
|
||||
)
|
||||
echo.
|
||||
:: ---------------------------------------------------------
|
||||
|
||||
:: Check for updates
|
||||
echo Checking for updates...
|
||||
set VERSION_FILE=%PROJECTFOLDER%\VERSION
|
||||
@@ -139,10 +159,10 @@ if exist "%VERSION_FILE%" (
|
||||
REM Python embedded download configuration for different architectures
|
||||
if "%PROCESSOR_ARCHITECTURE%"=="x86" (
|
||||
set PYTHON_EMBEDDED_FILE=%PROJECTFOLDER%\python_embedded_win32.zip
|
||||
set PYTHON_EMBEDDED_URL=https://github.com/wojiushixiaobai/Python-Embed-Win64/releases/download/3.12.8/python-3.12.8-embed-win32.zip
|
||||
set PYTHON_EMBEDDED_URL=https://github.com/wojiushixiaobai/Python-Embed-Win64/releases/download/3.12.12/python-3.12.12-embed-win32.zip
|
||||
) else (
|
||||
set PYTHON_EMBEDDED_FILE=%PROJECTFOLDER%\python_embedded_amd64.zip
|
||||
set PYTHON_EMBEDDED_URL=https://github.com/wojiushixiaobai/Python-Embed-Win64/releases/download/3.12.8/python-3.12.8-embed-amd64.zip
|
||||
set PYTHON_EMBEDDED_URL=https://github.com/wojiushixiaobai/Python-Embed-Win64/releases/download/3.12.12/python-3.12.12-embed-amd64.zip
|
||||
)
|
||||
|
||||
:: Check if Python exists
|
||||
@@ -200,18 +220,19 @@ if not "%~1"=="" (
|
||||
echo Open with: "%~1"
|
||||
)
|
||||
|
||||
:: Update pip
|
||||
echo Updating pip...
|
||||
:: Update pip and install uv
|
||||
echo Updating pip and installing uv...
|
||||
%PYTHON_CONSOLE_PATH% -m pip install --upgrade pip --no-warn-script-location
|
||||
%PYTHON_CONSOLE_PATH% -m pip install uv --no-warn-script-location
|
||||
if errorlevel 1 (
|
||||
echo Failed to update pip.
|
||||
echo Failed to install uv.
|
||||
pause
|
||||
exit /b
|
||||
)
|
||||
|
||||
:: Install docopt's fixed version
|
||||
echo Installing fixed version of docopt...
|
||||
%PYTHON_CONSOLE_PATH% -m pip install --force-reinstall https://github.com/denizsafak/abogen/raw/refs/heads/main/abogen/resources/docopt-0.6.2-py2.py3-none-any.whl --no-warn-script-location
|
||||
%PYTHON_CONSOLE_PATH% -m uv pip install --system --force-reinstall https://github.com/denizsafak/abogen/raw/refs/heads/main/abogen/resources/docopt-0.6.2-py2.py3-none-any.whl
|
||||
if errorlevel 1 (
|
||||
echo Failed to install fixed version of docopt.
|
||||
pause
|
||||
@@ -220,7 +241,7 @@ if errorlevel 1 (
|
||||
|
||||
:: Install progress's fixed version
|
||||
echo Installing fixed version of progress...
|
||||
%PYTHON_CONSOLE_PATH% -m pip install --force-reinstall https://github.com/denizsafak/abogen/raw/refs/heads/main/abogen/resources/progress-1.6-py3-none-any.whl --no-warn-script-location
|
||||
%PYTHON_CONSOLE_PATH% -m uv pip install --system --force-reinstall https://github.com/denizsafak/abogen/raw/refs/heads/main/abogen/resources/progress-1.6-py3-none-any.whl
|
||||
if errorlevel 1 (
|
||||
echo Failed to install fixed version of progress.
|
||||
pause
|
||||
@@ -229,7 +250,7 @@ if errorlevel 1 (
|
||||
|
||||
:: Install setup requirements
|
||||
echo Installing setup requirements...
|
||||
%PYTHON_CONSOLE_PATH% -m pip install --upgrade setuptools setuptools-scm wheel sphinx hatchling editables --no-warn-script-location
|
||||
%PYTHON_CONSOLE_PATH% -m uv pip install --system --upgrade setuptools setuptools-scm wheel sphinx hatchling editables
|
||||
if errorlevel 1 (
|
||||
echo Failed to install setup requirements.
|
||||
pause
|
||||
@@ -238,32 +259,43 @@ if errorlevel 1 (
|
||||
|
||||
:: Install gpustat
|
||||
echo Installing gpustat...
|
||||
%PYTHON_CONSOLE_PATH% -m pip install gpustat --no-warn-script-location
|
||||
%PYTHON_CONSOLE_PATH% -m uv pip install --system gpustat
|
||||
if errorlevel 1 (
|
||||
echo Failed to install gpustat.
|
||||
pause
|
||||
exit /b
|
||||
)
|
||||
|
||||
:: Install project and dependencies from pyproject.toml
|
||||
echo Checking and installing project dependencies...
|
||||
if exist %PYPROJECT_FILE% (
|
||||
echo Installing project from pyproject.toml...
|
||||
%PYTHON_CONSOLE_PATH% -m pip install -e . --no-warn-script-location
|
||||
:: Install project based on user selection
|
||||
if "%INSTALL_SOURCE%"=="pypi" (
|
||||
echo Installing stable version from PyPI...
|
||||
%PYTHON_CONSOLE_PATH% -m uv pip install --system abogen
|
||||
if errorlevel 1 (
|
||||
echo Failed to install from pyproject.toml.
|
||||
echo Failed to install abogen from PyPI.
|
||||
pause
|
||||
exit /b
|
||||
)
|
||||
) else (
|
||||
echo Warning: pyproject.toml not found in current directory.
|
||||
pause
|
||||
echo Checking and installing project dependencies...
|
||||
if exist %PYPROJECT_FILE% (
|
||||
echo Installing project from pyproject.toml using uv...
|
||||
:: Using uv pip install --system --editable
|
||||
%PYTHON_CONSOLE_PATH% -m uv pip install --system --editable .
|
||||
if errorlevel 1 (
|
||||
echo Failed to install from pyproject.toml.
|
||||
pause
|
||||
exit /b
|
||||
)
|
||||
) else (
|
||||
echo Warning: pyproject.toml not found in current directory.
|
||||
pause
|
||||
)
|
||||
)
|
||||
|
||||
:: Install misaki again if MISAKI_LANG is not set to "en"
|
||||
:: Install misaki again if MISAKI_LANG is not set to "en" via uv
|
||||
if "%MISAKI_LANG%" NEQ "en" (
|
||||
echo Configuring language pack: %MISAKI_LANG%
|
||||
%PYTHON_CONSOLE_PATH% -m pip install misaki[%MISAKI_LANG%] --upgrade --no-warn-script-location
|
||||
%PYTHON_CONSOLE_PATH% -m uv pip install --system misaki[%MISAKI_LANG%] --upgrade
|
||||
if errorlevel 1 (
|
||||
echo Failed to install misaki language pack.
|
||||
pause
|
||||
@@ -278,11 +310,13 @@ for /f %%i in ('%PYTHON_CONSOLE_PATH% -c "from abogen.is_nvidia import check; pr
|
||||
echo.
|
||||
echo Checking CUDA availability...
|
||||
if /I "%IS_NVIDIA%"=="true" (
|
||||
for /f %%i in ('%PYTHON_CONSOLE_PATH% -c "from torch.cuda import is_available; print(is_available())"') do set cuda_available=%%i
|
||||
for /f %%i in ('%PYTHON_CONSOLE_PATH% %PROJECTFOLDER%\check_cuda.py') do set cuda_available=%%i
|
||||
|
||||
if "%cuda_available%"=="False" (
|
||||
echo Installing PyTorch with CUDA %CUDA_VERSION% support...
|
||||
%PYTHON_CONSOLE_PATH% -m pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu%CUDA_VERSION% --no-warn-script-location
|
||||
echo "Installing PyTorch with CUDA (12.8) support..."
|
||||
:: We need to use an older version of PyTorch (2.8.0) until this issue is fixed: https://github.com/pytorch/pytorch/issues/166628
|
||||
:: Solution mentioned by @mazenemam19 in #99:
|
||||
%PYTHON_CONSOLE_PATH% -m uv pip install --system torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128
|
||||
echo.
|
||||
if errorlevel 1 (
|
||||
echo Failed to install PyTorch.
|
||||
@@ -305,8 +339,10 @@ if /I "%IS_NVIDIA%"=="true" (
|
||||
if errorlevel 2 (
|
||||
echo Skipping PyTorch installation.
|
||||
) else (
|
||||
echo Installing PyTorch with CUDA %CUDA_VERSION% support...
|
||||
%PYTHON_CONSOLE_PATH% -m pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu%CUDA_VERSION% --no-warn-script-location
|
||||
echo "Installing PyTorch with CUDA (12.8) support..."
|
||||
:: We need to use an older version of PyTorch (2.8.0) until this issue is fixed: https://github.com/pytorch/pytorch/issues/166628
|
||||
:: Solution mentioned by @mazenemam19 in #99:
|
||||
%PYTHON_CONSOLE_PATH% -m uv pip install --system torch==2.8.0+cu128 torchvision==0.23.0+cu128 torchaudio==2.8.0 --index-url https://download.pytorch.org/whl/cu128
|
||||
if errorlevel 1 (
|
||||
echo Failed to install PyTorch.
|
||||
pause
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# Special thanks to @geo38 from Reddit, who provided this Dockerfile:
|
||||
# https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/
|
||||
|
||||
# Use a docker base image that runs a window manager that can be viewed
|
||||
# outside the image with a web browser or VNC client.
|
||||
# https://github.com/jlesage/docker-baseimage-gui
|
||||
FROM jlesage/baseimage-gui:debian-12-v4
|
||||
|
||||
# Load stuff needed by abogen
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
python3 \
|
||||
python3-venv \
|
||||
python3-pip \
|
||||
python3-pyqt5 \
|
||||
espeak-ng \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# The base image will run /startapp.sh on launch.
|
||||
#
|
||||
# The base image runs that script as user 'app' uid=1000. That user
|
||||
# does not exist in the base image but is created at run time.
|
||||
#
|
||||
# We need to install abogen in python venv (requirement of newer python3).
|
||||
#
|
||||
# The python venv has to be writable by the 'app' user as abogen dynamically
|
||||
# installs python packages, so create the venv as that user
|
||||
#
|
||||
# We intend to share the /shared directory with the host using a bind volume
|
||||
# in order to access any source files and the created files.
|
||||
|
||||
RUN echo '#!/bin/bash\nsource /app/venv/bin/activate\nexec abogen' > /startapp.sh \
|
||||
&& chmod 555 /startapp.sh \
|
||||
&& mkdir /app /shared \
|
||||
&& chown 1000:1000 /app /shared \
|
||||
&& chmod 755 /app /shared
|
||||
USER 1000:1000
|
||||
RUN python3 -m venv /app/venv
|
||||
RUN /bin/bash -c "source /app/venv/bin/activate && pip install abogen"
|
||||
# Change back to user ROOT as the startup scripts inside base image needs it
|
||||
USER root
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.1.8
|
||||
1.3.1
|
||||
+30
-30
@@ -1,31 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#808080;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M502.325,307.303l-39.006-30.805c-6.215-4.908-9.665-12.429-9.668-20.348c0-0.084,0-0.168,0-0.252
|
||||
c-0.014-7.936,3.44-15.478,9.667-20.396l39.007-30.806c8.933-7.055,12.093-19.185,7.737-29.701l-17.134-41.366
|
||||
c-4.356-10.516-15.167-16.86-26.472-15.532l-49.366,5.8c-7.881,0.926-15.656-1.966-21.258-7.586
|
||||
c-0.059-0.06-0.118-0.119-0.177-0.178c-5.597-5.602-8.476-13.36-7.552-21.225l5.799-49.363
|
||||
c1.328-11.305-5.015-22.116-15.531-26.472L337.004,1.939c-10.516-4.356-22.646-1.196-29.701,7.736l-30.805,39.005
|
||||
c-4.908,6.215-12.43,9.665-20.349,9.668c-0.084,0-0.168,0-0.252,0c-7.935,0.014-15.477-3.44-20.395-9.667L204.697,9.675
|
||||
c-7.055-8.933-19.185-12.092-29.702-7.736L133.63,19.072c-10.516,4.356-16.86,15.167-15.532,26.473l5.799,49.366
|
||||
c0.926,7.881-1.964,15.656-7.585,21.257c-0.059,0.059-0.118,0.118-0.178,0.178c-5.602,5.598-13.36,8.477-21.226,7.552
|
||||
l-49.363-5.799c-11.305-1.328-22.116,5.015-26.472,15.531L1.939,174.996c-4.356,10.516-1.196,22.646,7.736,29.701l39.006,30.805
|
||||
c6.215,4.908,9.665,12.429,9.668,20.348c0,0.084,0,0.167,0,0.251c0.014,7.935-3.44,15.477-9.667,20.395L9.675,307.303
|
||||
c-8.933,7.055-12.092,19.185-7.736,29.701l17.134,41.365c4.356,10.516,15.168,16.86,26.472,15.532l49.366-5.799
|
||||
c7.882-0.926,15.656,1.965,21.258,7.586c0.059,0.059,0.118,0.119,0.178,0.178c5.597,5.603,8.476,13.36,7.552,21.226l-5.799,49.364
|
||||
c-1.328,11.305,5.015,22.116,15.532,26.472l41.366,17.134c10.516,4.356,22.646,1.196,29.701-7.736l30.804-39.005
|
||||
c4.908-6.215,12.43-9.665,20.348-9.669c0.084,0,0.168,0,0.251,0c7.936-0.014,15.478,3.44,20.396,9.667l30.806,39.007
|
||||
c7.055,8.933,19.185,12.093,29.701,7.736l41.366-17.134c10.516-4.356,16.86-15.168,15.532-26.472l-5.8-49.366
|
||||
c-0.926-7.881,1.965-15.656,7.586-21.257c0.059-0.059,0.119-0.119,0.178-0.178c5.602-5.597,13.36-8.476,21.225-7.552l49.364,5.799
|
||||
c11.305,1.328,22.117-5.015,26.472-15.531l17.134-41.365C514.418,326.488,511.258,314.358,502.325,307.303z M281.292,329.698
|
||||
c-39.68,16.436-85.172-2.407-101.607-42.087c-16.436-39.68,2.407-85.171,42.087-101.608c39.68-16.436,85.172,2.407,101.608,42.088
|
||||
C339.815,267.771,320.972,313.262,281.292,329.698z"/>
|
||||
</g>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg height="800px" width="800px" version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#808080;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M502.325,307.303l-39.006-30.805c-6.215-4.908-9.665-12.429-9.668-20.348c0-0.084,0-0.168,0-0.252
|
||||
c-0.014-7.936,3.44-15.478,9.667-20.396l39.007-30.806c8.933-7.055,12.093-19.185,7.737-29.701l-17.134-41.366
|
||||
c-4.356-10.516-15.167-16.86-26.472-15.532l-49.366,5.8c-7.881,0.926-15.656-1.966-21.258-7.586
|
||||
c-0.059-0.06-0.118-0.119-0.177-0.178c-5.597-5.602-8.476-13.36-7.552-21.225l5.799-49.363
|
||||
c1.328-11.305-5.015-22.116-15.531-26.472L337.004,1.939c-10.516-4.356-22.646-1.196-29.701,7.736l-30.805,39.005
|
||||
c-4.908,6.215-12.43,9.665-20.349,9.668c-0.084,0-0.168,0-0.252,0c-7.935,0.014-15.477-3.44-20.395-9.667L204.697,9.675
|
||||
c-7.055-8.933-19.185-12.092-29.702-7.736L133.63,19.072c-10.516,4.356-16.86,15.167-15.532,26.473l5.799,49.366
|
||||
c0.926,7.881-1.964,15.656-7.585,21.257c-0.059,0.059-0.118,0.118-0.178,0.178c-5.602,5.598-13.36,8.477-21.226,7.552
|
||||
l-49.363-5.799c-11.305-1.328-22.116,5.015-26.472,15.531L1.939,174.996c-4.356,10.516-1.196,22.646,7.736,29.701l39.006,30.805
|
||||
c6.215,4.908,9.665,12.429,9.668,20.348c0,0.084,0,0.167,0,0.251c0.014,7.935-3.44,15.477-9.667,20.395L9.675,307.303
|
||||
c-8.933,7.055-12.092,19.185-7.736,29.701l17.134,41.365c4.356,10.516,15.168,16.86,26.472,15.532l49.366-5.799
|
||||
c7.882-0.926,15.656,1.965,21.258,7.586c0.059,0.059,0.118,0.119,0.178,0.178c5.597,5.603,8.476,13.36,7.552,21.226l-5.799,49.364
|
||||
c-1.328,11.305,5.015,22.116,15.532,26.472l41.366,17.134c10.516,4.356,22.646,1.196,29.701-7.736l30.804-39.005
|
||||
c4.908-6.215,12.43-9.665,20.348-9.669c0.084,0,0.168,0,0.251,0c7.936-0.014,15.478,3.44,20.396,9.667l30.806,39.007
|
||||
c7.055,8.933,19.185,12.093,29.701,7.736l41.366-17.134c10.516-4.356,16.86-15.168,15.532-26.472l-5.8-49.366
|
||||
c-0.926-7.881,1.965-15.656,7.586-21.257c0.059-0.059,0.119-0.119,0.178-0.178c5.602-5.597,13.36-8.476,21.225-7.552l49.364,5.799
|
||||
c11.305,1.328,22.117-5.015,26.472-15.531l17.134-41.365C514.418,326.488,511.258,314.358,502.325,307.303z M281.292,329.698
|
||||
c-39.68,16.436-85.172-2.407-101.607-42.087c-16.436-39.68,2.407-85.171,42.087-101.608c39.68-16.436,85.172,2.407,101.608,42.088
|
||||
C339.815,267.771,320.972,313.262,281.292,329.698z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.5 KiB |
File diff suppressed because it is too large
Load Diff
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
|
||||
+7
-65
@@ -2,9 +2,7 @@ from abogen.utils import get_version
|
||||
|
||||
# Program Information
|
||||
PROGRAM_NAME = "abogen"
|
||||
PROGRAM_DESCRIPTION = (
|
||||
"Generate audiobooks from EPUBs, PDFs and text with synchronized captions."
|
||||
)
|
||||
PROGRAM_DESCRIPTION = "Generate audiobooks from EPUBs, PDFs, text and subtitles with synchronized captions."
|
||||
GITHUB_URL = "https://github.com/denizsafak/abogen"
|
||||
VERSION = get_version()
|
||||
|
||||
@@ -44,6 +42,7 @@ SUPPORTED_SOUND_FORMATS = [
|
||||
SUPPORTED_SUBTITLE_FORMATS = [
|
||||
"srt",
|
||||
"ass",
|
||||
"vtt",
|
||||
]
|
||||
|
||||
# Supported input formats
|
||||
@@ -51,6 +50,9 @@ SUPPORTED_INPUT_FORMATS = [
|
||||
"epub",
|
||||
"pdf",
|
||||
"txt",
|
||||
"srt",
|
||||
"ass",
|
||||
"vtt",
|
||||
]
|
||||
|
||||
# Supported languages for subtitle generation
|
||||
@@ -59,68 +61,7 @@ SUPPORTED_INPUT_FORMATS = [
|
||||
# Please refer to: https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py
|
||||
# 383 English processing (unchanged)
|
||||
# 384 if self.lang_code in 'ab':
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = [
|
||||
"a",
|
||||
"b",
|
||||
]
|
||||
|
||||
# Voice and sample text constants
|
||||
VOICES_INTERNAL = [
|
||||
"af_alloy",
|
||||
"af_aoede",
|
||||
"af_bella",
|
||||
"af_heart",
|
||||
"af_jessica",
|
||||
"af_kore",
|
||||
"af_nicole",
|
||||
"af_nova",
|
||||
"af_river",
|
||||
"af_sarah",
|
||||
"af_sky",
|
||||
"am_adam",
|
||||
"am_echo",
|
||||
"am_eric",
|
||||
"am_fenrir",
|
||||
"am_liam",
|
||||
"am_michael",
|
||||
"am_onyx",
|
||||
"am_puck",
|
||||
"am_santa",
|
||||
"bf_alice",
|
||||
"bf_emma",
|
||||
"bf_isabella",
|
||||
"bf_lily",
|
||||
"bm_daniel",
|
||||
"bm_fable",
|
||||
"bm_george",
|
||||
"bm_lewis",
|
||||
"ef_dora",
|
||||
"em_alex",
|
||||
"em_santa",
|
||||
"ff_siwis",
|
||||
"hf_alpha",
|
||||
"hf_beta",
|
||||
"hm_omega",
|
||||
"hm_psi",
|
||||
"if_sara",
|
||||
"im_nicola",
|
||||
"jf_alpha",
|
||||
"jf_gongitsune",
|
||||
"jf_nezumi",
|
||||
"jf_tebukuro",
|
||||
"jm_kumo",
|
||||
"pf_dora",
|
||||
"pm_alex",
|
||||
"pm_santa",
|
||||
"zf_xiaobei",
|
||||
"zf_xiaoni",
|
||||
"zf_xiaoxiao",
|
||||
"zf_xiaoyi",
|
||||
"zm_yunjian",
|
||||
"zm_yunxi",
|
||||
"zm_yunxia",
|
||||
"zm_yunyang",
|
||||
]
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys())
|
||||
|
||||
# Voice and sample text mapping
|
||||
SAMPLE_VOICE_TEXTS = {
|
||||
@@ -148,6 +89,7 @@ COLORS = {
|
||||
"BLUE_BORDER_HOVER": "#6ab0de",
|
||||
"YELLOW_BACKGROUND": "rgba(255, 221, 51, 0.40)",
|
||||
"GREY_BACKGROUND": "rgba(128, 128, 128, 0.15)",
|
||||
"GREY_BORDER": "#808080",
|
||||
"RED_BACKGROUND": "rgba(232, 78, 60, 0.15)",
|
||||
"RED_BG": "rgba(232, 78, 60, 0.10)",
|
||||
"RED_BG_HOVER": "rgba(232, 78, 60, 0.15)",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,172 @@
|
||||
"""Audio buffer operations for audiobook generation.
|
||||
|
||||
This module provides core audio buffer manipulation functions including:
|
||||
- Silence generation
|
||||
- Audio mixing
|
||||
- Audio normalization
|
||||
- Audio buffer resizing
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Standard sample rate used throughout the application
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
|
||||
def create_silence(duration_seconds: float) -> np.ndarray:
|
||||
"""Create a silence audio buffer.
|
||||
|
||||
Args:
|
||||
duration_seconds: Duration of silence in seconds.
|
||||
|
||||
Returns:
|
||||
Numpy array of float32 zeros with length = duration_seconds * SAMPLE_RATE.
|
||||
Returns empty array if duration is <= 0.
|
||||
"""
|
||||
if duration_seconds <= 0:
|
||||
return np.array([], dtype="float32")
|
||||
|
||||
samples = int(round(duration_seconds * SAMPLE_RATE))
|
||||
if samples <= 0:
|
||||
return np.array([], dtype="float32")
|
||||
|
||||
return np.zeros(samples, dtype="float32")
|
||||
|
||||
|
||||
def mix_audio(
|
||||
target: np.ndarray,
|
||||
source: np.ndarray,
|
||||
start_sample: int,
|
||||
end_sample: Optional[int] = None,
|
||||
) -> np.ndarray:
|
||||
"""Mix source audio into target buffer at specified position.
|
||||
|
||||
This performs additive mixing (target += source). The target buffer
|
||||
is extended if necessary to accommodate the source audio.
|
||||
|
||||
Args:
|
||||
target: The target audio buffer to mix into.
|
||||
source: The source audio buffer to mix.
|
||||
start_sample: Starting sample index in target buffer.
|
||||
end_sample: Optional end sample index. If None, calculated from source length.
|
||||
|
||||
Returns:
|
||||
The target buffer (possibly extended). If target was extended, returns new array.
|
||||
"""
|
||||
if source.size == 0:
|
||||
return target
|
||||
|
||||
if end_sample is None:
|
||||
end_sample = start_sample + len(source)
|
||||
|
||||
# Extend target buffer if needed
|
||||
if end_sample > len(target):
|
||||
new_length = end_sample
|
||||
new_target = np.concatenate([
|
||||
target,
|
||||
np.zeros(new_length - len(target), dtype="float32")
|
||||
])
|
||||
target = new_target
|
||||
|
||||
# Perform the mix (additive)
|
||||
target[start_sample:end_sample] += source
|
||||
return target
|
||||
|
||||
|
||||
def normalize_audio(
|
||||
audio: np.ndarray,
|
||||
target_peak: float = 1.0,
|
||||
) -> np.ndarray:
|
||||
"""Normalize audio buffer to prevent clipping.
|
||||
|
||||
If the audio exceeds the target peak (default 1.0), it is scaled down
|
||||
proportionally to prevent distortion.
|
||||
|
||||
Args:
|
||||
audio: Input audio buffer.
|
||||
target_peak: Target maximum amplitude (default 1.0).
|
||||
|
||||
Returns:
|
||||
Normalized audio buffer (new array, original is not modified).
|
||||
"""
|
||||
if audio.size == 0:
|
||||
return audio.copy()
|
||||
|
||||
max_amplitude = float(np.abs(audio).max())
|
||||
|
||||
if max_amplitude <= target_peak:
|
||||
return audio.copy()
|
||||
|
||||
# Scale down to prevent clipping
|
||||
scale_factor = target_peak / max_amplitude
|
||||
return (audio * scale_factor).astype("float32")
|
||||
|
||||
|
||||
def ensure_buffer_size(
|
||||
buffer: np.ndarray,
|
||||
min_samples: int,
|
||||
) -> np.ndarray:
|
||||
"""Ensure audio buffer is at least min_samples long.
|
||||
|
||||
If buffer is shorter, it is extended with zeros.
|
||||
|
||||
Args:
|
||||
buffer: Input audio buffer.
|
||||
min_samples: Minimum required length in samples.
|
||||
|
||||
Returns:
|
||||
Buffer of at least min_samples length (new array if extended).
|
||||
"""
|
||||
if len(buffer) >= min_samples:
|
||||
return buffer
|
||||
|
||||
new_buffer = np.zeros(min_samples, dtype="float32")
|
||||
new_buffer[:len(buffer)] = buffer
|
||||
return new_buffer
|
||||
|
||||
|
||||
def concatenate_audio(*buffers: np.ndarray) -> np.ndarray:
|
||||
"""Concatenate multiple audio buffers.
|
||||
|
||||
Args:
|
||||
*buffers: Audio buffers to concatenate.
|
||||
|
||||
Returns:
|
||||
Single concatenated audio buffer.
|
||||
"""
|
||||
non_empty = [b for b in buffers if b.size > 0]
|
||||
if not non_empty:
|
||||
return np.array([], dtype="float32")
|
||||
return np.concatenate(non_empty)
|
||||
|
||||
|
||||
def audio_duration(audio: np.ndarray, sample_rate: int = SAMPLE_RATE) -> float:
|
||||
"""Calculate duration of audio buffer in seconds.
|
||||
|
||||
Args:
|
||||
audio: Audio buffer.
|
||||
sample_rate: Sample rate in Hz (default SAMPLE_RATE).
|
||||
|
||||
Returns:
|
||||
Duration in seconds.
|
||||
"""
|
||||
return len(audio) / sample_rate
|
||||
|
||||
|
||||
def samples_for_duration(duration_seconds: float, sample_rate: int = SAMPLE_RATE) -> int:
|
||||
"""Calculate number of samples for a given duration.
|
||||
|
||||
Args:
|
||||
duration_seconds: Duration in seconds.
|
||||
sample_rate: Sample rate in Hz (default SAMPLE_RATE).
|
||||
|
||||
Returns:
|
||||
Number of samples (rounded to nearest integer), or 0 if duration is <= 0.
|
||||
"""
|
||||
if duration_seconds <= 0:
|
||||
return 0
|
||||
return int(round(duration_seconds * sample_rate))
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Audio helper utilities.
|
||||
|
||||
Functions for building ffmpeg commands, converting audio formats,
|
||||
and applying chapter metadata to MP4 files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
|
||||
def build_ffmpeg_command(path: Path, fmt: str, metadata: Optional[Dict[str, str]] = None) -> list[str]:
|
||||
from abogen.infrastructure.exporters import ExportService
|
||||
|
||||
base = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"f32le",
|
||||
"-ar",
|
||||
str(SAMPLE_RATE),
|
||||
"-ac",
|
||||
"1",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
]
|
||||
if fmt == "mp3":
|
||||
base += ["-c:a", "libmp3lame", "-qscale:a", "2"]
|
||||
elif fmt == "opus":
|
||||
base += ["-c:a", "libopus", "-b:a", "24000"]
|
||||
elif fmt == "m4b":
|
||||
base += ["-c:a", "aac", "-q:a", "2", "-movflags", "+faststart+use_metadata_tags"]
|
||||
else:
|
||||
base += ["-c:a", "copy"]
|
||||
|
||||
if metadata:
|
||||
svc = ExportService()
|
||||
base.extend(svc._metadata_to_ffmpeg_args(metadata))
|
||||
base.append(str(path))
|
||||
return base
|
||||
|
||||
|
||||
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 apply_m4b_chapters_with_mutagen(
|
||||
audio_path: Path,
|
||||
chapters: List[Dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Apply chapter atoms to an MP4/M4B file using mutagen.
|
||||
|
||||
Returns True if chapters were written, False otherwise.
|
||||
Raises ImportError if mutagen is not installed.
|
||||
"""
|
||||
if not chapters:
|
||||
return False
|
||||
|
||||
from fractions import Fraction
|
||||
from mutagen.mp4 import MP4, MP4Chapter # type: ignore[import]
|
||||
|
||||
mp4 = MP4(str(audio_path))
|
||||
|
||||
chapter_objects: List[MP4Chapter] = []
|
||||
for index, entry in enumerate(sorted(chapters, key=lambda item: float(item.get("start") or 0.0))):
|
||||
start_raw = entry.get("start")
|
||||
if start_raw is None:
|
||||
continue
|
||||
try:
|
||||
start_seconds = max(0.0, float(start_raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
title_value = entry.get("title")
|
||||
title_text = str(title_value) if title_value else f"Chapter {index + 1}"
|
||||
|
||||
start_fraction = Fraction(int(round(start_seconds * 1000)), 1000)
|
||||
chapter_atom = MP4Chapter(start_fraction, title_text)
|
||||
|
||||
end_raw = entry.get("end")
|
||||
if end_raw is not None:
|
||||
try:
|
||||
end_seconds = float(end_raw)
|
||||
except (TypeError, ValueError):
|
||||
end_seconds = None
|
||||
if end_seconds is not None and end_seconds > start_seconds:
|
||||
chapter_atom.end = Fraction(int(round(end_seconds * 1000)), 1000)
|
||||
|
||||
chapter_objects.append(chapter_atom)
|
||||
|
||||
if not chapter_objects:
|
||||
return False
|
||||
|
||||
from typing import cast
|
||||
|
||||
mp4.chapters = cast(Any, chapter_objects)
|
||||
mp4.save()
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Audio sink abstraction for unified audio output.
|
||||
|
||||
Provides a context-manager-based abstraction for writing audio data
|
||||
to various output formats (WAV, FLAC via soundfile; compressed via ffmpeg).
|
||||
|
||||
Usage:
|
||||
with open_audio_sink(path, "wav") as sink:
|
||||
sink.write(audio_data)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.domain.audio_buffer import SAMPLE_RATE
|
||||
from abogen.domain.audio_helpers import build_ffmpeg_command
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioSink:
|
||||
"""Represents an open audio output target."""
|
||||
|
||||
write: Callable[[np.ndarray], None]
|
||||
close: Callable[[], None]
|
||||
|
||||
def __enter__(self) -> AudioSink:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
def _ensure_ffmpeg() -> None:
|
||||
"""Ensure static ffmpeg binaries are on PATH."""
|
||||
import static_ffmpeg # type: ignore
|
||||
|
||||
ffmpeg_cache_root = _get_ffmpeg_cache_root()
|
||||
platform_cache = os.path.join(ffmpeg_cache_root, sys.platform)
|
||||
os.makedirs(platform_cache, exist_ok=True)
|
||||
try:
|
||||
import static_ffmpeg.run as static_ffmpeg_run # type: ignore
|
||||
|
||||
static_ffmpeg_run.LOCK_FILE = os.path.join(ffmpeg_cache_root, "lock.file")
|
||||
except Exception:
|
||||
pass
|
||||
static_ffmpeg.add_paths(weak=True, download_dir=platform_cache)
|
||||
|
||||
|
||||
def _get_ffmpeg_cache_root() -> str:
|
||||
from abogen.infrastructure.cache import get_internal_cache_path
|
||||
|
||||
return get_internal_cache_path("ffmpeg")
|
||||
|
||||
|
||||
def open_audio_sink(
|
||||
path: Path,
|
||||
fmt: str,
|
||||
*,
|
||||
metadata: Optional[dict[str, str]] = None,
|
||||
cancel_check: Optional[Callable[[], bool]] = None,
|
||||
extra_ffmpeg_args: Optional[list[str]] = None,
|
||||
ffmpeg_cmd: Optional[list[str]] = None,
|
||||
) -> AudioSink:
|
||||
"""Open an audio output sink for writing raw float32 PCM samples.
|
||||
|
||||
Args:
|
||||
path: Output file path.
|
||||
fmt: Output format ("wav", "flac", "mp3", "opus", "m4b").
|
||||
metadata: Optional metadata dict (ignored when ffmpeg_cmd is provided).
|
||||
cancel_check: Optional callable; if it returns True, writes are silently skipped.
|
||||
extra_ffmpeg_args: Optional extra args inserted after ffmpeg header (ignored when ffmpeg_cmd is provided).
|
||||
ffmpeg_cmd: Optional pre-built ffmpeg command list (for m4b with cover art etc.).
|
||||
|
||||
Returns:
|
||||
AudioSink with write() and close() methods.
|
||||
"""
|
||||
fmt = fmt.lower()
|
||||
|
||||
if fmt in {"wav", "flac"}:
|
||||
import soundfile as sf
|
||||
|
||||
soundfile_obj = sf.SoundFile(
|
||||
path,
|
||||
mode="w",
|
||||
samplerate=SAMPLE_RATE,
|
||||
channels=1,
|
||||
format=fmt.upper(),
|
||||
)
|
||||
|
||||
def _write_wav(data: np.ndarray) -> None:
|
||||
if cancel_check and cancel_check():
|
||||
return
|
||||
soundfile_obj.write(data)
|
||||
|
||||
def _close_wav() -> None:
|
||||
soundfile_obj.close()
|
||||
|
||||
return AudioSink(write=_write_wav, close=_close_wav)
|
||||
|
||||
# Compressed formats: pipe through ffmpeg
|
||||
_ensure_ffmpeg()
|
||||
|
||||
if ffmpeg_cmd is not None:
|
||||
cmd = list(ffmpeg_cmd)
|
||||
else:
|
||||
cmd = build_ffmpeg_command(path, fmt, metadata=metadata)
|
||||
if extra_ffmpeg_args:
|
||||
cmd[2:2] = extra_ffmpeg_args
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
def _write_compressed(data: np.ndarray) -> None:
|
||||
if (cancel_check and cancel_check()) or process.stdin is None or process.stdin.closed:
|
||||
return
|
||||
process.stdin.write(data.tobytes())
|
||||
|
||||
def _close_compressed() -> None:
|
||||
if process.stdin and not process.stdin.closed:
|
||||
process.stdin.close()
|
||||
process.wait()
|
||||
|
||||
return AudioSink(write=_write_compressed, close=_close_compressed)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Heuristics for classifying chapters as content vs. supplements.
|
||||
|
||||
A 'supplement' is any non-story material that a listener would typically
|
||||
skip: title page, copyright, table of contents, acknowledgements, etc.
|
||||
The scoring functions return a float; higher ⇒ more likely to be a
|
||||
supplement. ``should_preselect_chapter`` turns that score into a
|
||||
boolean suitable for a web form default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
# Compiled once at module load – these are immutable.
|
||||
|
||||
_SUPPLEMENT_TITLE_PATTERNS: List[Tuple[re.Pattern[str], float]] = [
|
||||
(re.compile(r"\btitle\s+page\b"), 3.0),
|
||||
(re.compile(r"\bcopyright\b"), 2.4),
|
||||
(re.compile(r"\btable\s+of\s+contents\b"), 2.8),
|
||||
(re.compile(r"\bcontents\b"), 2.0),
|
||||
(re.compile(r"\backnowledg(e)?ments?\b"), 2.0),
|
||||
(re.compile(r"\bdedication\b"), 2.0),
|
||||
(re.compile(r"\babout\s+the\s+author(s)?\b"), 2.4),
|
||||
(re.compile(r"\balso\s+by\b"), 2.0),
|
||||
(re.compile(r"\bpraise\s+for\b"), 2.0),
|
||||
(re.compile(r"\bcolophon\b"), 2.2),
|
||||
(re.compile(r"\bpublication\s+data\b"), 2.2),
|
||||
(re.compile(r"\btranscriber'?s?\s+note\b"), 2.2),
|
||||
(re.compile(r"\bglossary\b"), 2.2),
|
||||
(re.compile(r"\bindex\b"), 2.0),
|
||||
(re.compile(r"\bbibliograph(y|ies)\b"), 2.0),
|
||||
(re.compile(r"\breferences\b"), 1.8),
|
||||
(re.compile(r"\bappendix\b"), 1.9),
|
||||
]
|
||||
|
||||
_CONTENT_TITLE_PATTERNS: List[re.Pattern[str]] = [
|
||||
re.compile(r"\bchapter\b"),
|
||||
re.compile(r"\bbook\b"),
|
||||
re.compile(r"\bpart\b"),
|
||||
re.compile(r"\bsection\b"),
|
||||
re.compile(r"\bscene\b"),
|
||||
re.compile(r"\bprologue\b"),
|
||||
re.compile(r"\bepilogue\b"),
|
||||
re.compile(r"\bintroduction\b"),
|
||||
re.compile(r"\bstory\b"),
|
||||
]
|
||||
|
||||
_SUPPLEMENT_TEXT_KEYWORDS: List[Tuple[str, float]] = [
|
||||
("copyright", 1.2),
|
||||
("all rights reserved", 1.1),
|
||||
("isbn", 0.9),
|
||||
("library of congress", 1.0),
|
||||
("table of contents", 1.0),
|
||||
("dedicated to", 0.8),
|
||||
("acknowledg", 0.8),
|
||||
("printed in", 0.6),
|
||||
("permission", 0.6),
|
||||
("publisher", 0.5),
|
||||
("praise for", 0.9),
|
||||
("also by", 0.9),
|
||||
("glossary", 0.8),
|
||||
("index", 0.8),
|
||||
("newsletter", 3.2),
|
||||
("mailing list", 2.6),
|
||||
("sign-up", 2.2),
|
||||
]
|
||||
|
||||
|
||||
def supplement_score(title: str, text: str, index: int) -> float:
|
||||
"""Return a score indicating how likely *title*/*text* is a supplement.
|
||||
|
||||
Higher values ⇒ more likely to be non-story material (title page,
|
||||
copyright, acknowledgements, etc.).
|
||||
"""
|
||||
normalized_title = (title or "").lower()
|
||||
score = 0.0
|
||||
|
||||
for pattern, weight in _SUPPLEMENT_TITLE_PATTERNS:
|
||||
if pattern.search(normalized_title):
|
||||
score += weight
|
||||
|
||||
for pattern in _CONTENT_TITLE_PATTERNS:
|
||||
if pattern.search(normalized_title):
|
||||
score -= 2.0
|
||||
|
||||
stripped_text = (text or "").strip()
|
||||
length = len(stripped_text)
|
||||
if length <= 150:
|
||||
score += 0.9
|
||||
elif length <= 400:
|
||||
score += 0.6
|
||||
elif length <= 800:
|
||||
score += 0.35
|
||||
|
||||
lowercase_text = stripped_text.lower()
|
||||
for keyword, weight in _SUPPLEMENT_TEXT_KEYWORDS:
|
||||
if keyword in lowercase_text:
|
||||
score += weight
|
||||
|
||||
if index == 0 and score > 0:
|
||||
score += 0.25
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def should_preselect_chapter(
|
||||
title: str,
|
||||
text: str,
|
||||
index: int,
|
||||
total_count: int,
|
||||
) -> bool:
|
||||
"""Return True if the chapter should be *enabled* by default in the form.
|
||||
|
||||
A single chapter is always preselected. For multi-chapter books, the
|
||||
chapter is preselected when its supplement score is below 1.9.
|
||||
"""
|
||||
if total_count <= 1:
|
||||
return True
|
||||
score = supplement_score(title, text, index)
|
||||
return score < 1.9
|
||||
|
||||
|
||||
def ensure_at_least_one_chapter_enabled(chapters: List[Dict[str, Any]]) -> None:
|
||||
"""Mutate *chapters* in-place so that at least one has ``enabled=True``."""
|
||||
if not chapters:
|
||||
return
|
||||
if any(chapter.get("enabled") for chapter in chapters):
|
||||
return
|
||||
best_index = max(range(len(chapters)), key=lambda idx: chapters[idx].get("characters", 0))
|
||||
chapters[best_index]["enabled"] = True
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from abogen.text_extractor import ExtractedChapter
|
||||
from abogen.domain.voice_utils import coerce_truthy
|
||||
|
||||
|
||||
def apply_chapter_overrides(
|
||||
extracted: List[ExtractedChapter],
|
||||
overrides: List[Dict[str, Any]],
|
||||
) -> Tuple[List[ExtractedChapter], Dict[str, str], List[str]]:
|
||||
if not overrides:
|
||||
return [], {}, []
|
||||
|
||||
selected: List[ExtractedChapter] = []
|
||||
metadata_updates: Dict[str, str] = {}
|
||||
diagnostics: List[str] = []
|
||||
|
||||
for position, payload in enumerate(overrides):
|
||||
if not isinstance(payload, dict):
|
||||
diagnostics.append(
|
||||
f"Skipped chapter override at position {position + 1}: unsupported payload type {type(payload).__name__}."
|
||||
)
|
||||
continue
|
||||
|
||||
enabled = coerce_truthy(payload.get("enabled", True))
|
||||
payload["enabled"] = enabled
|
||||
if not enabled:
|
||||
continue
|
||||
|
||||
metadata_payload = payload.get("metadata") or {}
|
||||
if isinstance(metadata_payload, dict):
|
||||
for key, value in metadata_payload.items():
|
||||
if value is None:
|
||||
continue
|
||||
metadata_updates[str(key)] = str(value)
|
||||
|
||||
base: Optional[ExtractedChapter] = None
|
||||
idx_candidate = payload.get("index")
|
||||
idx_normalized: Optional[int] = None
|
||||
if isinstance(idx_candidate, int):
|
||||
idx_normalized = idx_candidate
|
||||
elif isinstance(idx_candidate, str):
|
||||
try:
|
||||
idx_normalized = int(idx_candidate)
|
||||
except ValueError:
|
||||
idx_normalized = None
|
||||
if idx_normalized is not None and 0 <= idx_normalized < len(extracted):
|
||||
base = extracted[idx_normalized]
|
||||
payload["index"] = idx_normalized
|
||||
|
||||
if base is None:
|
||||
source_title = payload.get("source_title")
|
||||
if isinstance(source_title, str):
|
||||
base = next((chapter for chapter in extracted if chapter.title == source_title), None)
|
||||
|
||||
if base is None:
|
||||
candidate_title = payload.get("title")
|
||||
if isinstance(candidate_title, str):
|
||||
base = next((chapter for chapter in extracted if chapter.title == candidate_title), None)
|
||||
|
||||
text_override = payload.get("text")
|
||||
if text_override is not None:
|
||||
text_value = str(text_override)
|
||||
elif base is not None:
|
||||
text_value = base.text
|
||||
else:
|
||||
diagnostics.append(
|
||||
f"Skipped chapter override at position {position + 1}: no text provided and no matching source chapter found."
|
||||
)
|
||||
continue
|
||||
|
||||
title_override = payload.get("title")
|
||||
if title_override is not None:
|
||||
title_value = str(title_override)
|
||||
elif base is not None:
|
||||
title_value = base.title
|
||||
else:
|
||||
title_value = f"Chapter {position + 1}"
|
||||
|
||||
if base and not payload.get("source_title"):
|
||||
payload["source_title"] = base.title
|
||||
|
||||
payload["title"] = title_value
|
||||
payload["text"] = text_value
|
||||
payload["characters"] = len(text_value)
|
||||
payload.setdefault("order", payload.get("order", position))
|
||||
|
||||
selected.append(ExtractedChapter(title=title_value, text=text_value))
|
||||
|
||||
return selected, metadata_updates, diagnostics
|
||||
@@ -0,0 +1,204 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
_HEADING_SANITIZE_RE = re.compile(r"[^a-z0-9]+")
|
||||
_HEADING_NUMBER_PREFIX_RE = re.compile(
|
||||
r"^\s*(?P<number>(?:\d+|[ivxlcdm]+))(?P<suffix>(?:[\s.:;-].*)?)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ACRONYM_ALLOWLIST = {
|
||||
"AI", "API", "CPU", "DIY", "GPU", "HTML", "HTTP", "HTTPS", "ID",
|
||||
"JSON", "MP3", "MP4", "M4B", "NASA", "OCR", "PDF", "SQL", "TV",
|
||||
"TTS", "UK", "UN", "UFO", "OK", "URL", "USA", "US", "VR",
|
||||
}
|
||||
_ROMAN_NUMERAL_CHARS = frozenset("IVXLCDM")
|
||||
_CAPS_WORD_RE = re.compile(r"[A-Z][A-Z0-9'\u2019-]*")
|
||||
|
||||
|
||||
def simplify_heading_text(text: str) -> str:
|
||||
raw = str(text or "").strip().lower()
|
||||
if not raw:
|
||||
return ""
|
||||
simplified = _HEADING_SANITIZE_RE.sub("", raw)
|
||||
if simplified.startswith("chapter"):
|
||||
simplified = simplified[7:]
|
||||
return simplified
|
||||
|
||||
|
||||
def headings_equivalent(left: str, right: str) -> bool:
|
||||
simple_left = simplify_heading_text(left)
|
||||
simple_right = simplify_heading_text(right)
|
||||
if not simple_left or not simple_right:
|
||||
return False
|
||||
if simple_left == simple_right:
|
||||
return True
|
||||
if simple_right.startswith(simple_left):
|
||||
return True
|
||||
if simple_left.startswith(simple_right):
|
||||
return True
|
||||
if len(simple_left) > 5 and simple_left in simple_right:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def strip_duplicate_heading_line(text: str, heading: str) -> Tuple[str, bool]:
|
||||
source_text = str(text or "")
|
||||
if not source_text:
|
||||
return source_text, False
|
||||
normalized_heading = simplify_heading_text(heading)
|
||||
if not normalized_heading:
|
||||
return source_text, False
|
||||
lines = source_text.splitlines()
|
||||
new_lines: List[str] = []
|
||||
removed = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not removed and stripped:
|
||||
if headings_equivalent(stripped, heading):
|
||||
removed = True
|
||||
continue
|
||||
new_lines.append(line)
|
||||
if not removed:
|
||||
return source_text, False
|
||||
while new_lines and not new_lines[0].strip():
|
||||
new_lines.pop(0)
|
||||
return "\n".join(new_lines), True
|
||||
|
||||
|
||||
def normalize_caps_word(word: str) -> str:
|
||||
upper = word.upper()
|
||||
letters = [char for char in upper if char.isalpha()]
|
||||
if not letters:
|
||||
return word
|
||||
if upper in _ACRONYM_ALLOWLIST:
|
||||
return word
|
||||
if len(letters) <= 1:
|
||||
return word
|
||||
if all(char in _ROMAN_NUMERAL_CHARS for char in letters) and len(letters) <= 7:
|
||||
return word
|
||||
|
||||
parts = re.split(r"(['\-\u2019])", word)
|
||||
normalized_parts: List[str] = []
|
||||
for part in parts:
|
||||
if part in {"'", "-", "\u2019"}:
|
||||
normalized_parts.append(part)
|
||||
continue
|
||||
if not part:
|
||||
continue
|
||||
normalized_parts.append(part[0].upper() + part[1:].lower())
|
||||
return "".join(normalized_parts) or word
|
||||
|
||||
|
||||
def normalize_chapter_opening_caps(text: str) -> Tuple[str, bool]:
|
||||
if not text:
|
||||
return text, False
|
||||
|
||||
leading_len = len(text) - len(text.lstrip())
|
||||
leading = text[:leading_len]
|
||||
working = text[leading_len:]
|
||||
if not working:
|
||||
return text, False
|
||||
|
||||
builder: List[str] = []
|
||||
pos = 0
|
||||
changed = False
|
||||
|
||||
while pos < len(working):
|
||||
char = working[pos]
|
||||
if char in "\r\n":
|
||||
builder.append(working[pos:])
|
||||
pos = len(working)
|
||||
break
|
||||
if char.isspace():
|
||||
builder.append(char)
|
||||
pos += 1
|
||||
continue
|
||||
if char.islower():
|
||||
builder.append(working[pos:])
|
||||
pos = len(working)
|
||||
break
|
||||
if not char.isalpha():
|
||||
builder.append(char)
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
match = _CAPS_WORD_RE.match(working, pos)
|
||||
if not match:
|
||||
builder.append(char)
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
word = match.group(0)
|
||||
if any(ch.islower() for ch in word):
|
||||
builder.append(working[pos:])
|
||||
pos = len(working)
|
||||
break
|
||||
|
||||
normalized = normalize_caps_word(word)
|
||||
if normalized != word:
|
||||
changed = True
|
||||
builder.append(normalized)
|
||||
pos = match.end()
|
||||
|
||||
if pos < len(working):
|
||||
builder.append(working[pos:])
|
||||
|
||||
if not changed:
|
||||
return text, False
|
||||
|
||||
return leading + "".join(builder), True
|
||||
|
||||
|
||||
def format_spoken_chapter_title(title: str, index: int, apply_prefix: bool) -> str:
|
||||
base = str(title or "").strip()
|
||||
if not base:
|
||||
return f"Chapter {index}" if apply_prefix else ""
|
||||
if not apply_prefix:
|
||||
return base
|
||||
lowered = base.lower()
|
||||
if lowered.startswith("chapter") and (len(lowered) == 7 or not lowered[7].isalpha()):
|
||||
return base
|
||||
match = _HEADING_NUMBER_PREFIX_RE.match(base)
|
||||
if match:
|
||||
number = match.group("number") or ""
|
||||
suffix = match.group("suffix") or ""
|
||||
cleaned_suffix = suffix.lstrip(" .,:;-_ \t\u2013\u2014\u00b7\u2022")
|
||||
if cleaned_suffix:
|
||||
return f"Chapter {number}. {cleaned_suffix}"
|
||||
return f"Chapter {number}"
|
||||
return base
|
||||
|
||||
|
||||
def apply_chapter_text_transforms(
|
||||
text: str,
|
||||
*,
|
||||
heading_text: str,
|
||||
raw_title: str,
|
||||
strip_heading: bool,
|
||||
normalize_caps: bool,
|
||||
) -> Tuple[str, bool, bool]:
|
||||
"""Strip duplicate heading and normalize opening caps.
|
||||
|
||||
Returns ``(text, heading_removed, caps_changed)``.
|
||||
The caller is responsible for state updates (pending flags, logging,
|
||||
dict mutation, ``continue``).
|
||||
"""
|
||||
heading_removed = False
|
||||
caps_changed = False
|
||||
|
||||
if strip_heading and heading_text:
|
||||
text, heading_removed = strip_duplicate_heading_line(text, heading_text)
|
||||
if not heading_removed and raw_title:
|
||||
match = _HEADING_NUMBER_PREFIX_RE.match(raw_title)
|
||||
if match:
|
||||
number = match.group("number")
|
||||
if number:
|
||||
text, heading_removed = strip_duplicate_heading_line(text, number)
|
||||
|
||||
if normalize_caps and text:
|
||||
text, caps_changed = normalize_chapter_opening_caps(text)
|
||||
|
||||
return text, heading_removed, caps_changed
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Chunk processing utilities.
|
||||
|
||||
Functions for grouping chunks, recording override usage, and selecting
|
||||
text for TTS synthesis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, Iterable, Mapping, Optional
|
||||
|
||||
from abogen.pronunciation_store import increment_usage
|
||||
|
||||
|
||||
def safe_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def group_chunks_by_chapter(chunks: Iterable[Dict[str, Any]]) -> Dict[int, List[Dict[str, Any]]]:
|
||||
grouped: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for entry in chunks or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
try:
|
||||
chapter_index = int(entry.get("chapter_index", 0))
|
||||
except (TypeError, ValueError):
|
||||
chapter_index = 0
|
||||
grouped[chapter_index].append(dict(entry))
|
||||
|
||||
for chapter_index, items in grouped.items():
|
||||
items.sort(key=lambda payload: safe_int(payload.get("chunk_index")))
|
||||
|
||||
return grouped
|
||||
|
||||
|
||||
def record_override_usage(
|
||||
job: Any,
|
||||
usage_counter: Mapping[str, int],
|
||||
token_map: Mapping[str, str],
|
||||
) -> None:
|
||||
if not usage_counter:
|
||||
return
|
||||
|
||||
language = getattr(job, "language", "") or "a"
|
||||
for normalized, amount in usage_counter.items():
|
||||
if amount <= 0:
|
||||
continue
|
||||
token_value = token_map.get(normalized, normalized)
|
||||
try:
|
||||
increment_usage(language=language, token=token_value, amount=int(amount))
|
||||
except Exception: # pragma: no cover - defensive logging
|
||||
job.add_log(f"Failed to record usage for override {token_value}", level="warning")
|
||||
|
||||
|
||||
def chunk_text_for_tts(entry: Mapping[str, Any]) -> str:
|
||||
"""Choose the best source text for synthesis.
|
||||
|
||||
We must prefer the raw chunk text (``text`` / ``original_text``) so
|
||||
manual/pronunciation overrides can match against the original tokens
|
||||
(e.g. censored words like ``Unfu*k``). ``normalized_text`` may have
|
||||
already been run through ``normalize_for_pipeline``, which can remove
|
||||
punctuation and prevent overrides from triggering.
|
||||
"""
|
||||
|
||||
if not isinstance(entry, Mapping):
|
||||
return ""
|
||||
return str(
|
||||
entry.get("text")
|
||||
or entry.get("original_text")
|
||||
or entry.get("normalized_text")
|
||||
or ""
|
||||
).strip()
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform as _platform
|
||||
|
||||
|
||||
def select_device() -> str:
|
||||
"""Return the best available compute device (``"mps"``, ``"cuda"``, or ``"cpu"``).
|
||||
|
||||
Checks ``torch`` availability at runtime so this can be called from
|
||||
any context without requiring torch at import time.
|
||||
"""
|
||||
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(): # type: ignore[union-attr]
|
||||
return "mps"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
try:
|
||||
if torch.cuda.is_available(): # type: ignore[union-attr]
|
||||
return "cuda"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from abogen.text_extractor import ExtractedChapter
|
||||
|
||||
|
||||
_SIGNIFICANT_LENGTH_THRESHOLDS: Dict[str, int] = {"epub": 1000, "markdown": 500}
|
||||
_MIN_SHORT_CONTENT: Dict[str, int] = {"epub": 240, "markdown": 160}
|
||||
_STRUCTURAL_KEYWORDS = (
|
||||
"preface",
|
||||
"prologue",
|
||||
"introduction",
|
||||
"foreword",
|
||||
"epilogue",
|
||||
"afterword",
|
||||
"appendix",
|
||||
"acknowledgment",
|
||||
"acknowledgement",
|
||||
)
|
||||
_STRUCTURAL_MIN_LENGTH = 120
|
||||
_MAX_SHORT_CHAPTERS = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChapterFilterResult:
|
||||
kept: List[ExtractedChapter]
|
||||
skipped: List[Tuple[str, int]]
|
||||
|
||||
|
||||
def infer_file_type(path: Path) -> str:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".epub":
|
||||
return "epub"
|
||||
if suffix in {".md", ".markdown"}:
|
||||
return "markdown"
|
||||
if suffix == ".pdf":
|
||||
return "pdf"
|
||||
if suffix == ".txt":
|
||||
return "text"
|
||||
return suffix.lstrip(".") or "text"
|
||||
|
||||
|
||||
def looks_structural(title: str) -> bool:
|
||||
lowered = title.strip().lower()
|
||||
if not lowered:
|
||||
return False
|
||||
return any(keyword in lowered for keyword in _STRUCTURAL_KEYWORDS)
|
||||
|
||||
|
||||
def chapter_label(file_type: str) -> str:
|
||||
return "chapters" if file_type.lower() in {"epub", "markdown"} else "pages"
|
||||
|
||||
|
||||
def auto_select_relevant_chapters(
|
||||
chapters: List[ExtractedChapter],
|
||||
file_type: str,
|
||||
) -> ChapterFilterResult:
|
||||
if not chapters:
|
||||
return ChapterFilterResult(kept=[], skipped=[])
|
||||
|
||||
normalized = file_type.lower()
|
||||
threshold = _SIGNIFICANT_LENGTH_THRESHOLDS.get(normalized, 0)
|
||||
min_short = _MIN_SHORT_CONTENT.get(normalized, 0)
|
||||
|
||||
kept: List[ExtractedChapter] = []
|
||||
skipped: List[Tuple[str, int]] = []
|
||||
short_kept = 0
|
||||
|
||||
for chapter in chapters:
|
||||
stripped = chapter.text.strip()
|
||||
length = len(stripped)
|
||||
if length == 0:
|
||||
skipped.append((chapter.title, length))
|
||||
continue
|
||||
|
||||
keep = False
|
||||
if threshold == 0:
|
||||
keep = True
|
||||
elif length >= threshold:
|
||||
keep = True
|
||||
elif not kept:
|
||||
keep = True
|
||||
elif min_short and length >= min_short and short_kept < _MAX_SHORT_CHAPTERS:
|
||||
keep = True
|
||||
short_kept += 1
|
||||
elif looks_structural(chapter.title) and length >= _STRUCTURAL_MIN_LENGTH:
|
||||
keep = True
|
||||
|
||||
if keep:
|
||||
kept.append(chapter)
|
||||
else:
|
||||
skipped.append((chapter.title, length))
|
||||
|
||||
if kept:
|
||||
return ChapterFilterResult(kept=kept, skipped=skipped)
|
||||
|
||||
longest_idx = None
|
||||
longest_length = 0
|
||||
for idx, chapter in enumerate(chapters):
|
||||
stripped = chapter.text.strip()
|
||||
if stripped and len(stripped) > longest_length:
|
||||
longest_length = len(stripped)
|
||||
longest_idx = idx
|
||||
|
||||
if longest_idx is not None:
|
||||
longest = chapters[longest_idx]
|
||||
fallback_skipped = [
|
||||
(chapter.title, len(chapter.text.strip()))
|
||||
for idx, chapter in enumerate(chapters)
|
||||
if idx != longest_idx and chapter.text.strip()
|
||||
]
|
||||
return ChapterFilterResult(kept=[longest], skipped=fallback_skipped)
|
||||
|
||||
return ChapterFilterResult(kept=[], skipped=skipped)
|
||||
|
||||
|
||||
def update_metadata_for_chapter_count(
|
||||
metadata: Dict[str, Any], count: int, file_type: str
|
||||
) -> None:
|
||||
if not metadata or count <= 0:
|
||||
return
|
||||
|
||||
label = "Chapters" if file_type.lower() in {"epub", "markdown"} else "Pages"
|
||||
metadata["chapter_count"] = str(count)
|
||||
|
||||
pattern = re.compile(r"\(\d+\s+(Chapters?|Pages?)\)")
|
||||
replacement = f"({count} {label})"
|
||||
for key in ("album", "ALBUM"):
|
||||
value = metadata.get(key)
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
metadata[key] = pattern.sub(replacement, value)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Metadata extraction and processing utilities.
|
||||
|
||||
This module provides functions for extracting metadata from text content
|
||||
and generating ffmpeg metadata arguments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
def extract_metadata_from_text(text: str) -> Dict[str, Optional[str]]:
|
||||
"""Extract metadata tags from text content.
|
||||
|
||||
Looks for tags in format: <<METADATA_KEY:value>>
|
||||
|
||||
Supported tags:
|
||||
- TITLE, ARTIST, ALBUM, YEAR
|
||||
- ALBUM_ARTIST, COMPOSER, GENRE
|
||||
- COVER_PATH
|
||||
|
||||
Args:
|
||||
text: Text content to search for metadata tags.
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted metadata values (None if not found).
|
||||
"""
|
||||
metadata = {}
|
||||
|
||||
patterns = {
|
||||
"title": r"<<METADATA_TITLE:([^>]*)>>",
|
||||
"artist": r"<<METADATA_ARTIST:([^>]*)>>",
|
||||
"album": r"<<METADATA_ALBUM:([^>]*)>>",
|
||||
"year": r"<<METADATA_YEAR:([^>]*)>>",
|
||||
"album_artist": r"<<METADATA_ALBUM_ARTIST:([^>]*)>>",
|
||||
"composer": r"<<METADATA_COMPOSER:([^>]*)>>",
|
||||
"genre": r"<<METADATA_GENRE:([^>]*)>>",
|
||||
"cover_path": r"<<METADATA_COVER_PATH:([^>]*)>>",
|
||||
}
|
||||
|
||||
for key, pattern in patterns.items():
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
metadata[key] = match.group(1).strip()
|
||||
else:
|
||||
metadata[key] = None
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def get_filename_from_path(
|
||||
file_path: str,
|
||||
display_path: Optional[str] = None,
|
||||
from_queue: bool = False,
|
||||
) -> str:
|
||||
"""Extract filename (without extension) from path.
|
||||
|
||||
Args:
|
||||
file_path: The file path to extract from.
|
||||
display_path: Optional display path (used if from_queue is False).
|
||||
from_queue: Whether the file is from queue.
|
||||
|
||||
Returns:
|
||||
Filename without extension.
|
||||
"""
|
||||
if from_queue:
|
||||
base_path = file_path
|
||||
else:
|
||||
base_path = display_path if display_path else file_path
|
||||
|
||||
filename = os.path.splitext(os.path.basename(base_path))[0]
|
||||
return filename
|
||||
|
||||
|
||||
def build_ffmpeg_metadata_args(
|
||||
metadata: Dict[str, Optional[str]],
|
||||
filename: str,
|
||||
) -> List[str]:
|
||||
"""Build ffmpeg metadata arguments from metadata dictionary.
|
||||
|
||||
Args:
|
||||
metadata: Dictionary with metadata keys and values.
|
||||
filename: Fallback filename for title/album if not specified.
|
||||
|
||||
Returns:
|
||||
List of ffmpeg metadata arguments.
|
||||
"""
|
||||
args = []
|
||||
|
||||
# Default values
|
||||
defaults = {
|
||||
"title": filename,
|
||||
"artist": "Unknown",
|
||||
"album": filename,
|
||||
"date": str(datetime.datetime.now().year),
|
||||
"album_artist": "Unknown",
|
||||
"composer": "Narrator",
|
||||
"genre": "Audiobook",
|
||||
}
|
||||
|
||||
# Map of metadata keys to ffmpeg metadata keys
|
||||
key_mapping = {
|
||||
"title": "title",
|
||||
"artist": "artist",
|
||||
"album": "album",
|
||||
"year": "date", # year -> date for ffmpeg
|
||||
"album_artist": "album_artist",
|
||||
"composer": "composer",
|
||||
"genre": "genre",
|
||||
}
|
||||
|
||||
for metadata_key, ffmpeg_key in key_mapping.items():
|
||||
value = metadata.get(metadata_key)
|
||||
if value is None:
|
||||
value = defaults.get(metadata_key, "")
|
||||
if value:
|
||||
args.extend(["-metadata", f"{ffmpeg_key}={value}"])
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def extract_metadata_and_build_args(
|
||||
text: str,
|
||||
filename: str,
|
||||
display_path: Optional[str] = None,
|
||||
from_queue: bool = False,
|
||||
) -> Tuple[List[str], Optional[str]]:
|
||||
"""Extract metadata from text and build ffmpeg arguments.
|
||||
|
||||
Convenience function that combines extract_metadata_from_text and
|
||||
build_ffmpeg_metadata_args.
|
||||
|
||||
Args:
|
||||
text: Text content to search for metadata tags.
|
||||
filename: Fallback filename for title/album.
|
||||
display_path: Optional display path.
|
||||
from_queue: Whether the file is from queue.
|
||||
|
||||
Returns:
|
||||
Tuple of (ffmpeg_metadata_args, cover_path).
|
||||
"""
|
||||
metadata = extract_metadata_from_text(text)
|
||||
cover_path = metadata.get("cover_path")
|
||||
|
||||
# Get actual filename from path
|
||||
actual_filename = get_filename_from_path(
|
||||
file_path=filename,
|
||||
display_path=display_path,
|
||||
from_queue=from_queue,
|
||||
)
|
||||
|
||||
args = build_ffmpeg_metadata_args(metadata, actual_filename)
|
||||
return args, cover_path
|
||||
|
||||
|
||||
def read_text_for_metadata(
|
||||
file_path: str,
|
||||
is_direct_text: bool,
|
||||
direct_text: Optional[str] = None,
|
||||
encoding: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Read text content for metadata extraction.
|
||||
|
||||
Args:
|
||||
file_path: Path to file (or text if is_direct_text).
|
||||
is_direct_text: Whether file_path contains direct text.
|
||||
direct_text: Optional direct text (used if is_direct_text).
|
||||
encoding: File encoding (detected if not provided).
|
||||
|
||||
Returns:
|
||||
Text content for metadata extraction.
|
||||
"""
|
||||
if is_direct_text:
|
||||
return direct_text or file_path
|
||||
|
||||
# Read from file
|
||||
actual_path = direct_text if direct_text else file_path
|
||||
|
||||
try:
|
||||
if encoding is None:
|
||||
from abogen.utils import detect_encoding
|
||||
encoding = detect_encoding(actual_path)
|
||||
|
||||
with open(actual_path, "r", encoding=encoding, errors="replace") as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return ""
|
||||
@@ -0,0 +1,405 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||
|
||||
|
||||
_SERIES_NAME_KEYS = (
|
||||
"series",
|
||||
"series_name",
|
||||
"series_title",
|
||||
)
|
||||
_SERIES_NUMBER_KEYS = (
|
||||
"series_index",
|
||||
"series_position",
|
||||
"series_sequence",
|
||||
"book_number",
|
||||
"series_number",
|
||||
)
|
||||
_SERIES_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?")
|
||||
|
||||
|
||||
def normalize_metadata_map(values: Optional[Mapping[str, Any]]) -> Dict[str, str]:
|
||||
normalized: Dict[str, str] = {}
|
||||
if not values:
|
||||
return normalized
|
||||
for key, value in values.items():
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
continue
|
||||
normalized[str(key).casefold()] = text
|
||||
return normalized
|
||||
|
||||
|
||||
def format_author_sentence(raw: Optional[str]) -> str:
|
||||
if raw is None:
|
||||
return ""
|
||||
normalized = str(raw).strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
lowered = normalized.casefold()
|
||||
if lowered in {"unknown", "various"}:
|
||||
return ""
|
||||
|
||||
working = normalized.replace("&", " and ")
|
||||
segments = [segment.strip() for segment in working.split(",") if segment.strip()]
|
||||
tokens: List[str] = []
|
||||
|
||||
if segments:
|
||||
for segment in segments:
|
||||
parts = [part.strip() for part in re.split(r"\band\b", segment, flags=re.IGNORECASE) if part.strip()]
|
||||
if parts:
|
||||
tokens.extend(parts)
|
||||
else:
|
||||
tokens.append(segment)
|
||||
else:
|
||||
parts = [part.strip() for part in re.split(r"\band\b", working, flags=re.IGNORECASE) if part.strip()]
|
||||
tokens.extend(parts or [normalized])
|
||||
|
||||
cleaned = [token for token in tokens if token and token.casefold() not in {"unknown", "various"}]
|
||||
if not cleaned:
|
||||
return ""
|
||||
if len(cleaned) == 1:
|
||||
return f"By {cleaned[0]}"
|
||||
if len(cleaned) == 2:
|
||||
return f"By {cleaned[0]} and {cleaned[1]}"
|
||||
return f"By {', '.join(cleaned[:-1])}, and {cleaned[-1]}"
|
||||
|
||||
|
||||
def ensure_sentence(text: str) -> str:
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
return ""
|
||||
if cleaned[-1] in ".!?":
|
||||
return cleaned
|
||||
return f"{cleaned}."
|
||||
|
||||
|
||||
def normalize_series_number(value: Any) -> Optional[str]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
candidate = text.replace(",", ".")
|
||||
if candidate.replace(".", "", 1).isdigit():
|
||||
if "." in candidate:
|
||||
normalized = candidate.rstrip("0").rstrip(".")
|
||||
return normalized or "0"
|
||||
try:
|
||||
return str(int(candidate))
|
||||
except ValueError:
|
||||
pass
|
||||
match = _SERIES_NUMBER_RE.search(candidate)
|
||||
if not match:
|
||||
return None
|
||||
normalized = match.group(0)
|
||||
if "." in normalized:
|
||||
normalized = normalized.rstrip("0").rstrip(".")
|
||||
return normalized or "0"
|
||||
try:
|
||||
return str(int(normalized))
|
||||
except ValueError:
|
||||
return normalized
|
||||
|
||||
|
||||
def extract_series_metadata(values: Mapping[str, str]) -> Tuple[Optional[str], Optional[str]]:
|
||||
series_name: Optional[str] = None
|
||||
for key in _SERIES_NAME_KEYS:
|
||||
raw = values.get(key)
|
||||
if raw:
|
||||
cleaned = str(raw).strip()
|
||||
if cleaned:
|
||||
series_name = cleaned
|
||||
break
|
||||
|
||||
series_number: Optional[str] = None
|
||||
for key in _SERIES_NUMBER_KEYS:
|
||||
raw = values.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
normalized = normalize_series_number(raw)
|
||||
if normalized:
|
||||
series_number = normalized
|
||||
break
|
||||
|
||||
return series_name, series_number
|
||||
|
||||
|
||||
def format_series_sentence(series_name: Optional[str], series_number: Optional[str]) -> str:
|
||||
if not series_name or not series_number:
|
||||
return ""
|
||||
name = series_name.strip()
|
||||
number = series_number.strip()
|
||||
if not name or not number:
|
||||
return ""
|
||||
article = "the " if not name.lower().startswith("the ") else ""
|
||||
phrase = f"Book {number} of {article}{name}"
|
||||
return re.sub(r"\s+", " ", phrase).strip()
|
||||
|
||||
|
||||
_PEOPLE_SPLIT_RE = re.compile(r"[;,/&]|\band\b", re.IGNORECASE)
|
||||
_LIST_SPLIT_RE = re.compile(r"[;,\n]")
|
||||
_SERIES_SEQUENCE_TAG_KEYS: Tuple[str, ...] = (
|
||||
"series_index",
|
||||
"series_position",
|
||||
"series_sequence",
|
||||
"series_number",
|
||||
"seriesnumber",
|
||||
"book_number",
|
||||
"booknumber",
|
||||
)
|
||||
|
||||
|
||||
def normalize_metadata_casefold(values: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
||||
normalized: Dict[str, Any] = {}
|
||||
if not values:
|
||||
return normalized
|
||||
for key, value in values.items():
|
||||
if value is None:
|
||||
continue
|
||||
key_text = str(key).strip().lower()
|
||||
if not key_text:
|
||||
continue
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
normalized[key_text] = value
|
||||
else:
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
normalized[key_text] = text
|
||||
return normalized
|
||||
|
||||
|
||||
def split_people_field(raw: Any) -> List[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple, set)):
|
||||
results: List[str] = []
|
||||
for item in raw:
|
||||
results.extend(split_people_field(item))
|
||||
return results
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
tokens = [_token.strip() for _token in _PEOPLE_SPLIT_RE.split(text) if _token.strip()]
|
||||
seen: set[str] = set()
|
||||
ordered: List[str] = []
|
||||
for token in tokens:
|
||||
key = token.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
ordered.append(token)
|
||||
return ordered
|
||||
|
||||
|
||||
def split_simple_list(raw: Any) -> List[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, (list, tuple, set)):
|
||||
results: List[str] = []
|
||||
for item in raw:
|
||||
results.extend(split_simple_list(item))
|
||||
return results
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
tokens = [_token.strip() for _token in _LIST_SPLIT_RE.split(text) if _token.strip()]
|
||||
seen: set[str] = set()
|
||||
ordered: List[str] = []
|
||||
for token in tokens:
|
||||
key = token.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
ordered.append(token)
|
||||
return ordered
|
||||
|
||||
|
||||
def first_nonempty(*values: Any) -> Optional[str]:
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
items = list(value)
|
||||
if not items:
|
||||
continue
|
||||
value = items[0]
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def extract_year(raw: Optional[str]) -> Optional[int]:
|
||||
if not raw:
|
||||
return None
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return None
|
||||
match = re.search(r"(19|20)\d{2}", text)
|
||||
if match:
|
||||
try:
|
||||
return int(match.group(0))
|
||||
except ValueError:
|
||||
return None
|
||||
try:
|
||||
parsed = int(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if 0 < parsed < 3000:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
def normalize_series_sequence(raw: Any) -> Optional[str]:
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, (int, float)):
|
||||
if isinstance(raw, float) and (math.isnan(raw) or math.isinf(raw)):
|
||||
return None
|
||||
text = str(raw)
|
||||
else:
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return None
|
||||
candidate = text.replace(",", ".")
|
||||
match = _SERIES_NUMBER_RE.search(candidate)
|
||||
if not match:
|
||||
return None
|
||||
normalized = match.group(0)
|
||||
if "." in normalized:
|
||||
normalized = normalized.rstrip("0").rstrip(".")
|
||||
if not normalized:
|
||||
normalized = "0"
|
||||
return normalized
|
||||
try:
|
||||
return str(int(normalized))
|
||||
except ValueError:
|
||||
cleaned = normalized.lstrip("0")
|
||||
return cleaned or "0"
|
||||
|
||||
|
||||
def build_audiobookshelf_metadata(
|
||||
tags: Mapping[str, Any],
|
||||
*,
|
||||
language: str = "",
|
||||
filename: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
normalized = normalize_metadata_casefold(tags)
|
||||
title = first_nonempty(
|
||||
normalized.get("title"),
|
||||
normalized.get("book_title"),
|
||||
normalized.get("name"),
|
||||
normalized.get("album"),
|
||||
filename,
|
||||
)
|
||||
authors = split_people_field(
|
||||
normalized.get("authors")
|
||||
or normalized.get("author")
|
||||
or normalized.get("album_artist")
|
||||
or normalized.get("artist")
|
||||
)
|
||||
narrators = split_people_field(normalized.get("narrators") or normalized.get("narrator"))
|
||||
description = first_nonempty(
|
||||
normalized.get("description"), normalized.get("summary"), normalized.get("comment")
|
||||
)
|
||||
genres = split_simple_list(normalized.get("genre"))
|
||||
keywords = split_simple_list(normalized.get("tags") or normalized.get("keywords"))
|
||||
lang = first_nonempty(normalized.get("language"), normalized.get("lang")) or language or ""
|
||||
series_name = first_nonempty(
|
||||
normalized.get("series"),
|
||||
normalized.get("series_name"),
|
||||
normalized.get("seriesname"),
|
||||
normalized.get("series_title"),
|
||||
normalized.get("seriestitle"),
|
||||
)
|
||||
|
||||
series_sequence = None
|
||||
for key in _SERIES_SEQUENCE_TAG_KEYS:
|
||||
raw_value = normalized.get(key)
|
||||
seq = normalize_series_sequence(raw_value)
|
||||
if seq:
|
||||
series_sequence = seq
|
||||
break
|
||||
if not series_name:
|
||||
series_sequence = None
|
||||
|
||||
data: Dict[str, Any] = {
|
||||
"title": title,
|
||||
"subtitle": normalized.get("subtitle"),
|
||||
"authors": authors,
|
||||
"narrators": narrators,
|
||||
"description": description,
|
||||
"publisher": normalized.get("publisher"),
|
||||
"genres": genres,
|
||||
"tags": keywords,
|
||||
"language": lang,
|
||||
"publishedYear": extract_year(
|
||||
normalized.get("published")
|
||||
or normalized.get("publication_year")
|
||||
or normalized.get("date")
|
||||
or normalized.get("year")
|
||||
),
|
||||
"seriesName": series_name,
|
||||
"seriesSequence": series_sequence,
|
||||
"isbn": first_nonempty(normalized.get("isbn"), normalized.get("asin")),
|
||||
}
|
||||
published_date = first_nonempty(
|
||||
normalized.get("published"), normalized.get("publication_date"), normalized.get("date")
|
||||
)
|
||||
if published_date:
|
||||
data["publishedDate"] = published_date
|
||||
|
||||
rating_text = first_nonempty(normalized.get("rating"), normalized.get("my_rating"))
|
||||
if rating_text:
|
||||
try:
|
||||
data["rating"] = float(str(rating_text).strip())
|
||||
except ValueError:
|
||||
pass
|
||||
rating_max_text = first_nonempty(
|
||||
normalized.get("rating_max"), normalized.get("rating_scale")
|
||||
)
|
||||
if rating_max_text:
|
||||
try:
|
||||
data["ratingMax"] = float(str(rating_max_text).strip())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
cleaned: Dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
continue
|
||||
if isinstance(value, (list, tuple)) and not value:
|
||||
continue
|
||||
cleaned[key] = value
|
||||
return cleaned
|
||||
|
||||
|
||||
def load_audiobookshelf_chapters(
|
||||
metadata_path: Path,
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
if not metadata_path.exists():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
chapters = payload.get("chapters")
|
||||
if not isinstance(chapters, list):
|
||||
return None
|
||||
cleaned: List[Dict[str, Any]] = []
|
||||
for entry in chapters:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
title = first_nonempty(entry.get("title"), entry.get("original_title"))
|
||||
start = entry.get("start")
|
||||
end = entry.get("end")
|
||||
if title and start is not None and end is not None:
|
||||
cleaned.append({"title": str(title), "start": start, "end": end})
|
||||
return cleaned or None
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def merge_metadata(
|
||||
extracted: Optional[Dict[str, Any]],
|
||||
overrides: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, str]:
|
||||
merged: Dict[str, str] = {}
|
||||
if extracted:
|
||||
for key, value in extracted.items():
|
||||
if value is None:
|
||||
continue
|
||||
merged[str(key)] = str(value)
|
||||
if overrides:
|
||||
for key, value in overrides.items():
|
||||
key_str = str(key)
|
||||
if value is None:
|
||||
merged.pop(key_str, None)
|
||||
else:
|
||||
merged[key_str] = str(value)
|
||||
return merged
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Text normalization convenience helpers.
|
||||
|
||||
Provides both the simple ``normalize_text_for_pipeline`` (apostrophe + LLM only)
|
||||
and the comprehensive ``prepare_text_for_tts`` that chains all three normalization
|
||||
stages used during conversion: heteronym rules → pronunciation rules → pipeline
|
||||
normalization. The latter is the single entry point that both the Web UI and
|
||||
PyQt Desktop GUI should use.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
|
||||
from abogen.kokoro_text_normalization import (
|
||||
ApostropheConfig,
|
||||
normalize_for_pipeline as _normalize_for_pipeline,
|
||||
)
|
||||
from abogen.normalization_settings import (
|
||||
build_apostrophe_config,
|
||||
get_runtime_settings,
|
||||
apply_overrides as _apply_overrides,
|
||||
)
|
||||
|
||||
_BASE_APOSTROPHE_CONFIG = ApostropheConfig()
|
||||
|
||||
|
||||
def normalize_text_for_pipeline(
|
||||
text: str,
|
||||
*,
|
||||
normalization_overrides: Optional[Mapping[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Normalize text using runtime settings with optional overrides."""
|
||||
runtime_settings = get_runtime_settings()
|
||||
if normalization_overrides:
|
||||
runtime_settings = _apply_overrides(runtime_settings, normalization_overrides)
|
||||
apostrophe_config = build_apostrophe_config(settings=runtime_settings, base=_BASE_APOSTROPHE_CONFIG)
|
||||
return _normalize_for_pipeline(text, config=apostrophe_config, settings=runtime_settings)
|
||||
|
||||
|
||||
def prepare_text_for_tts(
|
||||
text: str,
|
||||
*,
|
||||
heteronym_rules: Optional[List[Dict[str, Any]]] = None,
|
||||
pronunciation_rules: Optional[List[Dict[str, Any]]] = None,
|
||||
normalization_overrides: Optional[Mapping[str, Any]] = None,
|
||||
usage_counter: Optional[Dict[str, int]] = None,
|
||||
) -> str:
|
||||
"""Apply the full text normalization pipeline before TTS synthesis.
|
||||
|
||||
Chains three stages in order:
|
||||
1. Heteronym sentence rules (context-dependent pronunciation)
|
||||
2. Pronunciation rules (token-level replacements)
|
||||
3. Pipeline normalization (apostrophe handling, LLM normalization)
|
||||
|
||||
This is the **single entry point** that both the Web UI conversion runner
|
||||
and the PyQt conversion thread should call before passing text to the TTS
|
||||
backend.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text:
|
||||
Raw text to normalize.
|
||||
heteronym_rules:
|
||||
Compiled heteronym rules from ``compile_heteronym_sentence_rules``.
|
||||
pronunciation_rules:
|
||||
Compiled pronunciation rules from ``compile_pronunciation_rules``.
|
||||
normalization_overrides:
|
||||
User-level overrides for normalization settings (apostrophe mode, etc.).
|
||||
usage_counter:
|
||||
Mutable dict that tracks how many times each pronunciation override was
|
||||
applied. Passed through to ``apply_pronunciation_rules``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Fully normalized text ready for TTS.
|
||||
"""
|
||||
from abogen.domain.pronunciation import (
|
||||
apply_heteronym_sentence_rules,
|
||||
apply_pronunciation_rules,
|
||||
)
|
||||
|
||||
result = str(text or "")
|
||||
|
||||
if heteronym_rules:
|
||||
result = apply_heteronym_sentence_rules(result, heteronym_rules)
|
||||
|
||||
if pronunciation_rules:
|
||||
result = apply_pronunciation_rules(result, pronunciation_rules, usage_counter)
|
||||
|
||||
runtime_settings = get_runtime_settings()
|
||||
if normalization_overrides:
|
||||
runtime_settings = _apply_overrides(runtime_settings, normalization_overrides)
|
||||
apostrophe_config = build_apostrophe_config(settings=runtime_settings, base=_BASE_APOSTROPHE_CONFIG)
|
||||
|
||||
return _normalize_for_pipeline(result, config=apostrophe_config, settings=runtime_settings)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Output path resolution utilities.
|
||||
|
||||
Pure functions for resolving output directories, building file paths,
|
||||
and computing project folder layouts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, List, Optional, Tuple
|
||||
|
||||
from abogen.text_extractor import ExtractedChapter
|
||||
|
||||
|
||||
_OUTPUT_SANITIZE_RE = re.compile(r"[^\w\-_.]+")
|
||||
|
||||
|
||||
def slugify(title: str, index: int) -> str:
|
||||
sanitized = re.sub(r"[^\w\-]+", "_", title.lower()).strip("_")
|
||||
if not sanitized:
|
||||
sanitized = f"chapter_{index:02d}"
|
||||
return sanitized[:80]
|
||||
|
||||
|
||||
def sanitize_output_stem(name: str) -> str:
|
||||
base = Path(name or "").stem
|
||||
sanitized = _OUTPUT_SANITIZE_RE.sub("_", base).strip("_")
|
||||
return sanitized or "output"
|
||||
|
||||
|
||||
def output_timestamp_token() -> str:
|
||||
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
|
||||
def build_output_path(directory: Path, original_name: str, extension: str) -> Path:
|
||||
sanitized = sanitize_output_stem(original_name)
|
||||
return directory / f"{sanitized}.{extension}"
|
||||
|
||||
|
||||
def apply_newline_policy(chapters: List[ExtractedChapter], replace_single_newlines: bool) -> None:
|
||||
if not replace_single_newlines:
|
||||
return
|
||||
newline_regex = re.compile(r"(?<!\n)\n(?!\n)")
|
||||
for chapter in chapters:
|
||||
chapter.text = newline_regex.sub(" ", chapter.text)
|
||||
|
||||
|
||||
def resolve_output_directory(
|
||||
*,
|
||||
save_mode: str,
|
||||
stored_path: Path,
|
||||
output_folder: Optional[str],
|
||||
desktop_dir: Optional[Path],
|
||||
user_output_path: Optional[Path],
|
||||
user_cache_outputs: Optional[Path],
|
||||
) -> Path:
|
||||
if save_mode == "Save to Desktop" and desktop_dir:
|
||||
return desktop_dir
|
||||
if save_mode == "Save next to input file":
|
||||
return stored_path.parent
|
||||
if save_mode == "Choose output folder" and output_folder:
|
||||
return Path(output_folder)
|
||||
if save_mode == "Use default save location" and user_output_path:
|
||||
return user_output_path
|
||||
return user_cache_outputs or Path(".")
|
||||
|
||||
|
||||
def resolve_project_layout(
|
||||
*,
|
||||
original_filename: str,
|
||||
save_as_project: bool,
|
||||
base_dir: Path,
|
||||
timestamp_fn: Callable[[], str] = output_timestamp_token,
|
||||
sanitize_fn: Callable[[str, int], str] = sanitize_output_stem,
|
||||
) -> Tuple[Path, Path, Path, Optional[Path]]:
|
||||
sanitized = sanitize_fn(original_filename, 0)
|
||||
folder_name = f"{timestamp_fn()}_{sanitized}"
|
||||
project_root = base_dir / folder_name
|
||||
project_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if save_as_project:
|
||||
audio_dir = project_root / "audio"
|
||||
subtitle_dir = project_root / "subtitles"
|
||||
metadata_dir = project_root / "metadata"
|
||||
for directory in (audio_dir, subtitle_dir, metadata_dir):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return project_root, audio_dir, subtitle_dir, metadata_dir
|
||||
|
||||
return project_root, project_root, project_root, None
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Progress and ETR (estimated time remaining) calculation.
|
||||
|
||||
Shared by Web UI and PyQt desktop GUI. Pure math, no UI dependencies.
|
||||
"""
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProgressTracker:
|
||||
"""Tracks character-based progress with ETR calculation.
|
||||
|
||||
Usage:
|
||||
tracker = ProgressTracker(total_chars=50000)
|
||||
# ... as processing occurs:
|
||||
tracker.update(chars_done=5000)
|
||||
print(tracker.etr_str) # "00:04:30"
|
||||
print(tracker.percent) # 10
|
||||
"""
|
||||
total_chars: int
|
||||
_start_time: float = field(default_factory=time.time, repr=False)
|
||||
_chars_done: int = field(default=0, repr=False)
|
||||
|
||||
def update(self, chars_done: int) -> None:
|
||||
self._chars_done = chars_done
|
||||
|
||||
@property
|
||||
def percent(self) -> int:
|
||||
if self.total_chars <= 0:
|
||||
return 0
|
||||
return min(int(self._chars_done / self.total_chars * 100), 99)
|
||||
|
||||
@property
|
||||
def etr_str(self) -> str:
|
||||
elapsed = time.time() - self._start_time
|
||||
if self._chars_done <= 0 or elapsed <= 0.5:
|
||||
return "Processing..."
|
||||
avg_time_per_char = elapsed / self._chars_done
|
||||
remaining = self.total_chars - self._chars_done
|
||||
if remaining <= 0:
|
||||
return "00:00:00"
|
||||
secs = avg_time_per_char * remaining
|
||||
h = int(secs // 3600)
|
||||
m = int((secs % 3600) // 60)
|
||||
s = int(secs % 60)
|
||||
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||
|
||||
|
||||
def calc_etr_str(elapsed: float, done: int, total: int) -> str:
|
||||
"""Standalone ETR string calculation (matches PyQt original logic).
|
||||
|
||||
Args:
|
||||
elapsed: seconds since processing started
|
||||
done: items/characters processed so far
|
||||
total: total items/characters to process
|
||||
|
||||
Returns:
|
||||
ETR string like "01:23:45" or "Processing..."
|
||||
"""
|
||||
if done <= 0 or elapsed <= 0.5:
|
||||
return "Processing..."
|
||||
avg_time_per_item = elapsed / done
|
||||
remaining = total - done
|
||||
if remaining <= 0:
|
||||
return "00:00:00"
|
||||
secs = avg_time_per_item * remaining
|
||||
h = int(secs // 3600)
|
||||
m = int((secs % 3600) // 60)
|
||||
s = int(secs % 60)
|
||||
return f"{h:02d}:{m:02d}:{s:02d}"
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Pronunciation rule compilation and application.
|
||||
|
||||
Pure functions for compiling token-level and sentence-level pronunciation
|
||||
overrides into regex patterns, applying them to text, and merging multiple
|
||||
override sources with precedence rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from abogen.entity_analysis import normalize_token as normalize_entity_token
|
||||
from abogen.entity_analysis import normalize_manual_override_token
|
||||
|
||||
|
||||
def compile_pronunciation_rules(
|
||||
overrides: Optional[Iterable[Mapping[str, Any]]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
if not overrides:
|
||||
return []
|
||||
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for entry in overrides:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
pronunciation_value = str(entry.get("pronunciation") or "").strip()
|
||||
if not pronunciation_value:
|
||||
continue
|
||||
|
||||
token_values: List[str] = []
|
||||
token_raw = entry.get("token")
|
||||
if token_raw:
|
||||
token_value = str(token_raw).strip()
|
||||
if token_value:
|
||||
token_values.append(token_value)
|
||||
normalized_raw = entry.get("normalized")
|
||||
if normalized_raw:
|
||||
normalized_value = str(normalized_raw).strip()
|
||||
if normalized_value:
|
||||
token_values.append(normalized_value)
|
||||
if token_raw and not token_values:
|
||||
fallback = normalize_entity_token(str(token_raw))
|
||||
if fallback:
|
||||
token_values.append(fallback)
|
||||
|
||||
if not token_values:
|
||||
continue
|
||||
|
||||
usage_normalized = str(entry.get("normalized") or "").strip()
|
||||
if not usage_normalized and token_values:
|
||||
usage_normalized = normalize_entity_token(token_values[0]) or token_values[0]
|
||||
usage_token = str(entry.get("token") or token_values[0])
|
||||
|
||||
for token_value in token_values:
|
||||
key = token_value.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
candidates.append(
|
||||
{
|
||||
"token": token_value,
|
||||
"normalized": usage_normalized,
|
||||
"replacement": pronunciation_value,
|
||||
}
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
candidates.sort(key=lambda item: len(item["token"]), reverse=True)
|
||||
compiled: List[Dict[str, Any]] = []
|
||||
for candidate in candidates:
|
||||
token_value = candidate["token"]
|
||||
pronunciation_value = candidate["replacement"]
|
||||
escaped = re.escape(token_value)
|
||||
pattern = re.compile(rf"(?i)(?<!\w){escaped}(?P<possessive>'s|\u2019s|\u2019)?(?!\w)")
|
||||
compiled.append(
|
||||
{
|
||||
"pattern": pattern,
|
||||
"replacement": pronunciation_value,
|
||||
"normalized": candidate.get("normalized") or token_value,
|
||||
"token": candidate.get("token") or token_value,
|
||||
}
|
||||
)
|
||||
|
||||
return compiled
|
||||
|
||||
|
||||
def compile_heteronym_sentence_rules(
|
||||
overrides: Optional[Iterable[Mapping[str, Any]]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
if not overrides:
|
||||
return []
|
||||
|
||||
compiled: List[Dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for entry in overrides:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
sentence = str(entry.get("sentence") or "").strip()
|
||||
if not sentence:
|
||||
continue
|
||||
choice = str(entry.get("choice") or "").strip()
|
||||
if not choice:
|
||||
continue
|
||||
|
||||
replacement_sentence = ""
|
||||
options = entry.get("options")
|
||||
if isinstance(options, list):
|
||||
for opt in options:
|
||||
if not isinstance(opt, Mapping):
|
||||
continue
|
||||
if str(opt.get("key") or "").strip() == choice:
|
||||
replacement_sentence = str(opt.get("replacement_sentence") or "").strip()
|
||||
break
|
||||
if not replacement_sentence:
|
||||
continue
|
||||
|
||||
rule_key = f"{sentence}\n{choice}".casefold()
|
||||
if rule_key in seen:
|
||||
continue
|
||||
seen.add(rule_key)
|
||||
|
||||
parts = [p for p in re.split(r"\s+", sentence) if p]
|
||||
if not parts:
|
||||
continue
|
||||
pattern_text = r"\s+".join(re.escape(p) for p in parts)
|
||||
pattern = re.compile(pattern_text)
|
||||
compiled.append({"pattern": pattern, "replacement": replacement_sentence})
|
||||
|
||||
compiled.sort(key=lambda item: len(item["pattern"].pattern), reverse=True)
|
||||
return compiled
|
||||
|
||||
|
||||
def apply_heteronym_sentence_rules(text: str, rules: List[Dict[str, Any]]) -> str:
|
||||
if not text or not rules:
|
||||
return text
|
||||
result = text
|
||||
for rule in rules:
|
||||
pattern = rule["pattern"]
|
||||
replacement = rule["replacement"]
|
||||
result = pattern.sub(replacement, result)
|
||||
return result
|
||||
|
||||
|
||||
def apply_pronunciation_rules(
|
||||
text: str,
|
||||
rules: List[Dict[str, Any]],
|
||||
usage_counter: Optional[Dict[str, int]] = None,
|
||||
) -> str:
|
||||
if not text or not rules:
|
||||
return text
|
||||
|
||||
result = text
|
||||
for rule in rules:
|
||||
pattern = rule["pattern"]
|
||||
pronunciation_value = rule["replacement"]
|
||||
usage_key = str(rule.get("normalized") or "").strip()
|
||||
|
||||
def _replacement(match: re.Match[str]) -> str:
|
||||
suffix = match.group("possessive") or ""
|
||||
if usage_counter is not None and usage_key:
|
||||
usage_counter[usage_key] = usage_counter.get(usage_key, 0) + 1
|
||||
return pronunciation_value + suffix
|
||||
|
||||
result = pattern.sub(_replacement, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def merge_pronunciation_overrides(job: Any) -> List[Dict[str, Any]]:
|
||||
"""Return pronunciation override entries, ensuring manual overrides are included.
|
||||
|
||||
Pending jobs keep both ``manual_overrides`` and ``pronunciation_overrides``, but the
|
||||
latter can be stale if the UI didn't resync before enqueue. During conversion,
|
||||
we must merge manual overrides so they always apply (before TTS).
|
||||
|
||||
Precedence: manual overrides win over existing entries for the same normalized key.
|
||||
"""
|
||||
|
||||
collected: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
existing = getattr(job, "pronunciation_overrides", None)
|
||||
if isinstance(existing, list):
|
||||
for entry in existing:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
token_value = str(entry.get("token") or "").strip()
|
||||
pronunciation_value = str(entry.get("pronunciation") or "").strip()
|
||||
if not token_value or not pronunciation_value:
|
||||
continue
|
||||
normalized = str(entry.get("normalized") or "").strip() or normalize_entity_token(token_value)
|
||||
if not normalized:
|
||||
continue
|
||||
collected[normalized] = {
|
||||
"token": token_value,
|
||||
"normalized": normalized,
|
||||
"pronunciation": pronunciation_value,
|
||||
"voice": str(entry.get("voice") or "").strip() or None,
|
||||
"notes": str(entry.get("notes") or "").strip() or None,
|
||||
"context": str(entry.get("context") or "").strip() or None,
|
||||
"source": str(entry.get("source") or "pronunciation"),
|
||||
"language": getattr(job, "language", None),
|
||||
}
|
||||
|
||||
speakers = getattr(job, "speakers", None)
|
||||
if isinstance(speakers, dict):
|
||||
for payload in speakers.values():
|
||||
if not isinstance(payload, Mapping):
|
||||
continue
|
||||
token_value = str(payload.get("token") or "").strip()
|
||||
pronunciation_value = str(payload.get("pronunciation") or "").strip()
|
||||
if not token_value or not pronunciation_value:
|
||||
continue
|
||||
normalized = normalize_entity_token(token_value)
|
||||
if not normalized:
|
||||
continue
|
||||
collected[normalized] = {
|
||||
"token": token_value,
|
||||
"normalized": normalized,
|
||||
"pronunciation": pronunciation_value,
|
||||
"voice": str(
|
||||
payload.get("resolved_voice")
|
||||
or payload.get("voice")
|
||||
or getattr(job, "voice", "")
|
||||
).strip()
|
||||
or None,
|
||||
"notes": None,
|
||||
"context": None,
|
||||
"source": "speaker",
|
||||
"language": getattr(job, "language", None),
|
||||
}
|
||||
|
||||
manual = getattr(job, "manual_overrides", None)
|
||||
if isinstance(manual, list):
|
||||
for entry in manual:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
token_value = str(entry.get("token") or "").strip()
|
||||
pronunciation_value = str(entry.get("pronunciation") or "").strip()
|
||||
if not token_value or not pronunciation_value:
|
||||
continue
|
||||
normalized = str(entry.get("normalized") or "").strip() or normalize_manual_override_token(token_value)
|
||||
if not normalized:
|
||||
continue
|
||||
collected[normalized] = {
|
||||
"token": token_value,
|
||||
"normalized": normalized,
|
||||
"pronunciation": pronunciation_value,
|
||||
"voice": str(entry.get("voice") or "").strip() or None,
|
||||
"notes": str(entry.get("notes") or "").strip() or None,
|
||||
"context": str(entry.get("context") or "").strip() or None,
|
||||
"source": str(entry.get("source") or "manual"),
|
||||
"language": getattr(job, "language", None),
|
||||
}
|
||||
|
||||
return list(collected.values())
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Unified split pattern logic extracted from 3 copies."""
|
||||
import re
|
||||
|
||||
|
||||
PUNCTUATION_SENTENCE = r".!?。!?"
|
||||
PUNCTUATION_SENTENCE_COMMA = r".!?,。!?、,"
|
||||
|
||||
|
||||
def get_split_pattern(language: str, subtitle_mode: str) -> str:
|
||||
"""Get the appropriate split pattern based on language and subtitle mode.
|
||||
|
||||
Args:
|
||||
language: Language code (a, b, e, f, etc.)
|
||||
subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.)
|
||||
|
||||
Returns:
|
||||
Split pattern string
|
||||
"""
|
||||
# For English, always use newline splitting only
|
||||
if language in ("a", "b"):
|
||||
return "\n"
|
||||
|
||||
# Determine spacing pattern based on language
|
||||
spacing = r"\s*" if language in ("z", "j") else r"\s+"
|
||||
|
||||
# For CJK languages, when subtitle mode is Disabled or Line, prefer
|
||||
# punctuation-based splitting instead of plain newline splitting.
|
||||
if subtitle_mode in ("Disabled", "Line") and language in ("z", "j"):
|
||||
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
|
||||
|
||||
if subtitle_mode == "Line":
|
||||
return "\n"
|
||||
elif subtitle_mode == "Sentence":
|
||||
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
|
||||
elif subtitle_mode == "Sentence + Comma":
|
||||
return rf"(?<=[{PUNCTUATION_SENTENCE_COMMA}]){spacing}|\n+"
|
||||
else:
|
||||
return r"\n+"
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Subtitle generation utilities for audiobook generation.
|
||||
|
||||
This module provides functions for processing TTS tokens into subtitle entries
|
||||
according to various subtitle modes (Line, Sentence, Sentence + Comma,
|
||||
Sentence + Highlighting).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
# Punctuation constants for sentence splitting
|
||||
PUNCTUATION_SENTENCE = ".!?\u061f\u3002\uff01\uff1f" # .!? .?. ??
|
||||
PUNCTUATION_SENTENCE_COMMA = ".!?,\u3001\u061f\u3002\uff01\uff0c\uff1f" # .!?, ,. ??
|
||||
|
||||
|
||||
def process_subtitle_tokens(
|
||||
tokens_with_timestamps: List[dict],
|
||||
subtitle_entries: List[Tuple[float, float, str]],
|
||||
max_subtitle_words: int,
|
||||
subtitle_mode: str,
|
||||
lang_code: str,
|
||||
use_spacy_segmentation: bool = False,
|
||||
fallback_end_time: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Process TTS tokens into subtitle entries according to the subtitle mode.
|
||||
|
||||
This function modifies subtitle_entries in-place by appending new entries.
|
||||
|
||||
Args:
|
||||
tokens_with_timestamps: List of token dictionaries with 'start', 'end', 'text',
|
||||
and 'whitespace' keys.
|
||||
subtitle_entries: List to append subtitle entries to (modified in-place).
|
||||
Each entry is a tuple of (start_time, end_time, text).
|
||||
max_subtitle_words: Maximum number of words per subtitle entry.
|
||||
subtitle_mode: One of "Disabled", "Line", "Sentence", "Sentence + Comma",
|
||||
"Sentence + Highlighting", or a string like "5" for word-count mode.
|
||||
lang_code: Language code for spaCy processing (e.g., "a" for English).
|
||||
use_spacy_segmentation: Whether to use spaCy for sentence boundary detection.
|
||||
fallback_end_time: Fallback end time for the last entry if none is available.
|
||||
"""
|
||||
if not tokens_with_timestamps:
|
||||
return
|
||||
|
||||
processed_tokens = tokens_with_timestamps
|
||||
|
||||
# For English with spaCy enabled and sentence-based modes, use spaCy for sentence boundaries
|
||||
# spaCy is disabled when subtitle mode is "Disabled" or "Line"
|
||||
use_spacy_for_english = (
|
||||
use_spacy_segmentation
|
||||
and subtitle_mode not in ["Disabled", "Line"]
|
||||
and lang_code in ["a", "b"]
|
||||
and subtitle_mode in ["Sentence", "Sentence + Comma"]
|
||||
)
|
||||
|
||||
if subtitle_mode == "Sentence + Highlighting":
|
||||
_process_karaoke_highlighting(
|
||||
processed_tokens, subtitle_entries, max_subtitle_words, fallback_end_time
|
||||
)
|
||||
elif subtitle_mode in ["Sentence", "Sentence + Comma", "Line"]:
|
||||
if use_spacy_for_english and subtitle_mode != "Line":
|
||||
_process_spacy_sentences(
|
||||
processed_tokens, subtitle_entries, max_subtitle_words,
|
||||
subtitle_mode, lang_code, fallback_end_time
|
||||
)
|
||||
else:
|
||||
_process_regex_sentences(
|
||||
processed_tokens, subtitle_entries, max_subtitle_words,
|
||||
subtitle_mode, fallback_end_time
|
||||
)
|
||||
else:
|
||||
# Word count-based grouping (e.g., "5" for 5-word groups)
|
||||
_process_word_count(
|
||||
processed_tokens, subtitle_entries, max_subtitle_words,
|
||||
subtitle_mode, fallback_end_time
|
||||
)
|
||||
|
||||
|
||||
def _process_karaoke_highlighting(
|
||||
tokens: List[dict],
|
||||
subtitle_entries: List[Tuple[float, float, str]],
|
||||
max_subtitle_words: int,
|
||||
fallback_end_time: Optional[float],
|
||||
) -> None:
|
||||
"""Process tokens for Sentence + Highlighting mode (karaoke effect)."""
|
||||
separator = rf"[{re.escape(PUNCTUATION_SENTENCE)}]"
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
|
||||
for token in tokens:
|
||||
current_sentence.append(token)
|
||||
word_count += 1
|
||||
|
||||
# Split sentences based on separator or word count
|
||||
if (
|
||||
re.search(separator, token["text"]) and token.get("whitespace") == " "
|
||||
) or word_count >= max_subtitle_words:
|
||||
if current_sentence:
|
||||
# Create karaoke subtitle entry for this sentence
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
|
||||
# Generate karaoke text with timing
|
||||
karaoke_text = ""
|
||||
for t in current_sentence:
|
||||
# Calculate duration in centiseconds
|
||||
duration = (
|
||||
t["end"] - t["start"]
|
||||
if t.get("end") is not None and t.get("start") is not None
|
||||
else 0.5
|
||||
)
|
||||
duration_cs = int(duration * 100)
|
||||
# Add karaoke effect
|
||||
karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}"
|
||||
|
||||
subtitle_entries.append(
|
||||
(start_time, end_time, karaoke_text.strip())
|
||||
)
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
|
||||
# Add any remaining tokens as a sentence
|
||||
if current_sentence:
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
|
||||
# Generate karaoke text for remaining tokens
|
||||
karaoke_text = ""
|
||||
for t in current_sentence:
|
||||
duration = t["end"] - t["start"] if t.get("end") and t.get("start") else 0.5
|
||||
duration_cs = int(duration * 100)
|
||||
karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}"
|
||||
subtitle_entries.append((start_time, end_time, karaoke_text.strip()))
|
||||
|
||||
# Fallback for last entry
|
||||
_apply_fallback_end_time(subtitle_entries, fallback_end_time)
|
||||
|
||||
|
||||
def _process_spacy_sentences(
|
||||
tokens: List[dict],
|
||||
subtitle_entries: List[Tuple[float, float, str]],
|
||||
max_subtitle_words: int,
|
||||
subtitle_mode: str,
|
||||
lang_code: str,
|
||||
fallback_end_time: Optional[float],
|
||||
) -> None:
|
||||
"""Process tokens using spaCy for sentence boundary detection."""
|
||||
try:
|
||||
from abogen.spacy_utils import get_spacy_model
|
||||
except ImportError:
|
||||
# Fall back to regex if spaCy is not available
|
||||
_process_regex_sentences(
|
||||
tokens, subtitle_entries, max_subtitle_words,
|
||||
subtitle_mode, fallback_end_time
|
||||
)
|
||||
return
|
||||
|
||||
nlp = get_spacy_model(lang_code)
|
||||
if not nlp:
|
||||
_process_regex_sentences(
|
||||
tokens, subtitle_entries, max_subtitle_words,
|
||||
subtitle_mode, fallback_end_time
|
||||
)
|
||||
return
|
||||
|
||||
# Build full text and track character positions to token indices
|
||||
full_text = ""
|
||||
for token in tokens:
|
||||
text_part = token["text"] + (token.get("whitespace") or "")
|
||||
full_text += text_part
|
||||
|
||||
# Get sentence boundaries from spaCy
|
||||
doc = nlp(full_text)
|
||||
sentence_boundaries = [sent.end_char for sent in doc.sents]
|
||||
|
||||
# For "Sentence + Comma" mode, also split on commas
|
||||
if subtitle_mode == "Sentence + Comma":
|
||||
comma_positions = [
|
||||
i + 1 for i, c in enumerate(full_text) if c == ","
|
||||
]
|
||||
sentence_boundaries = sorted(
|
||||
set(sentence_boundaries + comma_positions)
|
||||
)
|
||||
|
||||
# Group tokens by sentence boundaries
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
current_char_pos = 0
|
||||
boundary_idx = 0
|
||||
|
||||
for token in tokens:
|
||||
current_sentence.append(token)
|
||||
word_count += 1
|
||||
text_len = len(token["text"]) + len(token.get("whitespace") or "")
|
||||
current_char_pos += text_len
|
||||
|
||||
# Check if we've hit a sentence boundary or max words
|
||||
at_boundary = (
|
||||
boundary_idx < len(sentence_boundaries)
|
||||
and current_char_pos >= sentence_boundaries[boundary_idx]
|
||||
)
|
||||
if at_boundary or word_count >= max_subtitle_words:
|
||||
if current_sentence:
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
sentence_text = "".join(
|
||||
t["text"] + (t.get("whitespace") or "")
|
||||
for t in current_sentence
|
||||
)
|
||||
subtitle_entries.append(
|
||||
(start_time, end_time, sentence_text.strip())
|
||||
)
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
if at_boundary:
|
||||
boundary_idx += 1
|
||||
|
||||
# Add remaining tokens
|
||||
if current_sentence:
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
sentence_text = "".join(
|
||||
t["text"] + (t.get("whitespace") or "")
|
||||
for t in current_sentence
|
||||
)
|
||||
subtitle_entries.append(
|
||||
(start_time, end_time, sentence_text.strip())
|
||||
)
|
||||
|
||||
# Fallback for last entry
|
||||
_apply_fallback_end_time(subtitle_entries, fallback_end_time)
|
||||
|
||||
|
||||
def _process_regex_sentences(
|
||||
tokens: List[dict],
|
||||
subtitle_entries: List[Tuple[float, float, str]],
|
||||
max_subtitle_words: int,
|
||||
subtitle_mode: str,
|
||||
fallback_end_time: Optional[float],
|
||||
) -> None:
|
||||
"""Process tokens using regex for sentence boundary detection."""
|
||||
# Define separator pattern based on mode
|
||||
if subtitle_mode == "Line":
|
||||
separator = r"\n"
|
||||
elif subtitle_mode == "Sentence":
|
||||
# Use punctuation without comma
|
||||
separator = rf"[{re.escape(PUNCTUATION_SENTENCE)}]"
|
||||
else: # Sentence + Comma
|
||||
# Use punctuation with comma
|
||||
separator = rf"[{re.escape(PUNCTUATION_SENTENCE_COMMA)}]"
|
||||
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
|
||||
for token in tokens:
|
||||
current_sentence.append(token)
|
||||
word_count += 1
|
||||
|
||||
# Split sentences based on separator or word count
|
||||
if (
|
||||
re.search(separator, token["text"]) and token.get("whitespace") == " "
|
||||
) or word_count >= max_subtitle_words:
|
||||
if current_sentence:
|
||||
# Create subtitle entry for this sentence
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
|
||||
# Simplified text joining logic
|
||||
sentence_text = ""
|
||||
for t in current_sentence:
|
||||
sentence_text += t["text"] + (t.get("whitespace") or "")
|
||||
|
||||
subtitle_entries.append(
|
||||
(start_time, end_time, sentence_text.strip())
|
||||
)
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
|
||||
# Add any remaining tokens as a sentence
|
||||
if current_sentence:
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
|
||||
# Simplified text joining logic
|
||||
sentence_text = ""
|
||||
for t in current_sentence:
|
||||
sentence_text += t["text"] + (t.get("whitespace") or "")
|
||||
subtitle_entries.append((start_time, end_time, sentence_text.strip()))
|
||||
|
||||
# Fallback for last entry
|
||||
_apply_fallback_end_time(subtitle_entries, fallback_end_time)
|
||||
|
||||
|
||||
def _process_word_count(
|
||||
tokens: List[dict],
|
||||
subtitle_entries: List[Tuple[float, float, str]],
|
||||
max_subtitle_words: int,
|
||||
subtitle_mode: str,
|
||||
fallback_end_time: Optional[float],
|
||||
) -> None:
|
||||
"""Process tokens by counting spaces (word count mode)."""
|
||||
try:
|
||||
word_count = int(subtitle_mode.split()[0])
|
||||
word_count = min(word_count, max_subtitle_words)
|
||||
except (ValueError, IndexError):
|
||||
word_count = 1
|
||||
|
||||
current_group = []
|
||||
space_count = 0
|
||||
|
||||
for token in tokens:
|
||||
current_group.append(token)
|
||||
|
||||
# Count spaces after tokens (in the whitespace field)
|
||||
if token.get("whitespace", "") == " ":
|
||||
space_count += 1
|
||||
|
||||
# Split after counting N spaces
|
||||
if space_count >= word_count:
|
||||
text = "".join(
|
||||
t["text"] + (t.get("whitespace") or "")
|
||||
for t in current_group
|
||||
)
|
||||
subtitle_entries.append(
|
||||
(
|
||||
current_group[0]["start"],
|
||||
current_group[-1]["end"],
|
||||
text.strip(),
|
||||
)
|
||||
)
|
||||
current_group = []
|
||||
space_count = 0
|
||||
|
||||
# Add any remaining tokens
|
||||
if current_group:
|
||||
text = "".join(
|
||||
t["text"] + (t.get("whitespace") or "") for t in current_group
|
||||
)
|
||||
subtitle_entries.append(
|
||||
(current_group[0]["start"], current_group[-1]["end"], text.strip())
|
||||
)
|
||||
|
||||
# Fallback for last entry
|
||||
_apply_fallback_end_time(subtitle_entries, fallback_end_time)
|
||||
|
||||
|
||||
def _apply_fallback_end_time(
|
||||
subtitle_entries: List[Tuple[float, float, str]],
|
||||
fallback_end_time: Optional[float],
|
||||
) -> None:
|
||||
"""Apply fallback end time to the last entry if needed."""
|
||||
if subtitle_entries and fallback_end_time is not None:
|
||||
last_entry = subtitle_entries[-1]
|
||||
start, end, text = last_entry
|
||||
if end is None or end <= start or end <= 0:
|
||||
subtitle_entries[-1] = (start, fallback_end_time, text)
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
|
||||
from .metadata_helpers import (
|
||||
ensure_sentence,
|
||||
extract_series_metadata,
|
||||
format_author_sentence,
|
||||
format_series_sentence,
|
||||
normalize_metadata_map,
|
||||
)
|
||||
|
||||
|
||||
def build_title_intro_text(
|
||||
metadata: Optional[Mapping[str, Any]],
|
||||
fallback_basename: str,
|
||||
) -> str:
|
||||
"""Build the title introduction text from metadata."""
|
||||
normalized = normalize_metadata_map(metadata)
|
||||
fallback_title = Path(fallback_basename).stem if fallback_basename else ""
|
||||
title = (
|
||||
normalized.get("title")
|
||||
or normalized.get("book_title")
|
||||
or normalized.get("album")
|
||||
or fallback_title
|
||||
)
|
||||
if not title:
|
||||
title = fallback_title
|
||||
subtitle = normalized.get("subtitle") or normalized.get("sub_title")
|
||||
if subtitle and title and subtitle.casefold() == title.casefold():
|
||||
subtitle = ""
|
||||
|
||||
author_value = ""
|
||||
for candidate in ("artist", "album_artist", "author", "authors", "writer", "composer"):
|
||||
value = normalized.get(candidate)
|
||||
if value:
|
||||
author_value = value
|
||||
break
|
||||
|
||||
series_name, series_number = extract_series_metadata(normalized)
|
||||
series_sentence = format_series_sentence(series_name, series_number)
|
||||
|
||||
sentences: List[str] = []
|
||||
if series_sentence:
|
||||
sentences.append(ensure_sentence(series_sentence))
|
||||
if title:
|
||||
sentences.append(ensure_sentence(title))
|
||||
if subtitle:
|
||||
sentences.append(ensure_sentence(subtitle))
|
||||
author_sentence = format_author_sentence(author_value)
|
||||
if author_sentence:
|
||||
sentences.append(ensure_sentence(author_sentence))
|
||||
return " ".join(sentences).strip()
|
||||
|
||||
|
||||
def build_outro_text(
|
||||
metadata: Optional[Mapping[str, Any]],
|
||||
fallback_basename: str,
|
||||
) -> str:
|
||||
"""Build the outro/closing text from metadata."""
|
||||
normalized = normalize_metadata_map(metadata)
|
||||
fallback_title = Path(fallback_basename).stem if fallback_basename else ""
|
||||
title = (
|
||||
normalized.get("title")
|
||||
or normalized.get("book_title")
|
||||
or normalized.get("album")
|
||||
or fallback_title
|
||||
)
|
||||
author_value = ""
|
||||
for candidate in ("authors", "author", "album_artist", "artist", "writer", "composer"):
|
||||
value = normalized.get(candidate)
|
||||
if value:
|
||||
author_value = value
|
||||
break
|
||||
author_sentence = format_author_sentence(author_value)
|
||||
authors_fragment = (
|
||||
author_sentence[3:].strip() if author_sentence.lower().startswith("by ") else author_sentence.strip()
|
||||
)
|
||||
|
||||
if title and authors_fragment:
|
||||
closing_line = f"The end of {title} from {authors_fragment}"
|
||||
elif title:
|
||||
closing_line = f"The end of {title}"
|
||||
elif authors_fragment:
|
||||
closing_line = f"The end from {authors_fragment}"
|
||||
else:
|
||||
closing_line = "The end"
|
||||
|
||||
series_name, series_number = extract_series_metadata(normalized)
|
||||
series_sentence = format_series_sentence(series_name, series_number)
|
||||
|
||||
sentences: List[str] = [ensure_sentence(closing_line)]
|
||||
if series_sentence:
|
||||
sentences.append(ensure_sentence(series_sentence))
|
||||
|
||||
return " ".join(sentence for sentence in sentences if sentence).strip()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Shared token stubs for TTS processing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class FakeToken:
|
||||
"""Minimal token stub for languages without per-word token support."""
|
||||
|
||||
def __init__(self, text: str, start: float, end: float):
|
||||
self.text = text
|
||||
self.start_ts = start
|
||||
self.end_ts = end
|
||||
self.whitespace = ""
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Voice loading and caching utilities.
|
||||
|
||||
This module provides unified voice loading with caching support for both
|
||||
PyQt and WebUI interfaces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
|
||||
|
||||
class VoiceCache:
|
||||
"""Thread-safe voice cache for loaded voice tensors."""
|
||||
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, Any] = {}
|
||||
|
||||
def get(self, voice_spec: str) -> Optional[Any]:
|
||||
"""Get cached voice by spec."""
|
||||
return self._cache.get(voice_spec)
|
||||
|
||||
def set(self, voice_spec: str, voice: Any) -> None:
|
||||
"""Cache a loaded voice."""
|
||||
self._cache[voice_spec] = voice
|
||||
|
||||
def contains(self, voice_spec: str) -> bool:
|
||||
"""Check if voice is in cache."""
|
||||
return voice_spec in self._cache
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cached voices."""
|
||||
self._cache.clear()
|
||||
|
||||
def __contains__(self, voice_spec: str) -> bool:
|
||||
return self.contains(voice_spec)
|
||||
|
||||
|
||||
def resolve_voice(
|
||||
voice_spec: str,
|
||||
pipeline: Any,
|
||||
use_gpu: bool,
|
||||
cache: Optional[VoiceCache] = None,
|
||||
) -> Any:
|
||||
"""Resolve voice spec to actual voice tensor or name.
|
||||
|
||||
If voice_spec contains '*' (formula), loads the voice using get_new_voice.
|
||||
Otherwise, returns the voice_spec as-is (it's a voice name).
|
||||
|
||||
Uses optional cache to avoid reloading same voice multiple times.
|
||||
|
||||
Args:
|
||||
voice_spec: Voice specification (name or formula string with '*').
|
||||
pipeline: TTS pipeline instance for loading formula voices.
|
||||
use_gpu: Whether to use GPU for voice loading.
|
||||
cache: Optional VoiceCache instance for caching loaded voices.
|
||||
|
||||
Returns:
|
||||
Loaded voice tensor (for formulas) or voice name string.
|
||||
"""
|
||||
# Check cache first
|
||||
if cache and cache.contains(voice_spec):
|
||||
return cache.get(voice_spec)
|
||||
|
||||
# Load voice
|
||||
if "*" in voice_spec:
|
||||
if pipeline is None:
|
||||
return voice_spec
|
||||
loaded_voice = get_new_voice(pipeline, voice_spec, use_gpu)
|
||||
else:
|
||||
loaded_voice = voice_spec
|
||||
|
||||
# Cache it
|
||||
if cache:
|
||||
cache.set(voice_spec, loaded_voice)
|
||||
|
||||
return loaded_voice
|
||||
|
||||
|
||||
def load_voice_cached(
|
||||
voice_name: str,
|
||||
pipeline: Any,
|
||||
use_gpu: bool,
|
||||
cache: Optional[Dict[str, Any]] = None,
|
||||
) -> Any:
|
||||
"""Load voice with caching (compatibility wrapper for PyQt).
|
||||
|
||||
This function maintains backward compatibility with the PyQt interface
|
||||
while using the unified voice loading logic.
|
||||
|
||||
Args:
|
||||
voice_name: Voice name or formula string.
|
||||
pipeline: TTS pipeline instance.
|
||||
use_gpu: Whether to use GPU.
|
||||
cache: Optional dict to use as cache (instead of VoiceCache).
|
||||
|
||||
Returns:
|
||||
Loaded voice tensor or voice name string.
|
||||
"""
|
||||
# Use dict cache if provided (for backward compatibility)
|
||||
if cache is not None:
|
||||
if voice_name in cache:
|
||||
return cache[voice_name]
|
||||
|
||||
# Load voice
|
||||
if "*" in voice_name:
|
||||
loaded_voice = get_new_voice(pipeline, voice_name, use_gpu)
|
||||
else:
|
||||
loaded_voice = voice_name
|
||||
|
||||
# Cache it
|
||||
if cache is not None:
|
||||
cache[voice_name] = loaded_voice
|
||||
|
||||
return loaded_voice
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Voice resolution helpers.
|
||||
|
||||
Functions for resolving voice specifications, collecting required voice IDs,
|
||||
and determining the voice to use for chapters and chunks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices, get_default_voice
|
||||
from abogen.voice_formulas import extract_voice_ids
|
||||
from abogen.voice_cache import ensure_voice_assets
|
||||
|
||||
|
||||
def spec_to_voice_ids(spec: Any) -> Set[str]:
|
||||
text = str(spec or "").strip()
|
||||
if not text:
|
||||
return set()
|
||||
if text == "__custom_mix":
|
||||
return set()
|
||||
if "*" in text:
|
||||
try:
|
||||
return set(extract_voice_ids(text))
|
||||
except ValueError:
|
||||
return set()
|
||||
if text in get_voices("kokoro"):
|
||||
return {text}
|
||||
return set()
|
||||
|
||||
|
||||
def job_voice_fallback(job: Any) -> str:
|
||||
base = str(getattr(job, "voice", "") or "").strip()
|
||||
if base and base != "__custom_mix":
|
||||
return base
|
||||
|
||||
speakers = getattr(job, "speakers", None)
|
||||
if isinstance(speakers, dict):
|
||||
narrator = speakers.get("narrator")
|
||||
if isinstance(narrator, dict):
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
value = narrator.get(key)
|
||||
candidate = str(value or "").strip()
|
||||
if candidate and candidate != "__custom_mix":
|
||||
return candidate
|
||||
for payload in speakers.values() or []:
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
value = payload.get(key)
|
||||
candidate = str(value or "").strip()
|
||||
if candidate and candidate != "__custom_mix":
|
||||
return candidate
|
||||
|
||||
for chapter in getattr(job, "chapters", []) or []:
|
||||
if not isinstance(chapter, dict):
|
||||
continue
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
candidate = str(chapter.get(key) or "").strip()
|
||||
if candidate and candidate != "__custom_mix":
|
||||
return candidate
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def collect_required_voice_ids(job: Any) -> Set[str]:
|
||||
voices: Set[str] = set()
|
||||
voices.update(spec_to_voice_ids(job.voice))
|
||||
voices.update(spec_to_voice_ids(job_voice_fallback(job)))
|
||||
|
||||
for chapter in getattr(job, "chapters", []) or []:
|
||||
if not isinstance(chapter, dict):
|
||||
continue
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
voices.update(spec_to_voice_ids(chapter.get(key)))
|
||||
|
||||
for chunk in getattr(job, "chunks", []) or []:
|
||||
if not isinstance(chunk, dict):
|
||||
continue
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
voices.update(spec_to_voice_ids(chunk.get(key)))
|
||||
|
||||
speakers = getattr(job, "speakers", {})
|
||||
if isinstance(speakers, dict):
|
||||
for payload in speakers.values() or []:
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
voices.update(spec_to_voice_ids(payload.get(key)))
|
||||
|
||||
voices.update(get_voices("kokoro"))
|
||||
return voices
|
||||
|
||||
|
||||
def initialize_voice_cache(job: Any) -> None:
|
||||
try:
|
||||
targets = collect_required_voice_ids(job)
|
||||
downloaded, errors = ensure_voice_assets(
|
||||
targets,
|
||||
on_progress=lambda message: job.add_log(message, level="debug"),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
job.add_log(f"Voice cache unavailable: {exc}", level="warning")
|
||||
return
|
||||
|
||||
if downloaded:
|
||||
job.add_log(
|
||||
f"Cached {len(downloaded)} voice asset{'s' if len(downloaded) != 1 else ''} locally.",
|
||||
level="info",
|
||||
)
|
||||
|
||||
for voice_id, error in errors.items():
|
||||
job.add_log(f"Failed to cache voice '{voice_id}': {error}", level="warning")
|
||||
|
||||
|
||||
def chapter_voice_spec(job: Any, override: Optional[Dict[str, Any]]) -> str:
|
||||
if not override:
|
||||
return job_voice_fallback(job)
|
||||
|
||||
resolved = str(override.get("resolved_voice", "")).strip()
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
formula = str(override.get("voice_formula", "")).strip()
|
||||
if formula:
|
||||
return formula
|
||||
|
||||
voice = str(override.get("voice", "")).strip()
|
||||
if voice:
|
||||
return voice
|
||||
|
||||
return job_voice_fallback(job)
|
||||
|
||||
|
||||
def chunk_voice_spec(job: Any, chunk: Dict[str, Any], fallback: str) -> str:
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
value = chunk.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
|
||||
speaker_id = chunk.get("speaker_id")
|
||||
speakers = getattr(job, "speakers", None)
|
||||
if isinstance(speakers, dict) and speaker_id in speakers:
|
||||
speaker_entry = speakers.get(speaker_id) or {}
|
||||
if isinstance(speaker_entry, dict):
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
value = speaker_entry.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
profile_formula = speaker_entry.get("voice_formula")
|
||||
if profile_formula:
|
||||
return str(profile_formula)
|
||||
|
||||
profile_name = chunk.get("voice_profile")
|
||||
if profile_name:
|
||||
if isinstance(speakers, dict):
|
||||
speaker_entry = speakers.get(profile_name)
|
||||
if isinstance(speaker_entry, dict):
|
||||
for key in ("resolved_voice", "voice_formula", "voice"):
|
||||
value = speaker_entry.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
|
||||
if fallback:
|
||||
return fallback
|
||||
return job_voice_fallback(job)
|
||||
|
||||
|
||||
def resolve_fallback_voice_spec(
|
||||
base_spec: str,
|
||||
job_voice: str,
|
||||
voice_cache_keys: list[str],
|
||||
provider: str = "kokoro",
|
||||
) -> str:
|
||||
"""Resolve the voice spec for intro/outro with a priority fallback chain.
|
||||
|
||||
Priority: base_spec → job_voice → first voice_cache key → default voice.
|
||||
``"__custom_mix"`` is treated as empty (it is not a usable voice spec).
|
||||
"""
|
||||
spec = base_spec or job_voice
|
||||
if spec == "__custom_mix":
|
||||
spec = job_voice or ""
|
||||
if not spec:
|
||||
for key in voice_cache_keys:
|
||||
if key and key != "__custom_mix":
|
||||
spec = key.split(":", 1)[-1]
|
||||
break
|
||||
if not spec:
|
||||
spec = get_default_voice(provider)
|
||||
return spec
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, Optional, Tuple, Set
|
||||
|
||||
from abogen.voice_formulas import extract_voice_ids, get_new_voice
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
|
||||
|
||||
def infer_provider_from_spec(value: Any, fallback: str = "kokoro") -> str:
|
||||
"""Infer TTS provider from voice specification."""
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return fallback
|
||||
if raw.upper() == raw and raw.replace("_", "").isalnum():
|
||||
return "supertonic"
|
||||
if raw == "__custom_mix" or "*" in raw or "+" in raw:
|
||||
return "kokoro"
|
||||
if raw in get_voices("kokoro"):
|
||||
return "kokoro"
|
||||
return fallback
|
||||
|
||||
|
||||
def supertonic_voice_from_spec(spec: Any, fallback: str) -> str:
|
||||
"""Normalize a voice specification for Supertonic.
|
||||
|
||||
This function only performs Supertonic-specific normalization (uppercase conversion
|
||||
and fallback handling). Backend resolution is handled by the registry.
|
||||
"""
|
||||
raw = str(spec or "").strip()
|
||||
fallback_raw = str(fallback or "").strip()
|
||||
|
||||
# Normalize to uppercase for Supertonic voice IDs
|
||||
upper = raw.upper() if raw else ""
|
||||
|
||||
# If empty or contains formula characters, use fallback
|
||||
if not upper or "*" in upper or "+" in upper:
|
||||
upper = fallback_raw.upper() if fallback_raw else ""
|
||||
|
||||
# If still empty, use default Supertonic voice
|
||||
if not upper or "*" in upper or "+" in upper:
|
||||
upper = "M1"
|
||||
|
||||
return upper
|
||||
|
||||
|
||||
def split_speaker_reference(value: Any) -> Tuple[Optional[str], str]:
|
||||
"""Parse speaker/profile reference from string.
|
||||
|
||||
Expected format: "speaker:name" or "profile:name"
|
||||
Returns (name, original) or (None, original) if not a valid reference.
|
||||
"""
|
||||
raw = str(value or "").strip()
|
||||
if not raw or ":" not in raw:
|
||||
return None, raw
|
||||
prefix, remainder = raw.split(":", 1)
|
||||
prefix = prefix.strip().lower()
|
||||
if prefix not in {"speaker", "profile"}:
|
||||
return None, raw
|
||||
name = remainder.strip()
|
||||
return (name or None), raw
|
||||
|
||||
|
||||
def formula_from_kokoro_entry(entry: Mapping[str, Any]) -> str:
|
||||
"""Build voice formula string from kokoro entry."""
|
||||
voices = entry.get("voices") or []
|
||||
if not voices:
|
||||
return ""
|
||||
total = 0.0
|
||||
parts: list[tuple[str, float]] = []
|
||||
for item in voices:
|
||||
if not isinstance(item, (list, tuple)) or len(item) < 2:
|
||||
continue
|
||||
name = str(item[0] or "").strip()
|
||||
try:
|
||||
weight = float(item[1])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if name and weight > 0:
|
||||
parts.append((name, weight))
|
||||
total += weight
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
normalized = [(name, weight / total) for name, weight in parts]
|
||||
return " + ".join(f"{name}*{weight:.6f}" for name, weight in normalized)
|
||||
|
||||
|
||||
def coerce_truthy(value: Any, default: bool = True) -> bool:
|
||||
"""Coerce a value to boolean with default."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.lower() not in {"false", "0", "no", "off", ""}
|
||||
if value is None:
|
||||
return default
|
||||
return bool(value)
|
||||
@@ -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;
|
||||
}
|
||||
"""
|
||||
+6
-3594
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,448 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Mapping, Sequence
|
||||
|
||||
import static_ffmpeg
|
||||
|
||||
from abogen.domain.metadata_helpers import (
|
||||
normalize_metadata_casefold,
|
||||
split_people_field,
|
||||
split_simple_list,
|
||||
first_nonempty,
|
||||
extract_year,
|
||||
normalize_series_sequence,
|
||||
build_audiobookshelf_metadata as _build_abs_metadata,
|
||||
load_audiobookshelf_chapters as _load_abs_chapters,
|
||||
_SERIES_SEQUENCE_TAG_KEYS,
|
||||
)
|
||||
from abogen.epub3.exporter import build_epub3_package
|
||||
from abogen.integrations.audiobookshelf import (
|
||||
AudiobookshelfClient,
|
||||
AudiobookshelfConfig,
|
||||
AudiobookshelfUploadError,
|
||||
)
|
||||
from abogen.utils import create_process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExportConfig:
|
||||
"""Configuration for export operations."""
|
||||
ffmpeg_path: str = "ffmpeg"
|
||||
verify_ssl: bool = True
|
||||
|
||||
|
||||
class ExportService:
|
||||
"""Unified service for audiobook exports (M4B, FFMETADATA, EPUB3, Audiobookshelf)."""
|
||||
|
||||
def __init__(self, config: Optional[ExportConfig] = None):
|
||||
self.config = config or ExportConfig()
|
||||
static_ffmpeg.add_paths()
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# FFMETADATA
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def render_ffmetadata(
|
||||
self,
|
||||
metadata: Dict[str, Any],
|
||||
chapters: List[Dict[str, Any]],
|
||||
) -> str:
|
||||
"""Render FFMETADATA content."""
|
||||
lines = [";FFMETADATA1"]
|
||||
|
||||
for key, value in (metadata or {}).items():
|
||||
if value is None:
|
||||
continue
|
||||
key_str = str(key).strip()
|
||||
if not key_str:
|
||||
continue
|
||||
lines.append(f"{key_str}={self._escape_ffmetadata_value(value)}")
|
||||
|
||||
for chapter in chapters or []:
|
||||
start = chapter.get("start")
|
||||
end = chapter.get("end")
|
||||
if start is None or end is None:
|
||||
continue
|
||||
try:
|
||||
start_ms = max(0, int(round(float(start) * 1000)))
|
||||
end_ms = int(round(float(end) * 1000))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if end_ms <= start_ms:
|
||||
end_ms = start_ms + 1
|
||||
lines.append("[CHAPTER]")
|
||||
lines.append("TIMEBASE=1/1000")
|
||||
lines.append(f"START={start_ms}")
|
||||
lines.append(f"END={end_ms}")
|
||||
title = chapter.get("title")
|
||||
if title:
|
||||
lines.append(f"title={self._escape_ffmetadata_value(title)}")
|
||||
voice = chapter.get("voice")
|
||||
if voice:
|
||||
lines.append(f"voice={self._escape_ffmetadata_value(voice)}")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _escape_ffmetadata_value(value: Any) -> str:
|
||||
escaped = str(value).replace("\\", "\\\\").replace("\n", "\\n")
|
||||
escaped = escaped.replace("=", "\\=").replace(";", "\\;").replace("#", "\\#")
|
||||
return escaped
|
||||
|
||||
def write_ffmetadata_file(
|
||||
self,
|
||||
audio_path: Path,
|
||||
metadata: Dict[str, Any],
|
||||
chapters: List[Dict[str, Any]],
|
||||
) -> Optional[Path]:
|
||||
"""Write FFMETADATA file to temp location."""
|
||||
content = self.render_ffmetadata(metadata, chapters)
|
||||
if content.strip() == ";FFMETADATA1":
|
||||
return None
|
||||
|
||||
directory = audio_path.parent if audio_path.parent.exists() else Path(tempfile.gettempdir())
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
suffix=".ffmeta",
|
||||
delete=False,
|
||||
dir=str(directory),
|
||||
) as handle:
|
||||
handle.write(content)
|
||||
return Path(handle.name)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# M4B Export
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def embed_m4b_metadata(
|
||||
self,
|
||||
audio_path: Path,
|
||||
metadata: Dict[str, Any],
|
||||
chapters: List[Dict[str, Any]],
|
||||
cover_path: Optional[Path] = None,
|
||||
cover_mime: Optional[str] = None,
|
||||
log_callback: Optional[callable] = None,
|
||||
) -> None:
|
||||
"""Embed metadata and chapters into M4B file using FFmpeg + Mutagen."""
|
||||
ffmetadata_path = self.write_ffmetadata_file(audio_path, metadata, chapters)
|
||||
|
||||
metadata_args = self._metadata_to_ffmpeg_args(metadata)
|
||||
|
||||
cmd = ["ffmpeg", "-y", "-i", str(audio_path)]
|
||||
|
||||
if ffmetadata_path:
|
||||
cmd.extend(["-f", "ffmetadata", "-i", str(ffmetadata_path)])
|
||||
|
||||
if cover_path and cover_path.exists():
|
||||
cmd.extend(["-i", str(cover_path)])
|
||||
cmd.extend(["-map", "0:a"])
|
||||
cmd.extend(["-map", "1:v:0", "-c:v:0", "mjpeg", "-disposition:v:0", "attached_pic"])
|
||||
if cover_mime:
|
||||
cmd.extend(["-metadata:s:v:0", f"mimetype={cover_mime}"])
|
||||
cmd.extend(["-metadata:s:v:0", "title=Cover Art"])
|
||||
else:
|
||||
cmd.extend(["-map", "0:a"])
|
||||
|
||||
cmd.extend(["-c:a", "copy"])
|
||||
|
||||
if ffmetadata_path:
|
||||
cmd.extend(["-map_metadata", "1", "-map_chapters", "1"])
|
||||
else:
|
||||
cmd.extend(["-map_metadata", "0"])
|
||||
|
||||
if metadata_args:
|
||||
cmd.extend(metadata_args)
|
||||
|
||||
cmd.extend(["-movflags", "+faststart+use_metadata_tags"])
|
||||
|
||||
temp_output = audio_path.with_suffix(audio_path.suffix + ".tmp")
|
||||
if audio_path.suffix.lower() in {".m4b", ".mp4", ".m4a"}:
|
||||
cmd.extend(["-f", "mp4"])
|
||||
cmd.append(str(temp_output))
|
||||
|
||||
if log_callback:
|
||||
log_callback("Embedding metadata into M4B output")
|
||||
|
||||
process = create_process(cmd, text=True)
|
||||
return_code = process.wait()
|
||||
|
||||
if ffmetadata_path and ffmetadata_path.exists():
|
||||
try:
|
||||
ffmetadata_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if return_code != 0:
|
||||
if temp_output.exists():
|
||||
temp_output.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"ffmpeg failed to embed metadata (exit code {return_code})")
|
||||
|
||||
temp_output.replace(audio_path)
|
||||
|
||||
if log_callback:
|
||||
log_callback("Embedded metadata and chapters into M4B output", "info")
|
||||
|
||||
# Apply chapters via Mutagen for better compatibility
|
||||
self._apply_m4b_chapters_mutagen(audio_path, chapters, log_callback)
|
||||
|
||||
@staticmethod
|
||||
def _metadata_to_ffmpeg_args(metadata: Dict[str, Any]) -> List[str]:
|
||||
args = []
|
||||
for key, value in (metadata or {}).items():
|
||||
if value in (None, ""):
|
||||
continue
|
||||
key_str = str(key).strip()
|
||||
if not key_str:
|
||||
continue
|
||||
normalized_key = key_str.lower()
|
||||
if normalized_key == "year":
|
||||
ffmpeg_key = "date"
|
||||
else:
|
||||
ffmpeg_key = key_str
|
||||
args.extend(["-metadata", f"{ffmpeg_key}={value}"])
|
||||
return args
|
||||
|
||||
def _apply_m4b_chapters_mutagen(
|
||||
self,
|
||||
audio_path: Path,
|
||||
chapters: List[Dict[str, Any]],
|
||||
log_callback: Optional[callable] = None,
|
||||
) -> bool:
|
||||
"""Apply chapter atoms using Mutagen."""
|
||||
if not chapters:
|
||||
return False
|
||||
|
||||
try:
|
||||
from fractions import Fraction
|
||||
from mutagen.mp4 import MP4, MP4Chapter
|
||||
except ImportError:
|
||||
if log_callback:
|
||||
log_callback("Unable to write MP4 chapter atoms because mutagen is not installed.", "warning")
|
||||
return False
|
||||
|
||||
try:
|
||||
mp4 = MP4(str(audio_path))
|
||||
except Exception as exc:
|
||||
if log_callback:
|
||||
log_callback(f"Failed to open m4b for chapter embedding: {exc}", "warning")
|
||||
return False
|
||||
|
||||
chapter_objects = []
|
||||
for index, entry in enumerate(sorted(chapters, key=lambda item: float(item.get("start") or 0.0))):
|
||||
start_raw = entry.get("start")
|
||||
if start_raw is None:
|
||||
continue
|
||||
try:
|
||||
start_seconds = max(0.0, float(start_raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
title_value = entry.get("title")
|
||||
title_text = str(title_value) if title_value else f"Chapter {index + 1}"
|
||||
|
||||
start_fraction = Fraction(int(round(start_seconds * 1000)), 1000)
|
||||
chapter_atom = MP4Chapter(start_fraction, title_text)
|
||||
|
||||
end_raw = entry.get("end")
|
||||
if end_raw is not None:
|
||||
try:
|
||||
end_seconds = float(end_raw)
|
||||
except (TypeError, ValueError):
|
||||
end_seconds = None
|
||||
if end_seconds is not None and end_seconds > start_seconds:
|
||||
chapter_atom.end = Fraction(int(round(end_seconds * 1000)), 1000)
|
||||
|
||||
chapter_objects.append(chapter_atom)
|
||||
|
||||
if not chapter_objects:
|
||||
return False
|
||||
|
||||
try:
|
||||
mp4.chapters = chapter_objects
|
||||
mp4.save()
|
||||
except Exception as exc:
|
||||
if log_callback:
|
||||
log_callback(f"Failed to persist MP4 chapter atoms: {exc}", "warning")
|
||||
return False
|
||||
|
||||
if log_callback:
|
||||
log_callback(f"Applied {len(chapter_objects)} chapter markers via mutagen", "info")
|
||||
return True
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# EPUB3 Export
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def export_epub3(
|
||||
self,
|
||||
output_path: Path,
|
||||
book_id: str,
|
||||
extraction: Any, # 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_path: Optional[Path] = None,
|
||||
cover_mime: Optional[str] = None,
|
||||
) -> Path:
|
||||
"""Export EPUB3 with media overlays."""
|
||||
return build_epub3_package(
|
||||
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_path,
|
||||
cover_image_mime=cover_mime,
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Audiobookshelf Integration
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def build_audiobookshelf_metadata(self, job: Any) -> Dict[str, Any]:
|
||||
"""Build Audiobookshelf metadata from job."""
|
||||
filename = Path(getattr(job, "original_filename", "") or "").stem or "Audiobook"
|
||||
return _build_abs_metadata(
|
||||
getattr(job, "metadata_tags", {}),
|
||||
language=getattr(job, "language", "") or "",
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
def load_audiobookshelf_chapters(self, job: Any) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Load chapters from job artifacts for Audiobookshelf."""
|
||||
metadata_ref = job.result.artifacts.get("metadata") if getattr(job, "result", None) else None
|
||||
if not metadata_ref:
|
||||
return None
|
||||
metadata_path = metadata_ref if isinstance(metadata_ref, Path) else Path(str(metadata_ref))
|
||||
return _load_abs_chapters(metadata_path)
|
||||
|
||||
def upload_audiobookshelf(
|
||||
self,
|
||||
job: Any,
|
||||
audio_path: Path,
|
||||
subtitle_paths: List[Path],
|
||||
chapters: List[Dict[str, Any]],
|
||||
metadata: Dict[str, Any],
|
||||
cover_path: Optional[Path] = None,
|
||||
config: Optional[AudiobookshelfConfig] = None,
|
||||
log_callback: Optional[callable] = None,
|
||||
) -> None:
|
||||
"""Upload to Audiobookshelf."""
|
||||
if config is None:
|
||||
# Load from job or global config
|
||||
cfg = getattr(job, "_abs_config", None)
|
||||
if cfg is None:
|
||||
from abogen.utils import load_config
|
||||
global_cfg = load_config() or {}
|
||||
abs_cfg = global_cfg.get("audiobookshelf")
|
||||
if isinstance(abs_cfg, Mapping):
|
||||
config = AudiobookshelfConfig(
|
||||
base_url=str(abs_cfg.get("base_url") or "").strip(),
|
||||
api_token=str(abs_cfg.get("api_token") or "").strip(),
|
||||
library_id=str(abs_cfg.get("library_id") or "").strip(),
|
||||
collection_id=(str(abs_cfg.get("collection_id") or "").strip() or None),
|
||||
folder_id=str(abs_cfg.get("folder_id") or "").strip(),
|
||||
verify_ssl=self._coerce_bool(abs_cfg.get("verify_ssl"), True),
|
||||
send_cover=self._coerce_bool(abs_cfg.get("send_cover"), True),
|
||||
send_chapters=self._coerce_bool(abs_cfg.get("send_chapters"), True),
|
||||
send_subtitles=self._coerce_bool(abs_cfg.get("send_subtitles"), False),
|
||||
timeout=float(abs_cfg.get("timeout", 3600.0)),
|
||||
)
|
||||
else:
|
||||
if log_callback:
|
||||
log_callback("Audiobookshelf upload skipped: not configured", "warning")
|
||||
return
|
||||
|
||||
if not config.base_url or not config.api_token or not config.library_id:
|
||||
if log_callback:
|
||||
log_callback("Audiobookshelf upload skipped: configure base URL, API token, and library ID first", "warning")
|
||||
return
|
||||
if not config.folder_id:
|
||||
if log_callback:
|
||||
log_callback("Audiobookshelf upload skipped: enter folder name or ID in settings", "warning")
|
||||
return
|
||||
|
||||
if not audio_path.exists():
|
||||
if log_callback:
|
||||
log_callback("Audiobookshelf upload skipped: audio output not found", "warning")
|
||||
return
|
||||
|
||||
existing_subtitles = [p for p in subtitle_paths if p.exists()] if config.send_subtitles else None
|
||||
chapters_to_send = chapters if config.send_chapters else None
|
||||
|
||||
client = AudiobookshelfClient(config)
|
||||
|
||||
display_title = metadata.get("title") or audio_path.stem
|
||||
try:
|
||||
existing_items = client.find_existing_items(display_title, folder_id=config.folder_id)
|
||||
except AudiobookshelfUploadError as exc:
|
||||
if log_callback:
|
||||
log_callback(f"Audiobookshelf lookup failed: {exc}", "error")
|
||||
return
|
||||
|
||||
if existing_items:
|
||||
if log_callback:
|
||||
log_callback(f"Removing existing Audiobookshelf item(s) for '{display_title}' before upload.", "info")
|
||||
try:
|
||||
client.delete_items(existing_items)
|
||||
except Exception as exc:
|
||||
if log_callback:
|
||||
log_callback(f"Failed to remove existing item(s): {exc}", "warning")
|
||||
|
||||
cover_to_send = cover_path
|
||||
if config.send_cover and cover_to_send:
|
||||
if isinstance(cover_to_send, str):
|
||||
cover_to_send = Path(cover_to_send)
|
||||
if not cover_to_send.exists():
|
||||
cover_to_send = None
|
||||
|
||||
client.upload_audiobook(
|
||||
audio_path,
|
||||
metadata=metadata,
|
||||
cover_path=cover_to_send,
|
||||
chapters=chapters_to_send,
|
||||
subtitles=existing_subtitles,
|
||||
)
|
||||
|
||||
if log_callback:
|
||||
log_callback("Audiobookshelf upload queued.", "info")
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"true", "1", "yes", "on"}:
|
||||
return True
|
||||
if lowered in {"false", "0", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
if value is None:
|
||||
return default
|
||||
return bool(value)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ExportConfig",
|
||||
"ExportService",
|
||||
]
|
||||
@@ -0,0 +1,303 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, TextIO
|
||||
|
||||
from abogen.subtitle_utils import clean_subtitle_text
|
||||
|
||||
|
||||
class SubtitleFormat(Enum):
|
||||
SRT = "srt"
|
||||
ASS = "ass"
|
||||
VTT = "vtt"
|
||||
|
||||
|
||||
class SubtitleMode(Enum):
|
||||
DISABLED = "Disabled"
|
||||
LINE = "Line"
|
||||
SENTENCE = "Sentence"
|
||||
SENTENCE_COMMA = "Sentence + Comma"
|
||||
SENTENCE_HIGHLIGHT = "Sentence + Highlighting"
|
||||
|
||||
|
||||
class SubtitleAlignment(Enum):
|
||||
LEFT = "left"
|
||||
CENTER = "center"
|
||||
NARROW = "narrow"
|
||||
CENTER_NARROW = "center_narrow"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubtitleConfig:
|
||||
"""Configuration for subtitle writer."""
|
||||
format: SubtitleFormat
|
||||
mode: SubtitleMode
|
||||
alignment: SubtitleAlignment = SubtitleAlignment.LEFT
|
||||
max_words: int = 50
|
||||
highlight_color: str = "&H00FFFF00" # ASS highlight color
|
||||
|
||||
|
||||
class SubtitleWriter(ABC):
|
||||
"""Abstract base class for subtitle writers."""
|
||||
|
||||
def __init__(self, path: Path, config: SubtitleConfig):
|
||||
self.path = path
|
||||
self.config = config
|
||||
self._file: Optional[TextIO] = None
|
||||
self._index = 0
|
||||
self._opened = False
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open the subtitle file and write header."""
|
||||
if self._opened:
|
||||
return
|
||||
self._file = open(self.path, "w", encoding="utf-8", errors="replace")
|
||||
self._write_header()
|
||||
self._opened = True
|
||||
|
||||
@abstractmethod
|
||||
def _write_header(self) -> None:
|
||||
pass
|
||||
|
||||
def write_entry(
|
||||
self,
|
||||
start: float,
|
||||
end: float,
|
||||
text: str,
|
||||
voice: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Write a subtitle entry."""
|
||||
if not self._opened:
|
||||
self.open()
|
||||
|
||||
text = clean_subtitle_text(text)
|
||||
if not text:
|
||||
return
|
||||
|
||||
self._index += 1
|
||||
self._write_entry(self._index, start, end, text, voice)
|
||||
|
||||
@abstractmethod
|
||||
def _write_entry(
|
||||
self,
|
||||
index: int,
|
||||
start: float,
|
||||
end: float,
|
||||
text: str,
|
||||
voice: Optional[str],
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the subtitle file."""
|
||||
if self._file:
|
||||
self._file.close()
|
||||
self._file = None
|
||||
self._opened = False
|
||||
|
||||
def __enter__(self) -> "SubtitleWriter":
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
class SrtWriter(SubtitleWriter):
|
||||
"""SRT subtitle writer."""
|
||||
|
||||
def _write_header(self) -> None:
|
||||
pass # SRT has no header
|
||||
|
||||
def _write_entry(
|
||||
self,
|
||||
index: int,
|
||||
start: float,
|
||||
end: float,
|
||||
text: str,
|
||||
voice: Optional[str],
|
||||
) -> None:
|
||||
start_str = self._format_time(start)
|
||||
end_str = self._format_time(end)
|
||||
|
||||
if voice:
|
||||
text = f"[{voice}] {text}"
|
||||
|
||||
self._file.write(f"{index}\n")
|
||||
self._file.write(f"{start_str} --> {end_str}\n")
|
||||
self._file.write(f"{text}\n\n")
|
||||
|
||||
@staticmethod
|
||||
def _format_time(seconds: float) -> str:
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = int(seconds % 60)
|
||||
millis = int((seconds - int(seconds)) * 1000)
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
|
||||
|
||||
|
||||
class VttWriter(SubtitleWriter):
|
||||
"""WebVTT subtitle writer."""
|
||||
|
||||
def _write_header(self) -> None:
|
||||
self._file.write("WEBVTT\n\n")
|
||||
|
||||
def _write_entry(
|
||||
self,
|
||||
index: int,
|
||||
start: float,
|
||||
end: float,
|
||||
text: str,
|
||||
voice: Optional[str],
|
||||
) -> None:
|
||||
start_str = self._format_time(start)
|
||||
end_str = self._format_time(end)
|
||||
|
||||
if voice:
|
||||
text = f"[{voice}] {text}"
|
||||
|
||||
self._file.write(f"{index}\n")
|
||||
self._file.write(f"{start_str} --> {end_str}\n")
|
||||
self._file.write(f"{text}\n\n")
|
||||
|
||||
@staticmethod
|
||||
def _format_time(seconds: float) -> str:
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = seconds % 60
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:06.3f}".replace(".", ".")
|
||||
|
||||
|
||||
class AssWriter(SubtitleWriter):
|
||||
"""ASS subtitle writer with karaoke highlighting support."""
|
||||
|
||||
def __init__(self, path: Path, config: SubtitleConfig):
|
||||
super().__init__(path, config)
|
||||
self._is_centered = config.alignment in (SubtitleAlignment.CENTER, SubtitleAlignment.CENTER_NARROW)
|
||||
self._is_narrow = config.alignment in (SubtitleAlignment.NARROW, SubtitleAlignment.CENTER_NARROW)
|
||||
|
||||
def _write_header(self) -> None:
|
||||
margin = "90" if self._is_narrow else "10"
|
||||
alignment = "5" if self._is_centered else "2"
|
||||
|
||||
self._file.write("[Script Info]\n")
|
||||
self._file.write("Title: Generated by Abogen\n")
|
||||
self._file.write("ScriptType: v4.00+\n\n")
|
||||
|
||||
# Styles
|
||||
self._file.write("[V4+ Styles]\n")
|
||||
self._file.write(
|
||||
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
|
||||
"OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, "
|
||||
"ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, "
|
||||
"Alignment, MarginL, MarginR, MarginV, Encoding\n"
|
||||
)
|
||||
|
||||
if self.config.mode == SubtitleMode.SENTENCE_HIGHLIGHT:
|
||||
# Karaoke style with highlighting
|
||||
self._file.write(
|
||||
f"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,"
|
||||
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n"
|
||||
)
|
||||
self._file.write(
|
||||
f"Style: Highlight,Arial,24,&H0000FFFF,&H00808080,&H00000000,&H00404040,"
|
||||
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n\n"
|
||||
)
|
||||
else:
|
||||
self._file.write(
|
||||
f"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,"
|
||||
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n\n"
|
||||
)
|
||||
|
||||
self._file.write("[Events]\n")
|
||||
self._file.write(
|
||||
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
|
||||
)
|
||||
|
||||
def _write_entry(
|
||||
self,
|
||||
index: int,
|
||||
start: float,
|
||||
end: float,
|
||||
text: str,
|
||||
voice: Optional[str],
|
||||
) -> None:
|
||||
start_str = self._format_time(start)
|
||||
end_str = self._format_time(end)
|
||||
|
||||
if voice:
|
||||
text = f"[{voice}] {text}"
|
||||
|
||||
style = "Default"
|
||||
if self.config.mode == SubtitleMode.SENTENCE_HIGHLIGHT:
|
||||
# Add karaoke tags for highlighting
|
||||
text = self._add_karaoke_tags(text)
|
||||
style = "Highlight"
|
||||
|
||||
alignment_tag = r"{\an5}" if self._is_centered else ""
|
||||
self._file.write(
|
||||
f"Dialogue: 0,{start_str},{end_str},{style},,0,0,0,,{alignment_tag}{text}\n"
|
||||
)
|
||||
|
||||
def _add_karaoke_tags(self, text: str) -> str:
|
||||
"""Add karaoke highlighting tags to text."""
|
||||
# Simple word-level karaoke timing
|
||||
words = text.split()
|
||||
if not words:
|
||||
return text
|
||||
|
||||
# This is a simplified version - real karaoke needs per-word timing
|
||||
# For now, just return the text with the highlight color
|
||||
return r"{\k100}" + r"{\k100}".join(words) + r"{\k0}"
|
||||
|
||||
@staticmethod
|
||||
def _format_time(seconds: float) -> str:
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = seconds % 60
|
||||
return f"{hours}:{minutes:02d}:{secs:05.2f}"
|
||||
|
||||
|
||||
def create_subtitle_writer(
|
||||
path: Path,
|
||||
format: str,
|
||||
mode: str,
|
||||
alignment: str = "left",
|
||||
max_words: int = 50,
|
||||
) -> SubtitleWriter:
|
||||
"""Factory function to create subtitle writer."""
|
||||
fmt = SubtitleFormat(format.lower())
|
||||
mode = SubtitleMode(mode)
|
||||
align = SubtitleAlignment(alignment.lower())
|
||||
|
||||
config = SubtitleConfig(
|
||||
format=fmt,
|
||||
mode=mode,
|
||||
alignment=align,
|
||||
max_words=max_words,
|
||||
)
|
||||
|
||||
if fmt == SubtitleFormat.SRT:
|
||||
return SrtWriter(path, config)
|
||||
elif fmt == SubtitleFormat.VTT:
|
||||
return VttWriter(path, config)
|
||||
elif fmt == SubtitleFormat.ASS:
|
||||
return AssWriter(path, config)
|
||||
else:
|
||||
raise ValueError(f"Unsupported subtitle format: {format}")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SubtitleFormat",
|
||||
"SubtitleMode",
|
||||
"SubtitleAlignment",
|
||||
"SubtitleConfig",
|
||||
"SubtitleWriter",
|
||||
"SrtWriter",
|
||||
"VttWriter",
|
||||
"AssWriter",
|
||||
"create_subtitle_writer",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
"""Integration clients for external services."""
|
||||
@@ -0,0 +1,647 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
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
|
||||
|
||||
from abogen.domain.metadata_helpers import normalize_series_sequence
|
||||
|
||||
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 = normalize_series_sequence(metadata.get(key))
|
||||
if normalized:
|
||||
return normalized
|
||||
return ""
|
||||
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")
|
||||
+25
-116
@@ -1,130 +1,39 @@
|
||||
from json import load
|
||||
"""Backwards-compatible entry point that now launches the web UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
import atexit
|
||||
import signal
|
||||
|
||||
# Qt platform plugin detection (fixes #59)
|
||||
try:
|
||||
from PyQt5.QtCore import QLibraryInfo
|
||||
plugins = QLibraryInfo.location(QLibraryInfo.PluginsPath)
|
||||
platform_dir = os.path.join(plugins, "platforms")
|
||||
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("PyQt5 platform plugins not found at", platform_dir)
|
||||
except ImportError:
|
||||
print("PyQt5 not installed.")
|
||||
# Initialise global shutdown handling (atexit, signals, Qt) as early as possible.
|
||||
from abogen import shutdown # noqa: F401
|
||||
shutdown.register_shutdown()
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PyQt5.QtGui import QIcon
|
||||
from PyQt5.QtCore import qInstallMessageHandler, QtMsgType
|
||||
from abogen.utils import load_config
|
||||
from abogen.webui.app import main as _run_web_ui
|
||||
|
||||
# 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
|
||||
# Configure Hugging Face Hub behaviour (mirrors legacy GUI defaults).
|
||||
os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
|
||||
os.environ.setdefault("HF_HUB_ETAG_TIMEOUT", "10")
|
||||
os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "10")
|
||||
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
|
||||
if load_config().get("disable_kokoro_internet", False):
|
||||
print("INFO: Kokoro's internet access is disabled.")
|
||||
os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
|
||||
from abogen.gui import abogen
|
||||
from abogen.constants import PROGRAM_NAME, VERSION
|
||||
# Prefer faster ROCm tuning defaults when available.
|
||||
os.environ.setdefault("MIOPEN_FIND_MODE", "FAST")
|
||||
os.environ.setdefault("MIOPEN_CONV_PRECISE_ROCM_TUNING", "0")
|
||||
|
||||
# Set environment variables for AMD ROCm
|
||||
os.environ["MIOPEN_FIND_MODE"] = "FAST"
|
||||
os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0"
|
||||
|
||||
# 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
|
||||
# Enable MPS GPU acceleration on Apple Silicon.
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
|
||||
os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
|
||||
|
||||
|
||||
# Custom message handler to filter out specific Qt warnings
|
||||
def qt_message_handler(mode, context, message):
|
||||
if "Wayland does not support QWindow::requestActivate()" in message:
|
||||
return # Suppress this specific message
|
||||
if "setGrabPopup called with a parent, QtWaylandClient" in message:
|
||||
return
|
||||
if mode == QtMsgType.QtWarningMsg:
|
||||
print(f"Qt Warning: {message}")
|
||||
elif mode == QtMsgType.QtCriticalMsg:
|
||||
print(f"Qt Critical: {message}")
|
||||
elif mode == QtMsgType.QtFatalMsg:
|
||||
print(f"Qt Fatal: {message}")
|
||||
elif mode == QtMsgType.QtInfoMsg:
|
||||
print(f"Qt Info: {message}")
|
||||
def main() -> None:
|
||||
"""Launch the Flask-based web UI."""
|
||||
|
||||
_run_web_ui()
|
||||
|
||||
|
||||
# Install the custom message handler
|
||||
qInstallMessageHandler(qt_message_handler)
|
||||
|
||||
# Set application ID for Windows taskbar icon
|
||||
if platform.system() == "Windows":
|
||||
import ctypes
|
||||
|
||||
app_id = f"{PROGRAM_NAME}.{VERSION}"
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
|
||||
|
||||
# Handle Wayland on Linux GNOME
|
||||
if platform.system() == "Linux":
|
||||
xdg_session = os.environ.get("XDG_SESSION_TYPE", "").lower()
|
||||
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
|
||||
if (
|
||||
"gnome" in desktop
|
||||
and xdg_session == "wayland"
|
||||
and "QT_QPA_PLATFORM" not in os.environ
|
||||
):
|
||||
os.environ["QT_QPA_PLATFORM"] = "wayland"
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for console usage."""
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Set application icon using get_resource_path from utils
|
||||
icon_path = get_resource_path("abogen.assets", "icon.ico")
|
||||
if icon_path:
|
||||
app.setWindowIcon(QIcon(icon_path))
|
||||
|
||||
# Set the .desktop name on Linux
|
||||
if platform.system() == "Linux":
|
||||
try:
|
||||
app.setDesktopFileName("abogen.desktop")
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
ex = abogen()
|
||||
ex.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == "__main__": # pragma: no cover - manual execution hook
|
||||
main()
|
||||
|
||||
@@ -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,591 @@
|
||||
"""
|
||||
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
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
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 = get_voices("kokoro")
|
||||
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 get_voices("kokoro"):
|
||||
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(get_voices("kokoro"))
|
||||
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
+4303
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
|
||||
# Initialise global shutdown handling (atexit, signals, Qt) as early as possible.
|
||||
from abogen import shutdown # noqa: F401
|
||||
shutdown.register_shutdown()
|
||||
|
||||
# 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
|
||||
from abogen.utils import load_config
|
||||
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"
|
||||
|
||||
# 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,591 @@
|
||||
"""
|
||||
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
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
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 = get_voices("kokoro")
|
||||
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 get_voices("kokoro"):
|
||||
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(get_voices("kokoro"))
|
||||
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
-645
@@ -1,649 +1,11 @@
|
||||
# a simple window with a list of items in the queue, no checkboxes
|
||||
# button to remove an item from the queue
|
||||
# button to clear the queue
|
||||
"""Backwards-compatible re-export of the PyQt queue manager.
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QDialogButtonBox,
|
||||
QPushButton,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QFileIconProvider,
|
||||
QLabel,
|
||||
QWidget,
|
||||
QSizePolicy,
|
||||
)
|
||||
from PyQt5.QtCore import QFileInfo, Qt
|
||||
from abogen.constants import COLORS
|
||||
from copy import deepcopy
|
||||
from PyQt5.QtGui import QFontMetrics
|
||||
The actual implementation lives in abogen.pyqt.queue_manager_gui.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
class ElidedLabel(QLabel):
|
||||
def __init__(self, text, parent=None):
|
||||
super().__init__(text, parent)
|
||||
self._full_text = text
|
||||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
from abogen.pyqt.queue_manager_gui import * # noqa: F401, F403
|
||||
from abogen.pyqt.queue_manager_gui import QueueManager
|
||||
|
||||
def setText(self, text):
|
||||
self._full_text = text
|
||||
super().setText(text)
|
||||
self.update()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
metrics = QFontMetrics(self.font())
|
||||
elided = metrics.elidedText(self._full_text, Qt.ElideRight, self.width())
|
||||
super().setText(elided)
|
||||
super().resizeEvent(event)
|
||||
|
||||
def fullText(self):
|
||||
return self._full_text
|
||||
|
||||
|
||||
class QueueListItemWidget(QWidget):
|
||||
def __init__(self, file_name, char_count):
|
||||
super().__init__()
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(12, 0, 6, 0)
|
||||
layout.setSpacing(0)
|
||||
import os
|
||||
|
||||
name_label = ElidedLabel(os.path.basename(file_name))
|
||||
char_label = QLabel(f"Chars: {char_count}")
|
||||
char_label.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']};")
|
||||
char_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
char_label.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
|
||||
layout.addWidget(name_label, 1)
|
||||
layout.addWidget(char_label, 0)
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
class DroppableQueueListWidget(QListWidget):
|
||||
def __init__(self, parent_dialog):
|
||||
super().__init__()
|
||||
self.parent_dialog = parent_dialog
|
||||
self.setAcceptDrops(True)
|
||||
# Overlay for drag hover
|
||||
self.drag_overlay = QLabel("", self)
|
||||
self.drag_overlay.setAlignment(Qt.AlignCenter)
|
||||
self.drag_overlay.setStyleSheet(
|
||||
f"border:2px dashed {COLORS['BLUE_BORDER_HOVER']}; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG_HOVER']};"
|
||||
)
|
||||
self.drag_overlay.setVisible(False)
|
||||
self.drag_overlay.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
for url in event.mimeData().urls():
|
||||
if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt"):
|
||||
self.drag_overlay.resize(self.size())
|
||||
self.drag_overlay.setVisible(True)
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
self.drag_overlay.setVisible(False)
|
||||
event.ignore()
|
||||
|
||||
def dragMoveEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
for url in event.mimeData().urls():
|
||||
if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt"):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
def dragLeaveEvent(self, event):
|
||||
self.drag_overlay.setVisible(False)
|
||||
event.accept()
|
||||
|
||||
def dropEvent(self, event):
|
||||
self.drag_overlay.setVisible(False)
|
||||
if event.mimeData().hasUrls():
|
||||
file_paths = [
|
||||
url.toLocalFile()
|
||||
for url in event.mimeData().urls()
|
||||
if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt")
|
||||
]
|
||||
if file_paths:
|
||||
self.parent_dialog.add_files_from_paths(file_paths)
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
event.ignore()
|
||||
else:
|
||||
event.ignore()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, "drag_overlay"):
|
||||
self.drag_overlay.resize(self.size())
|
||||
|
||||
|
||||
class QueueManager(QDialog):
|
||||
def __init__(self, parent, queue: list, title="Queue Manager", size=(600, 700)):
|
||||
super().__init__()
|
||||
self.queue = queue
|
||||
self._original_queue = deepcopy(
|
||||
queue
|
||||
) # Store a deep copy of the original queue
|
||||
self.parent = parent
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(15, 15, 15, 15) # set main layout margins
|
||||
layout.setSpacing(12) # set spacing between widgets in main layout
|
||||
# list of queued items
|
||||
self.listwidget = DroppableQueueListWidget(self)
|
||||
self.listwidget.setSelectionMode(QListWidget.ExtendedSelection)
|
||||
self.listwidget.setAlternatingRowColors(True)
|
||||
self.listwidget.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.listwidget.customContextMenuRequested.connect(self.show_context_menu)
|
||||
# Add informative instructions at the top
|
||||
instructions = QLabel(
|
||||
"<h2>How Queue Works?</h2>"
|
||||
"You can add text files (.txt) directly using the '<b>Add files</b>' button below. "
|
||||
"To add PDF or EPUB files, use the input box in the main window and click the <b>'Add to Queue'</b> button. "
|
||||
"Each file in the queue keeps the configuration settings active when it was added. "
|
||||
"Changing the main window configuration afterward <b>does not</b> affect files already in the queue. "
|
||||
"You can view each file's configuration by hovering over them."
|
||||
)
|
||||
instructions.setAlignment(Qt.AlignLeft)
|
||||
instructions.setWordWrap(True)
|
||||
instructions.setStyleSheet("margin-bottom: 8px;")
|
||||
layout.addWidget(instructions)
|
||||
# Overlay label for empty queue
|
||||
self.empty_overlay = QLabel(
|
||||
"Drag and drop your text files here or use the 'Add files' button.",
|
||||
self.listwidget,
|
||||
)
|
||||
self.empty_overlay.setAlignment(Qt.AlignCenter)
|
||||
self.empty_overlay.setStyleSheet(
|
||||
f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;"
|
||||
)
|
||||
self.empty_overlay.setWordWrap(True)
|
||||
self.empty_overlay.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
||||
self.empty_overlay.hide()
|
||||
# add queue items to the list
|
||||
self.process_queue()
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
button_row.setContentsMargins(0, 0, 0, 0) # optional: no margins for button row
|
||||
button_row.setSpacing(7) # set spacing between buttons
|
||||
# Add files button
|
||||
add_files_button = QPushButton("Add files")
|
||||
add_files_button.setFixedHeight(40)
|
||||
add_files_button.clicked.connect(self.add_more_files)
|
||||
button_row.addWidget(add_files_button)
|
||||
|
||||
# Remove button
|
||||
self.remove_button = QPushButton("Remove selected")
|
||||
self.remove_button.setFixedHeight(40)
|
||||
self.remove_button.clicked.connect(self.remove_item)
|
||||
button_row.addWidget(self.remove_button)
|
||||
|
||||
# Clear button
|
||||
self.clear_button = QPushButton("Clear Queue")
|
||||
self.clear_button.setFixedHeight(40)
|
||||
self.clear_button.clicked.connect(self.clear_queue)
|
||||
button_row.addWidget(self.clear_button)
|
||||
|
||||
layout.addLayout(button_row)
|
||||
layout.addWidget(self.listwidget)
|
||||
|
||||
# Connect selection change to update button state
|
||||
self.listwidget.currentItemChanged.connect(self.update_button_states)
|
||||
self.listwidget.itemSelectionChanged.connect(self.update_button_states)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
layout.addWidget(buttons)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
self.setWindowTitle(title)
|
||||
self.resize(*size)
|
||||
|
||||
self.update_button_states()
|
||||
|
||||
def process_queue(self):
|
||||
"""Process the queue items."""
|
||||
self.listwidget.clear()
|
||||
if not self.queue:
|
||||
self.empty_overlay.resize(self.listwidget.size())
|
||||
self.empty_overlay.show()
|
||||
self.update_button_states()
|
||||
return
|
||||
else:
|
||||
self.empty_overlay.hide()
|
||||
icon_provider = QFileIconProvider()
|
||||
for item in self.queue:
|
||||
# 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
|
||||
|
||||
# Only show the file name, not the full path
|
||||
display_name = display_file_path
|
||||
import os
|
||||
|
||||
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)}"
|
||||
)
|
||||
list_item.setToolTip(tooltip)
|
||||
list_item.setIcon(icon)
|
||||
# Store both paths for context menu
|
||||
list_item.setData(Qt.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 PyQt5.QtWidgets import QMessageBox
|
||||
|
||||
# Remove by index to ensure correct mapping
|
||||
rows = sorted([self.listwidget.row(item) for item in items], reverse=True)
|
||||
# Warn user if removing multiple files
|
||||
if len(rows) > 1:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirm Remove",
|
||||
f"Are you sure you want to remove {len(rows)} selected items from the queue?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
for row in rows:
|
||||
if 0 <= row < len(self.queue):
|
||||
del self.queue[row]
|
||||
self.process_queue()
|
||||
self.update_button_states()
|
||||
|
||||
def clear_queue(self):
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
|
||||
if len(self.queue) > 1:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirm Clear Queue",
|
||||
f"Are you sure you want to clear {len(self.queue)} items from the queue?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
self.queue.clear()
|
||||
self.listwidget.clear()
|
||||
self.empty_overlay.resize(
|
||||
self.listwidget.size()
|
||||
) # Ensure overlay is sized correctly
|
||||
self.empty_overlay.show() # Show the overlay when queue is empty
|
||||
self.update_button_states()
|
||||
|
||||
def get_queue(self):
|
||||
return self.queue
|
||||
|
||||
def get_current_attributes(self):
|
||||
# Fetch current attribute values from the parent abogen GUI
|
||||
attrs = {}
|
||||
parent = self.parent
|
||||
if parent is not None:
|
||||
# lang_code: use parent's get_voice_formula and get_selected_lang
|
||||
if hasattr(parent, "get_voice_formula") and hasattr(
|
||||
parent, "get_selected_lang"
|
||||
):
|
||||
voice_formula = parent.get_voice_formula()
|
||||
attrs["lang_code"] = parent.get_selected_lang(voice_formula)
|
||||
attrs["voice"] = voice_formula
|
||||
else:
|
||||
attrs["lang_code"] = getattr(parent, "selected_lang", "")
|
||||
attrs["voice"] = getattr(parent, "selected_voice", "")
|
||||
# speed
|
||||
if hasattr(parent, "speed_slider"):
|
||||
attrs["speed"] = parent.speed_slider.value() / 100.0
|
||||
else:
|
||||
attrs["speed"] = getattr(parent, "speed", 1.0)
|
||||
# save_option
|
||||
attrs["save_option"] = getattr(parent, "save_option", "")
|
||||
# output_folder
|
||||
attrs["output_folder"] = getattr(parent, "selected_output_folder", "")
|
||||
# subtitle_mode
|
||||
if hasattr(parent, "get_actual_subtitle_mode"):
|
||||
attrs["subtitle_mode"] = parent.get_actual_subtitle_mode()
|
||||
else:
|
||||
attrs["subtitle_mode"] = getattr(parent, "subtitle_mode", "")
|
||||
# output_format
|
||||
attrs["output_format"] = getattr(parent, "selected_format", "")
|
||||
# total_char_count
|
||||
attrs["total_char_count"] = getattr(parent, "char_count", "")
|
||||
# replace_single_newlines
|
||||
attrs["replace_single_newlines"] = getattr(
|
||||
parent, "replace_single_newlines", False
|
||||
)
|
||||
else:
|
||||
# fallback: empty values
|
||||
attrs = {
|
||||
k: ""
|
||||
for k in [
|
||||
"lang_code",
|
||||
"speed",
|
||||
"voice",
|
||||
"save_option",
|
||||
"output_folder",
|
||||
"subtitle_mode",
|
||||
"output_format",
|
||||
"total_char_count",
|
||||
"replace_single_newlines",
|
||||
]
|
||||
}
|
||||
return attrs
|
||||
|
||||
def add_files_from_paths(self, file_paths):
|
||||
from abogen.utils import calculate_text_length
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
import os
|
||||
|
||||
current_attrs = self.get_current_attributes()
|
||||
duplicates = []
|
||||
for file_path in file_paths:
|
||||
|
||||
class QueueItem:
|
||||
pass
|
||||
|
||||
item = QueueItem()
|
||||
item.file_name = file_path
|
||||
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)
|
||||
# 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, "save_base_path", None)
|
||||
== getattr(item, "save_base_path", 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 PyQt5.QtWidgets import QFileDialog
|
||||
from abogen.utils import calculate_text_length # import the function
|
||||
|
||||
# Only allow .txt files
|
||||
files, _ = QFileDialog.getOpenFileNames(
|
||||
self, "Select .txt files", "", "Text Files (*.txt)"
|
||||
)
|
||||
if not files:
|
||||
return
|
||||
self.add_files_from_paths(files)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, "empty_overlay"):
|
||||
self.empty_overlay.resize(self.listwidget.size())
|
||||
|
||||
def update_button_states(self):
|
||||
# Enable Remove if at least one item is selected, else disable
|
||||
if hasattr(self, "remove_button"):
|
||||
selected_count = len(self.listwidget.selectedItems())
|
||||
self.remove_button.setEnabled(selected_count > 0)
|
||||
if selected_count > 1:
|
||||
self.remove_button.setText(f"Remove selected ({selected_count})")
|
||||
else:
|
||||
self.remove_button.setText("Remove selected")
|
||||
# Disable Clear if queue is empty
|
||||
if hasattr(self, "clear_button"):
|
||||
self.clear_button.setEnabled(bool(self.queue))
|
||||
|
||||
def show_context_menu(self, pos):
|
||||
from PyQt5.QtWidgets import QMenu, QAction
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
from PyQt5.QtCore import QUrl
|
||||
import os
|
||||
|
||||
global_pos = self.listwidget.viewport().mapToGlobal(pos)
|
||||
selected_items = self.listwidget.selectedItems()
|
||||
menu = QMenu(self)
|
||||
if len(selected_items) == 1:
|
||||
# Add Remove action
|
||||
remove_action = QAction("Remove this item", self)
|
||||
remove_action.triggered.connect(self.remove_item)
|
||||
menu.addAction(remove_action)
|
||||
|
||||
# Add Open file action
|
||||
open_file_action = QAction("Open file", self)
|
||||
|
||||
def open_file():
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
|
||||
item = selected_items[0]
|
||||
paths = item.data(Qt.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
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(target_path))
|
||||
break
|
||||
|
||||
open_file_action.triggered.connect(open_file)
|
||||
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)
|
||||
item = selected_items[0]
|
||||
paths = item.data(Qt.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)
|
||||
)
|
||||
|
||||
from PyQt5.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.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 PyQt5.QtWidgets import QMessageBox
|
||||
|
||||
# Warn if user changed a lot (e.g., more than 1 items difference)
|
||||
original_count = len(self._original_queue)
|
||||
current_count = len(self.queue)
|
||||
if abs(original_count - current_count) > 1:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirm Cancel",
|
||||
f"Are you sure you want to cancel and discard all changes?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
self.queue.clear()
|
||||
self.queue.extend(deepcopy(self._original_queue))
|
||||
super().reject()
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
if event.key() == Qt.Key_Delete:
|
||||
self.remove_item()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
__all__ = ["QueueManager"]
|
||||
|
||||
@@ -13,5 +13,9 @@ class QueuedItem:
|
||||
subtitle_mode: str
|
||||
output_format: str
|
||||
total_char_count: int
|
||||
replace_single_newlines: bool = False
|
||||
replace_single_newlines: bool = True
|
||||
use_silent_gaps: bool = False
|
||||
subtitle_speed_method: str = "tts"
|
||||
save_base_path: str = None
|
||||
save_chapters_separately: bool = None
|
||||
merge_chapters_at_end: bool = None
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Graceful shutdown - single module, no over-engineering."""
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import gc
|
||||
import signal
|
||||
import sys
|
||||
from typing import Callable
|
||||
|
||||
_CLEANUP_FUNCS: list[Callable[[], None]] = []
|
||||
_EXECUTED = False
|
||||
|
||||
|
||||
def register_cleanup(fn: Callable[[], None]) -> None:
|
||||
"""Register a cleanup function to run on shutdown."""
|
||||
_CLEANUP_FUNCS.append(fn)
|
||||
|
||||
|
||||
def _run_cleanups() -> None:
|
||||
global _EXECUTED
|
||||
if _EXECUTED:
|
||||
return
|
||||
_EXECUTED = True
|
||||
for fn in _CLEANUP_FUNCS:
|
||||
try:
|
||||
fn()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---- Register built-in cleanup functions ----
|
||||
|
||||
# 1. Restore sleep prevention
|
||||
def _restore_sleep() -> None:
|
||||
try:
|
||||
from abogen.utils import prevent_sleep_end
|
||||
prevent_sleep_end()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
register_cleanup(_restore_sleep)
|
||||
|
||||
# 2. Shutdown web UI ConversionService
|
||||
def _shutdown_conversion_service() -> None:
|
||||
try:
|
||||
from abogen.webui.service import get_service
|
||||
svc = get_service()
|
||||
if svc is not None:
|
||||
svc.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
register_cleanup(_shutdown_conversion_service)
|
||||
|
||||
# 3. Clear TTS pipelines and GPU memory
|
||||
def _cleanup_tts_pipelines() -> None:
|
||||
# Clear web UI pipeline cache
|
||||
try:
|
||||
from abogen.webui.conversion_runner import _PIPELINES
|
||||
_PIPELINES.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clear PyQt conversion thread voice cache
|
||||
try:
|
||||
from abogen.pyqt.conversion import ConversionThread
|
||||
if hasattr(ConversionThread, "voice_cache"):
|
||||
ConversionThread.voice_cache.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
gc.collect()
|
||||
|
||||
# Release CUDA cache
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.ipc_collect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
register_cleanup(_cleanup_tts_pipelines)
|
||||
|
||||
# 4. Clear global voice cache
|
||||
def _clear_voice_cache() -> None:
|
||||
try:
|
||||
from abogen.voice_cache import clear_voice_cache
|
||||
clear_voice_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
register_cleanup(_clear_voice_cache)
|
||||
|
||||
# 5. Terminate child processes (ffmpeg, etc.)
|
||||
def _terminate_subprocesses() -> None:
|
||||
try:
|
||||
import psutil
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
current = psutil.Process()
|
||||
for child in current.children(recursive=True):
|
||||
try:
|
||||
child.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
gone, alive = psutil.wait_procs(current.children(recursive=True), timeout=3)
|
||||
for proc in alive:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
register_cleanup(_terminate_subprocesses)
|
||||
|
||||
|
||||
def register_shutdown() -> None:
|
||||
"""Install process-wide shutdown hooks (atexit, signals, Qt)."""
|
||||
if register_shutdown._registered:
|
||||
return
|
||||
register_shutdown._registered = True
|
||||
|
||||
atexit.register(_run_cleanups)
|
||||
|
||||
# POSIX signals
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
signal.signal(sig, _on_signal)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Qt hook
|
||||
try:
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.aboutToQuit.connect(_run_cleanups)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
register_shutdown._registered = False
|
||||
|
||||
|
||||
def _on_signal(signum: int, _frame) -> None:
|
||||
_run_cleanups()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def request_shutdown() -> None:
|
||||
"""Programmatically trigger cleanup (e.g., from GUI closeEvent)."""
|
||||
_run_cleanups()
|
||||
|
||||
|
||||
__all__ = ["register_shutdown", "request_shutdown", "register_cleanup"]
|
||||
@@ -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 available voices (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.tts_plugin.utils import get_voices
|
||||
|
||||
# Create case-insensitive lookup set (done once per call)
|
||||
voice_lookup_lower = {v.lower() for v in get_voices("kokoro")}
|
||||
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 canonical voice names.
|
||||
|
||||
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.tts_plugin.utils import get_voices
|
||||
|
||||
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 get_voices("kokoro") 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 get_voices("kokoro") 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,170 @@
|
||||
"""TTS Plugin Architecture - Public API.
|
||||
|
||||
This package defines the frozen Plugin API for the TTS Plugin Architecture.
|
||||
All public interfaces are fully defined but contain no business logic.
|
||||
|
||||
Public modules:
|
||||
- types: Core domain value objects (AudioFormat, Duration, VoiceSelection, etc.)
|
||||
- errors: Error hierarchy (EngineError and subtypes)
|
||||
- manifest: Plugin manifest types (PluginManifest, EngineManifest, etc.)
|
||||
- engine: Engine and EngineSession protocols
|
||||
- capabilities: Optional capability interfaces (VoiceLister, PreviewGenerator, etc.)
|
||||
- host_context: HostContext dataclass
|
||||
- plugin: Plugin contract (create_engine function signature)
|
||||
- loader: Plugin discovery and loading
|
||||
- plugin_manager: Plugin management and engine creation
|
||||
- utils: Direct utility functions (get_voices, create_pipeline, etc.)
|
||||
|
||||
Usage:
|
||||
from abogen.tts_plugin import (
|
||||
# Types
|
||||
AudioFormat,
|
||||
Duration,
|
||||
VoiceSelection,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
EngineConfig,
|
||||
# Errors
|
||||
EngineError,
|
||||
ModelNotFoundError,
|
||||
ModelLoadError,
|
||||
NetworkError,
|
||||
InvalidInputError,
|
||||
ConfigurationError,
|
||||
CancelledError,
|
||||
InternalError,
|
||||
# Manifest
|
||||
PluginManifest,
|
||||
EngineManifest,
|
||||
VoiceSourceManifest,
|
||||
VoiceManifest,
|
||||
ParameterManifest,
|
||||
AudioFormatManifest,
|
||||
EnumOption,
|
||||
RequirementManifest,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
# Engine
|
||||
Engine,
|
||||
EngineSession,
|
||||
# Capabilities
|
||||
VoiceLister,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
CancelableSession,
|
||||
# Host Context
|
||||
HostContext,
|
||||
HttpClient,
|
||||
# Plugin Manager
|
||||
get_plugin_manager,
|
||||
reset_plugin_manager,
|
||||
# Utils
|
||||
get_voices,
|
||||
get_default_voice,
|
||||
is_plugin_registered,
|
||||
resolve_voice_to_plugin,
|
||||
create_pipeline,
|
||||
)
|
||||
"""
|
||||
|
||||
from abogen.tts_plugin.capabilities import (
|
||||
CancelableSession,
|
||||
PreviewGenerator,
|
||||
StreamingSynthesizer,
|
||||
VoiceLister,
|
||||
)
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.errors import (
|
||||
CancelledError,
|
||||
ConfigurationError,
|
||||
EngineError,
|
||||
InternalError,
|
||||
InvalidInputError,
|
||||
ModelLoadError,
|
||||
ModelNotFoundError,
|
||||
NetworkError,
|
||||
)
|
||||
from abogen.tts_plugin.host_context import HttpClient, HostContext
|
||||
from abogen.tts_plugin.manifest import (
|
||||
AudioFormatManifest,
|
||||
EngineManifest,
|
||||
EnumOption,
|
||||
GpuRequirement,
|
||||
ModelManifest,
|
||||
ParameterManifest,
|
||||
PluginManifest,
|
||||
RequirementManifest,
|
||||
VoiceManifest,
|
||||
VoiceSourceManifest,
|
||||
)
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
Duration,
|
||||
EngineConfig,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
SynthesizedAudio,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
# Plugin Manager and Utils
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager, reset_plugin_manager
|
||||
from abogen.tts_plugin.utils import (
|
||||
create_pipeline,
|
||||
get_default_voice,
|
||||
get_voices,
|
||||
is_plugin_registered,
|
||||
resolve_voice_to_plugin,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Types
|
||||
"AudioFormat",
|
||||
"Duration",
|
||||
"VoiceSelection",
|
||||
"ParameterValues",
|
||||
"SynthesisRequest",
|
||||
"SynthesizedAudio",
|
||||
"EngineConfig",
|
||||
# Errors
|
||||
"EngineError",
|
||||
"ModelNotFoundError",
|
||||
"ModelLoadError",
|
||||
"NetworkError",
|
||||
"InvalidInputError",
|
||||
"ConfigurationError",
|
||||
"CancelledError",
|
||||
"InternalError",
|
||||
# Manifest
|
||||
"PluginManifest",
|
||||
"EngineManifest",
|
||||
"VoiceSourceManifest",
|
||||
"VoiceManifest",
|
||||
"ParameterManifest",
|
||||
"AudioFormatManifest",
|
||||
"EnumOption",
|
||||
"RequirementManifest",
|
||||
"GpuRequirement",
|
||||
"ModelManifest",
|
||||
# Engine
|
||||
"Engine",
|
||||
"EngineSession",
|
||||
# Capabilities
|
||||
"VoiceLister",
|
||||
"PreviewGenerator",
|
||||
"StreamingSynthesizer",
|
||||
"CancelableSession",
|
||||
# Host Context
|
||||
"HostContext",
|
||||
"HttpClient",
|
||||
# Plugin Manager
|
||||
"get_plugin_manager",
|
||||
"reset_plugin_manager",
|
||||
# Utils
|
||||
"get_voices",
|
||||
"get_default_voice",
|
||||
"is_plugin_registered",
|
||||
"resolve_voice_to_plugin",
|
||||
"create_pipeline",
|
||||
]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Capability interfaces for the TTS Plugin Architecture.
|
||||
|
||||
This module defines optional capability interfaces that engines can implement.
|
||||
Capabilities are additive; implementing new capabilities doesn't break old plugins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator, Protocol, runtime_checkable
|
||||
|
||||
from abogen.tts_plugin.manifest import VoiceManifest
|
||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, VoiceSelection
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class VoiceLister(Protocol):
|
||||
"""Protocol for listing available voices.
|
||||
|
||||
Engines that support voice listing should implement this interface.
|
||||
"""
|
||||
|
||||
def listVoices(self, sourceId: str) -> list[VoiceManifest]:
|
||||
"""List available voices for a given source.
|
||||
|
||||
Args:
|
||||
sourceId: The voice source identifier.
|
||||
|
||||
Returns:
|
||||
List of VoiceManifest describing available voices.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PreviewGenerator(Protocol):
|
||||
"""Protocol for generating voice previews.
|
||||
|
||||
Engines that support voice preview should implement this interface.
|
||||
"""
|
||||
|
||||
def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
|
||||
"""Generate a preview audio for a voice.
|
||||
|
||||
Args:
|
||||
voice: Voice selection for the preview.
|
||||
text: Text to use for the preview.
|
||||
|
||||
Returns:
|
||||
SynthesizedAudio with the preview audio data.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class StreamingSynthesizer(Protocol):
|
||||
"""Protocol for streaming synthesis.
|
||||
|
||||
Optional capability of EngineSession, not Engine.
|
||||
Engines that support streaming synthesis should implement this interface.
|
||||
"""
|
||||
|
||||
def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
|
||||
"""Synthesize audio in streaming mode.
|
||||
|
||||
Args:
|
||||
request: The synthesis request.
|
||||
|
||||
Yields:
|
||||
Audio chunks as they become available.
|
||||
|
||||
Raises:
|
||||
CancelledError: If cancel() is called during iteration.
|
||||
EngineError: On synthesis failure.
|
||||
"""
|
||||
...
|
||||
# This is a generator function; implementation will use yield
|
||||
yield b"" # pragma: no cover
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CancelableSession(Protocol):
|
||||
"""Protocol for cancellation support.
|
||||
|
||||
Optional capability for engines that support cancellation.
|
||||
cancel() causes synthesize() to raise CancelledError.
|
||||
"""
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel in-progress synthesis.
|
||||
|
||||
After cancellation, synthesize() raises CancelledError.
|
||||
The session remains usable after cancellation.
|
||||
|
||||
Raises:
|
||||
EngineError: If called after dispose().
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Engine interfaces for the TTS Plugin Architecture.
|
||||
|
||||
This module defines the core Engine and EngineSession protocols.
|
||||
These are the primary interfaces that plugin implementations must satisfy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EngineSession(Protocol):
|
||||
"""Protocol for a session that owns mutable execution state.
|
||||
|
||||
An EngineSession is created by Engine.createSession() and owns
|
||||
mutable execution state isolated from other concurrent work.
|
||||
It is NOT thread-safe.
|
||||
|
||||
Lifecycle:
|
||||
1. Created by Engine.createSession()
|
||||
2. Used for synthesis via synthesize()
|
||||
3. Disposed via dispose()
|
||||
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
|
||||
def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
|
||||
"""Synthesize audio from text.
|
||||
|
||||
Args:
|
||||
request: The synthesis request containing text, voice, parameters, and format.
|
||||
|
||||
Returns:
|
||||
SynthesizedAudio with the synthesized audio data.
|
||||
|
||||
Raises:
|
||||
EngineError: On synthesis failure. Session remains usable after error.
|
||||
EngineError: If called after dispose().
|
||||
"""
|
||||
...
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release session resources.
|
||||
|
||||
This method is idempotent and safe to call multiple times.
|
||||
It never raises exceptions (catches and logs internally).
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Engine(Protocol):
|
||||
"""Protocol for a TTS engine that creates sessions.
|
||||
|
||||
An Engine is a factory for EngineSession instances. It is stateless
|
||||
and thread-safe for createSession().
|
||||
|
||||
Lifecycle:
|
||||
1. Created via create_engine() (plugin contract)
|
||||
2. Sessions created via createSession()
|
||||
3. Disposed via dispose()
|
||||
|
||||
Thread Safety:
|
||||
- createSession() is thread-safe and can be called from any thread.
|
||||
- dispose() must be called after all sessions are disposed.
|
||||
- Disposing engine while sessions are alive violates API contract.
|
||||
"""
|
||||
|
||||
def createSession(self) -> EngineSession:
|
||||
"""Create a new session for synthesis.
|
||||
|
||||
Returns:
|
||||
A new EngineSession instance. Ownership transfers to caller.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure. No partially initialized session is returned.
|
||||
"""
|
||||
...
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release engine resources.
|
||||
|
||||
Caller must ensure all sessions created by this engine are disposed
|
||||
before calling dispose(). Disposing an engine while any session is
|
||||
still alive violates the API contract; behavior is undefined.
|
||||
|
||||
This method is idempotent and safe to call multiple times.
|
||||
It never raises exceptions (catches and logs internally).
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Error hierarchy for the TTS Plugin Architecture.
|
||||
|
||||
This module defines typed exceptions that engines raise.
|
||||
Engines should never raise raw exceptions; they must use EngineError or its subtypes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class EngineError(Exception):
|
||||
"""Base exception for all engine errors.
|
||||
|
||||
All engine operations that can fail should raise EngineError or one of its subtypes.
|
||||
After dispose(), all methods except dispose() raise EngineError.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ModelNotFoundError(EngineError):
|
||||
"""Raised when a required model is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ModelLoadError(EngineError):
|
||||
"""Raised when a model fails to load."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NetworkError(EngineError):
|
||||
"""Raised when a network operation fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidInputError(EngineError):
|
||||
"""Raised when invalid input is provided to the engine."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ConfigurationError(EngineError):
|
||||
"""Raised when there is a configuration error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class CancelledError(EngineError):
|
||||
"""Raised when an operation is cancelled.
|
||||
|
||||
This is raised by synthesize() when cancel() is called during synthesis.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InternalError(EngineError):
|
||||
"""Raised when an internal engine error occurs."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Host context for the TTS Plugin Architecture.
|
||||
|
||||
This module defines the HostContext dataclass that provides minimal
|
||||
host services to plugins. It is the only interface through which
|
||||
plugins can access host functionality.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class HttpClient(Protocol):
|
||||
"""Protocol for HTTP client provided by host.
|
||||
|
||||
Plugins can use this for network requests (e.g., API-based engines).
|
||||
"""
|
||||
|
||||
def get(self, url: str, **kwargs: object) -> object:
|
||||
"""Perform an HTTP GET request."""
|
||||
...
|
||||
|
||||
def post(self, url: str, **kwargs: object) -> object:
|
||||
"""Perform an HTTP POST request."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostContext:
|
||||
"""Minimal host context provided to plugins.
|
||||
|
||||
Contains only essential host services. No business logic.
|
||||
|
||||
Attributes:
|
||||
config_dir: Directory for API keys, preferences, and configuration.
|
||||
logger: Logger for plugin logging.
|
||||
http_client: HTTP client for network requests.
|
||||
"""
|
||||
|
||||
config_dir: Path
|
||||
logger: logging.Logger
|
||||
http_client: HttpClient
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Plugin loader infrastructure for the TTS Plugin Architecture.
|
||||
|
||||
This module provides functionality to discover, import, validate, and load
|
||||
TTS plugins. It handles both valid and invalid plugins, providing diagnostic
|
||||
messages for errors.
|
||||
|
||||
The loader does NOT:
|
||||
- Create Engine instances (that's the plugin's create_engine() responsibility)
|
||||
- Manage plugin lifecycle (that's the Plugin Manager's responsibility)
|
||||
- Implement any TTS engine functionality
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from abogen.tts_plugin.manifest import ModelManifest, PluginManifest
|
||||
|
||||
|
||||
# Host API version for compatibility checking
|
||||
HOST_API_VERSION = "1.0"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginLoadError:
|
||||
"""Diagnostic information for a failed plugin load.
|
||||
|
||||
Attributes:
|
||||
plugin_id: Plugin identifier if available, otherwise directory name.
|
||||
path: Path to the plugin directory.
|
||||
errors: List of error messages describing what went wrong.
|
||||
"""
|
||||
|
||||
plugin_id: str
|
||||
path: Path
|
||||
errors: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginLoadResult:
|
||||
"""Result of loading a plugin.
|
||||
|
||||
Attributes:
|
||||
success: Whether the plugin loaded successfully.
|
||||
manifest: The plugin manifest if successful.
|
||||
model_requirements: Model requirements if successful.
|
||||
create_engine: The create_engine function if successful.
|
||||
module: The plugin module if successful.
|
||||
error: Error information if failed.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
manifest: PluginManifest | None = None
|
||||
model_requirements: tuple[ModelManifest, ...] | None = None
|
||||
create_engine: Callable[..., Any] | None = None
|
||||
module: types.ModuleType | None = None
|
||||
error: PluginLoadError | None = None
|
||||
|
||||
|
||||
def _parse_api_version(version: str) -> tuple[int, int] | None:
|
||||
"""Parse an api_version string into (major, minor) tuple.
|
||||
|
||||
Args:
|
||||
version: Version string in format "MAJOR.MINOR".
|
||||
|
||||
Returns:
|
||||
Tuple of (major, minor) or None if invalid format.
|
||||
"""
|
||||
match = re.match(r"^(\d+)\.(\d+)$", version)
|
||||
if match:
|
||||
return int(match.group(1)), int(match.group(2))
|
||||
return None
|
||||
|
||||
|
||||
def _check_api_version_compatibility(plugin_version: str) -> str | None:
|
||||
"""Check if plugin api_version is compatible with host.
|
||||
|
||||
Architecture spec:
|
||||
- Format: semver (MAJOR.MINOR)
|
||||
- Compatibility: Host rejects plugin if major version differs
|
||||
- Minor version: backward compatible, Host accepts higher minor
|
||||
|
||||
Args:
|
||||
plugin_version: Plugin's api_version string.
|
||||
|
||||
Returns:
|
||||
Error message if incompatible, None if compatible.
|
||||
"""
|
||||
plugin_ver = _parse_api_version(plugin_version)
|
||||
if plugin_ver is None:
|
||||
return f"Invalid api_version format: '{plugin_version}'. Expected format: MAJOR.MINOR"
|
||||
|
||||
host_ver = _parse_api_version(HOST_API_VERSION)
|
||||
if host_ver is None:
|
||||
return f"Invalid host api_version format: '{HOST_API_VERSION}'"
|
||||
|
||||
if plugin_ver[0] != host_ver[0]:
|
||||
return (
|
||||
f"api_version major mismatch: plugin={plugin_ver[0]}, host={host_ver[0]}. "
|
||||
f"Major version must match for compatibility."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validate_manifest(module: types.ModuleType, plugin_dir: Path) -> list[str]:
|
||||
"""Validate that a plugin module has required exports.
|
||||
|
||||
Args:
|
||||
module: The imported plugin module.
|
||||
plugin_dir: Path to the plugin directory.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# Check PLUGIN_MANIFEST
|
||||
manifest = getattr(module, "PLUGIN_MANIFEST", None)
|
||||
if manifest is None:
|
||||
errors.append("Missing PLUGIN_MANIFEST export")
|
||||
elif not isinstance(manifest, PluginManifest):
|
||||
errors.append(
|
||||
f"PLUGIN_MANIFEST must be a PluginManifest instance, "
|
||||
f"got {type(manifest).__name__}"
|
||||
)
|
||||
|
||||
# Check MODEL_REQUIREMENTS
|
||||
model_reqs = getattr(module, "MODEL_REQUIREMENTS", None)
|
||||
if model_reqs is None:
|
||||
errors.append("Missing MODEL_REQUIREMENTS export")
|
||||
elif not isinstance(model_reqs, list):
|
||||
errors.append(
|
||||
f"MODEL_REQUIREMENTS must be a list, got {type(model_reqs).__name__}"
|
||||
)
|
||||
else:
|
||||
for i, req in enumerate(model_reqs):
|
||||
if not isinstance(req, ModelManifest):
|
||||
errors.append(
|
||||
f"MODEL_REQUIREMENTS[{i}] must be a ModelManifest instance, "
|
||||
f"got {type(req).__name__}"
|
||||
)
|
||||
|
||||
# Check create_engine
|
||||
create_engine = getattr(module, "create_engine", None)
|
||||
if create_engine is None:
|
||||
errors.append("Missing create_engine export")
|
||||
elif not callable(create_engine):
|
||||
errors.append(
|
||||
f"create_engine must be callable, got {type(create_engine).__name__}"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_capabilities(manifest: PluginManifest) -> list[str]:
|
||||
"""Validate plugin capabilities.
|
||||
|
||||
Args:
|
||||
manifest: The plugin manifest to validate.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# Known capabilities (can be extended)
|
||||
known_capabilities = frozenset({
|
||||
"voice_list",
|
||||
"preview",
|
||||
"voice_clone",
|
||||
"voice_blend",
|
||||
"streaming",
|
||||
"cancel",
|
||||
})
|
||||
|
||||
for cap in manifest.capabilities:
|
||||
if cap not in known_capabilities:
|
||||
errors.append(f"Unknown capability: '{cap}'")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_api_version(manifest: PluginManifest) -> list[str]:
|
||||
"""Validate api_version compatibility.
|
||||
|
||||
Args:
|
||||
manifest: The plugin manifest to validate.
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
error = _check_api_version_compatibility(manifest.api_version)
|
||||
if error:
|
||||
errors.append(error)
|
||||
return errors
|
||||
|
||||
|
||||
def load_plugin_from_dir(plugin_dir: Path) -> PluginLoadResult:
|
||||
"""Load and validate a plugin from a directory.
|
||||
|
||||
The plugin directory must contain an __init__.py that exports:
|
||||
- PLUGIN_MANIFEST: PluginManifest
|
||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
||||
- create_engine: Callable
|
||||
|
||||
Args:
|
||||
plugin_dir: Path to the plugin directory.
|
||||
|
||||
Returns:
|
||||
PluginLoadResult with success status and either plugin data or error info.
|
||||
"""
|
||||
plugin_id = plugin_dir.name
|
||||
errors: list[str] = []
|
||||
|
||||
# Check if directory exists
|
||||
if not plugin_dir.exists():
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=(f"Plugin directory does not exist: {plugin_dir}",),
|
||||
),
|
||||
)
|
||||
|
||||
# Check for __init__.py
|
||||
init_file = plugin_dir / "__init__.py"
|
||||
if not init_file.exists():
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=("Missing __init__.py in plugin directory",),
|
||||
),
|
||||
)
|
||||
|
||||
# Import the module
|
||||
module_name = f"abogen.tts_plugin._loaded.{plugin_id}"
|
||||
try:
|
||||
# Remove from cache if already imported (for testing)
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name, init_file, submodule_search_locations=[]
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=(f"Failed to create module spec for {init_file}",),
|
||||
),
|
||||
)
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as e:
|
||||
# Clean up module from sys.modules on import failure
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=(f"Failed to import plugin module: {e}",),
|
||||
),
|
||||
)
|
||||
|
||||
# Validate manifest
|
||||
manifest_errors = _validate_manifest(module, plugin_dir)
|
||||
errors.extend(manifest_errors)
|
||||
|
||||
# If manifest is valid, perform additional validation
|
||||
manifest = getattr(module, "PLUGIN_MANIFEST", None)
|
||||
if isinstance(manifest, PluginManifest):
|
||||
# Validate api_version
|
||||
api_errors = _validate_api_version(manifest)
|
||||
errors.extend(api_errors)
|
||||
|
||||
# Validate capabilities
|
||||
cap_errors = _validate_capabilities(manifest)
|
||||
errors.extend(cap_errors)
|
||||
|
||||
# Use manifest id if available
|
||||
plugin_id = manifest.id
|
||||
|
||||
# Check if any errors occurred
|
||||
if errors:
|
||||
# Clean up module from sys.modules
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
return PluginLoadResult(
|
||||
success=False,
|
||||
error=PluginLoadError(
|
||||
plugin_id=plugin_id,
|
||||
path=plugin_dir,
|
||||
errors=tuple(errors),
|
||||
),
|
||||
)
|
||||
|
||||
# Get MODEL_REQUIREMENTS
|
||||
model_requirements = tuple(getattr(module, "MODEL_REQUIREMENTS", []))
|
||||
create_engine = getattr(module, "create_engine", None)
|
||||
|
||||
return PluginLoadResult(
|
||||
success=True,
|
||||
manifest=manifest,
|
||||
model_requirements=model_requirements,
|
||||
create_engine=create_engine,
|
||||
module=module,
|
||||
)
|
||||
|
||||
|
||||
def discover_plugins(plugin_dirs: list[Path]) -> list[PluginLoadResult]:
|
||||
"""Discover and load plugins from multiple directories.
|
||||
|
||||
Args:
|
||||
plugin_dirs: List of directories to scan for plugins.
|
||||
|
||||
Returns:
|
||||
List of PluginLoadResult, one per plugin directory found.
|
||||
"""
|
||||
results: list[PluginLoadResult] = []
|
||||
|
||||
for plugin_dir in plugin_dirs:
|
||||
if not plugin_dir.exists():
|
||||
continue
|
||||
|
||||
# Scan for subdirectories (each is a potential plugin)
|
||||
for item in sorted(plugin_dir.iterdir()):
|
||||
if item.is_dir() and not item.name.startswith("."):
|
||||
result = load_plugin_from_dir(item)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def load_plugin(
|
||||
plugin_dir: Path,
|
||||
) -> PluginLoadResult:
|
||||
"""Load a single plugin from a directory.
|
||||
|
||||
This is the main entry point for loading a plugin.
|
||||
|
||||
Args:
|
||||
plugin_dir: Path to the plugin directory.
|
||||
|
||||
Returns:
|
||||
PluginLoadResult with success status and either plugin data or error info.
|
||||
"""
|
||||
return load_plugin_from_dir(plugin_dir)
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Plugin manifest types for the TTS Plugin Architecture.
|
||||
|
||||
This module contains static metadata types that describe plugins.
|
||||
These types have no dependencies and are immutable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioFormatManifest:
|
||||
"""Manifest describing an audio format.
|
||||
|
||||
Attributes:
|
||||
mime: MIME type of the audio.
|
||||
extension: File extension.
|
||||
"""
|
||||
|
||||
mime: str
|
||||
extension: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnumOption:
|
||||
"""Manifest describing an enum option for a parameter.
|
||||
|
||||
Attributes:
|
||||
value: The enum value.
|
||||
label: Human-readable label.
|
||||
"""
|
||||
|
||||
value: str
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterManifest:
|
||||
"""Manifest describing a synthesis parameter.
|
||||
|
||||
Attributes:
|
||||
id: Parameter identifier.
|
||||
name: Human-readable name.
|
||||
description: Parameter description.
|
||||
type: Parameter type ("float", "int", "string", "boolean", "enum").
|
||||
default: Default value.
|
||||
min: Minimum value (optional, for numeric types).
|
||||
max: Maximum value (optional, for numeric types).
|
||||
step: Step size (optional, for numeric types).
|
||||
options: Available options (optional, for enum type).
|
||||
unit: Unit of measurement (optional).
|
||||
group: Parameter group (optional).
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
type: str
|
||||
default: Any
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
step: float | None = None
|
||||
options: tuple[EnumOption, ...] = field(default_factory=tuple)
|
||||
unit: str | None = None
|
||||
group: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceManifest:
|
||||
"""Manifest describing a voice.
|
||||
|
||||
Attributes:
|
||||
id: Voice identifier.
|
||||
name: Human-readable name.
|
||||
tags: Voice tags (e.g., language, style).
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
tags: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceSourceManifest:
|
||||
"""Manifest describing a voice source.
|
||||
|
||||
Attributes:
|
||||
id: Voice source identifier.
|
||||
name: Human-readable name.
|
||||
type: Source type ("list", "speaker_id", "clone", "blend", "generate", "none").
|
||||
config: Source-specific configuration.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
config: Any = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineManifest:
|
||||
"""Manifest describing engine capabilities.
|
||||
|
||||
Attributes:
|
||||
voiceSources: Available voice sources.
|
||||
parameters: Available synthesis parameters.
|
||||
audioFormats: Supported audio formats.
|
||||
"""
|
||||
|
||||
voiceSources: tuple[VoiceSourceManifest, ...] = field(default_factory=tuple)
|
||||
parameters: tuple[ParameterManifest, ...] = field(default_factory=tuple)
|
||||
audioFormats: tuple[AudioFormatManifest, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GpuRequirement:
|
||||
"""Manifest describing GPU requirements.
|
||||
|
||||
Attributes:
|
||||
required: Whether GPU is required.
|
||||
type: GPU type (e.g., "cuda", "rocm").
|
||||
memory: Required GPU memory in GB.
|
||||
"""
|
||||
|
||||
required: bool = False
|
||||
type: str | None = None
|
||||
memory: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequirementManifest:
|
||||
"""Manifest describing plugin requirements.
|
||||
|
||||
Attributes:
|
||||
gpu: GPU requirements (optional).
|
||||
memory: Required RAM in GB (optional).
|
||||
internet: Whether internet is required (optional).
|
||||
"""
|
||||
|
||||
gpu: GpuRequirement | None = None
|
||||
memory: float | None = None
|
||||
internet: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelManifest:
|
||||
"""Manifest describing a model requirement.
|
||||
|
||||
Attributes:
|
||||
id: Model identifier.
|
||||
name: Human-readable name.
|
||||
size: Model size as string (e.g., "100MB", "2GB").
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
size: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginManifest:
|
||||
"""Main manifest for a TTS plugin.
|
||||
|
||||
Attributes:
|
||||
id: Plugin identifier (unique).
|
||||
name: Human-readable name.
|
||||
version: Plugin version.
|
||||
api_version: API version (semver format: MAJOR.MINOR).
|
||||
description: Plugin description.
|
||||
author: Plugin author.
|
||||
capabilities: List of capability identifiers.
|
||||
requires: Plugin requirements.
|
||||
engine: Engine manifest.
|
||||
voices: Optional static voice catalog. None = not declared (use VoiceLister),
|
||||
empty tuple = explicitly no static voices, non-empty = static catalog.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
api_version: str
|
||||
description: str
|
||||
author: str
|
||||
capabilities: tuple[str, ...] = field(default_factory=tuple)
|
||||
requires: RequirementManifest = field(default_factory=RequirementManifest)
|
||||
engine: EngineManifest = field(default_factory=EngineManifest)
|
||||
voices: tuple[VoiceManifest, ...] | None = None
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Plugin contract for the TTS Plugin Architecture.
|
||||
|
||||
This module defines the plugin contract that all TTS plugins must implement.
|
||||
Each plugin must export:
|
||||
- PLUGIN_MANIFEST: PluginManifest instance
|
||||
- MODEL_REQUIREMENTS: list of ModelManifest instances
|
||||
- create_engine(): Factory function that creates an Engine
|
||||
|
||||
The create_engine() function is the entry point for plugin activation.
|
||||
It must be atomic: succeed fully or raise and clean up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from abogen.tts_plugin.engine import Engine
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Plugin(Protocol):
|
||||
"""Protocol defining the plugin contract.
|
||||
|
||||
Every TTS plugin must implement this protocol by exporting:
|
||||
- PLUGIN_MANIFEST: PluginManifest
|
||||
- MODEL_REQUIREMENTS: list[ModelManifest]
|
||||
- create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
|
||||
"""
|
||||
|
||||
def create_engine(
|
||||
self,
|
||||
context: HostContext,
|
||||
model_path: Path | None,
|
||||
config: EngineConfig,
|
||||
) -> Engine:
|
||||
"""Create an engine instance.
|
||||
|
||||
This is the factory function that creates an Engine from a plugin.
|
||||
It must be atomic: succeed fully or raise EngineError and clean up.
|
||||
|
||||
Args:
|
||||
context: Host services (config dir, logger, http client).
|
||||
model_path: Resolved model path, or None for cloud/no-model engines.
|
||||
config: Engine initialization settings.
|
||||
|
||||
Returns:
|
||||
A fully initialized Engine instance.
|
||||
|
||||
Raises:
|
||||
EngineError: On failure. Cleans up partially created resources.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Plugin Manager
|
||||
|
||||
Provides a simple interface for consumers to access TTS engines via the
|
||||
new Plugin Architecture. Discovers, loads, and manages plugins from the
|
||||
plugins directory.
|
||||
|
||||
Usage:
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
||||
|
||||
manager = get_plugin_manager()
|
||||
engine = manager.create_engine("kokoro", lang_code="a", device="cpu")
|
||||
session = engine.create_session()
|
||||
try:
|
||||
result = session.synthesize("Hello world")
|
||||
finally:
|
||||
session.dispose()
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
from abogen.tts_plugin.engine import Engine, EngineSession
|
||||
from abogen.tts_plugin.manifest import PluginManifest
|
||||
from abogen.tts_plugin.types import AudioFormat
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""Manages TTS plugins and provides a simple interface for consumers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._plugins: Dict[str, dict] = {}
|
||||
self._engines: Dict[str, Engine] = {}
|
||||
self._loaded = False
|
||||
|
||||
def discover(self, plugins_dir: str = "plugins") -> None:
|
||||
"""Discover and load all plugins from the given directory."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from abogen.tts_plugin.loader import load_plugin_from_dir
|
||||
|
||||
self._plugins.clear()
|
||||
self._engines.clear()
|
||||
|
||||
plugins_path = Path(plugins_dir)
|
||||
if not plugins_path.exists():
|
||||
self._loaded = True
|
||||
return
|
||||
|
||||
for entry in plugins_path.iterdir():
|
||||
if entry.is_dir() and (entry / "__init__.py").exists():
|
||||
try:
|
||||
result = load_plugin_from_dir(entry)
|
||||
if result.success and result.manifest is not None:
|
||||
self._plugins[result.manifest.id] = {
|
||||
"manifest": result.manifest,
|
||||
"create_engine": result.create_engine,
|
||||
"module": result.module,
|
||||
}
|
||||
except Exception as e:
|
||||
# Log error but continue with other plugins
|
||||
print(f"Warning: Failed to load plugin from {entry}: {e}")
|
||||
|
||||
self._loaded = True
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure plugins have been discovered."""
|
||||
if not self._loaded:
|
||||
self.discover()
|
||||
|
||||
def list_plugins(self) -> List[PluginManifest]:
|
||||
"""Return manifests for all loaded plugins."""
|
||||
self._ensure_loaded()
|
||||
return [info["manifest"] for info in self._plugins.values()]
|
||||
|
||||
def get_plugin(self, plugin_id: str) -> Optional[dict]:
|
||||
"""Get plugin info by ID."""
|
||||
self._ensure_loaded()
|
||||
return self._plugins.get(plugin_id)
|
||||
|
||||
def has_plugin(self, plugin_id: str) -> bool:
|
||||
"""Check if a plugin is loaded."""
|
||||
self._ensure_loaded()
|
||||
return plugin_id in self._plugins
|
||||
|
||||
def create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
|
||||
"""Create an engine instance for the given plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: The plugin identifier (e.g., "kokoro")
|
||||
**kwargs: Arguments passed to the engine constructor
|
||||
|
||||
Returns:
|
||||
An Engine instance
|
||||
|
||||
Raises:
|
||||
KeyError: If plugin_id is not found
|
||||
Exception: If engine creation fails
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
if plugin_id not in self._plugins:
|
||||
raise KeyError(f"Plugin not found: {plugin_id}")
|
||||
|
||||
plugin_info = self._plugins[plugin_id]
|
||||
create_engine_func = plugin_info["create_engine"]
|
||||
|
||||
# Create engine using the plugin's factory
|
||||
engine = create_engine_func(**kwargs)
|
||||
return engine
|
||||
|
||||
def get_or_create_engine(self, plugin_id: str, **kwargs: Any) -> Engine:
|
||||
"""Get an existing engine or create a new one.
|
||||
|
||||
Engines are cached by plugin_id. If you need multiple instances
|
||||
with different parameters, use create_engine() directly.
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
|
||||
cache_key = plugin_id
|
||||
if cache_key in self._engines:
|
||||
return self._engines[cache_key]
|
||||
|
||||
engine = self.create_engine(plugin_id, **kwargs)
|
||||
self._engines[cache_key] = engine
|
||||
return engine
|
||||
|
||||
def dispose_all(self) -> None:
|
||||
"""Dispose all cached engines."""
|
||||
for engine in self._engines.values():
|
||||
try:
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
pass # dispose() should never raise
|
||||
self._engines.clear()
|
||||
|
||||
|
||||
# Global singleton
|
||||
_manager: Optional[PluginManager] = None
|
||||
|
||||
|
||||
def get_plugin_manager() -> PluginManager:
|
||||
"""Get the global PluginManager instance."""
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = PluginManager()
|
||||
return _manager
|
||||
|
||||
|
||||
def reset_plugin_manager() -> None:
|
||||
"""Reset the global PluginManager (for testing)."""
|
||||
global _manager
|
||||
if _manager is not None:
|
||||
_manager.dispose_all()
|
||||
_manager = None
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Core domain types for the TTS Plugin Architecture.
|
||||
|
||||
This module contains immutable value objects that form the core domain.
|
||||
These types have zero dependencies and are used across the plugin system.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioFormat:
|
||||
"""Immutable value object representing an audio format.
|
||||
|
||||
Attributes:
|
||||
mime: MIME type of the audio (e.g., "audio/wav", "audio/mpeg").
|
||||
extension: File extension (e.g., "wav", "mp3").
|
||||
"""
|
||||
|
||||
mime: str
|
||||
extension: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Duration:
|
||||
"""Immutable value object representing a time duration.
|
||||
|
||||
Attributes:
|
||||
seconds: Duration in seconds.
|
||||
"""
|
||||
|
||||
seconds: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceSelection:
|
||||
"""Immutable value object for voice selection. Opaque to engine.
|
||||
|
||||
Attributes:
|
||||
source: Voice source identifier (e.g., "builtin", "clone").
|
||||
key: Voice key within the source.
|
||||
payload: Optional payload for clone/blend sources.
|
||||
"""
|
||||
|
||||
source: str
|
||||
key: str
|
||||
payload: Any = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterValues:
|
||||
"""Immutable value object for synthesis parameters. Behaves like Mapping[str, Any].
|
||||
|
||||
Attributes:
|
||||
values: Mapping of parameter names to their values.
|
||||
"""
|
||||
|
||||
values: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SynthesisRequest:
|
||||
"""Immutable value object for a synthesis request.
|
||||
|
||||
Attributes:
|
||||
text: Text to synthesize.
|
||||
voice: Voice selection.
|
||||
parameters: Synthesis parameters.
|
||||
format: Desired audio output format.
|
||||
"""
|
||||
|
||||
text: str
|
||||
voice: VoiceSelection
|
||||
parameters: ParameterValues
|
||||
format: AudioFormat
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SynthesizedAudio:
|
||||
"""Immutable value object for synthesized audio result.
|
||||
|
||||
Attributes:
|
||||
data: Raw audio bytes.
|
||||
format: Audio format of the result.
|
||||
duration: Duration of the audio.
|
||||
"""
|
||||
|
||||
data: bytes
|
||||
format: AudioFormat
|
||||
duration: Duration
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineConfig:
|
||||
"""Immutable configuration of an Engine instance.
|
||||
|
||||
Contains parameters that define how a particular Engine instance is
|
||||
created and that remain constant throughout the lifetime of that Engine.
|
||||
|
||||
Plugin implementations may ignore fields that are not applicable to them.
|
||||
|
||||
Attributes:
|
||||
device: Device to use (e.g., "cpu", "cuda:0").
|
||||
lang_code: Language code for the engine (e.g., "a" for Kokoro English).
|
||||
Plugins that do not require a language code ignore this field.
|
||||
"""
|
||||
|
||||
device: str = "cpu"
|
||||
lang_code: str = "a"
|
||||
@@ -0,0 +1,235 @@
|
||||
"""TTS Plugin Architecture — direct utility functions.
|
||||
|
||||
Provides helpers that replace the former compatibility adapter by
|
||||
calling the Plugin Manager directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterator
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
||||
|
||||
|
||||
def get_voices(plugin_id: str) -> tuple[str, ...]:
|
||||
"""Return the voice-id tuple for *plugin_id*.
|
||||
|
||||
Uses the official Plugin Architecture: PluginManager → Engine → VoiceLister.
|
||||
First checks plugin manifest for static voice catalog.
|
||||
"""
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
manager = get_plugin_manager()
|
||||
if not manager.has_plugin(plugin_id):
|
||||
return ()
|
||||
|
||||
# Check manifest for static voice catalog
|
||||
plugin_info = manager.get_plugin(plugin_id)
|
||||
if plugin_info is not None:
|
||||
manifest = plugin_info.get("manifest")
|
||||
if manifest is not None and manifest.voices is not None:
|
||||
return tuple(v.id for v in manifest.voices)
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=Path(tempfile.gettempdir()),
|
||||
logger=logging.getLogger(f"abogen.utils.{plugin_id}"),
|
||||
http_client=type("_StubHttpClient", (), {
|
||||
"get": staticmethod(lambda url, **kw: None),
|
||||
"post": staticmethod(lambda url, **kw: None),
|
||||
})(),
|
||||
)
|
||||
|
||||
try:
|
||||
engine = manager.create_engine(
|
||||
plugin_id,
|
||||
context=ctx,
|
||||
model_path=None,
|
||||
config=EngineConfig(device="cpu"),
|
||||
)
|
||||
except Exception:
|
||||
return ()
|
||||
|
||||
try:
|
||||
from abogen.tts_plugin.capabilities import VoiceLister
|
||||
|
||||
if isinstance(engine, VoiceLister):
|
||||
manifests = engine.listVoices("builtin")
|
||||
return tuple(v.id for v in manifests)
|
||||
return ()
|
||||
except Exception:
|
||||
return ()
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def get_default_voice(plugin_id: str, fallback: str = "") -> str:
|
||||
"""Return the first voice of *plugin_id*, or *fallback*."""
|
||||
voices = get_voices(plugin_id)
|
||||
return voices[0] if voices else fallback
|
||||
|
||||
|
||||
def is_plugin_registered(plugin_id: str) -> bool:
|
||||
"""Check whether *plugin_id* is loaded by the Plugin Manager."""
|
||||
return get_plugin_manager().has_plugin(plugin_id)
|
||||
|
||||
|
||||
def resolve_voice_to_plugin(spec: str, fallback: str = "kokoro") -> str:
|
||||
"""Determine which plugin owns the given voice specification.
|
||||
|
||||
Resolution rules:
|
||||
1. Empty spec -> fallback
|
||||
2. Kokoro formula (contains '*' or '+') -> "kokoro"
|
||||
3. Exact voice-id match against loaded plugins -> plugin id
|
||||
4. Unknown voice -> fallback
|
||||
"""
|
||||
raw = str(spec or "").strip()
|
||||
if not raw:
|
||||
return fallback
|
||||
|
||||
if "*" in raw or "+" in raw:
|
||||
return "kokoro"
|
||||
|
||||
upper = raw.upper()
|
||||
manager = get_plugin_manager()
|
||||
|
||||
for manifest in manager.list_plugins():
|
||||
for voice_source in manifest.engine.voiceSources:
|
||||
if voice_source.type == "list" and isinstance(voice_source.config, dict):
|
||||
try:
|
||||
engine = manager.create_engine(manifest.id)
|
||||
try:
|
||||
if hasattr(engine, "listVoices"):
|
||||
voice_manifests = engine.listVoices(voice_source.id)
|
||||
voice_ids = [v.id.upper() for v in voice_manifests]
|
||||
if upper in voice_ids:
|
||||
return manifest.id
|
||||
finally:
|
||||
engine.dispose()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""Callable wrapper around Engine / EngineSession.
|
||||
|
||||
Presents the same interface that old callers expect::
|
||||
|
||||
pipeline = create_pipeline("kokoro", lang_code="a", device="cpu")
|
||||
for segment in pipeline(text, voice="af_nova", speed=1.0):
|
||||
audio = segment.audio
|
||||
"""
|
||||
|
||||
def __init__(self, engine: Any, **engine_kwargs: Any) -> None:
|
||||
self._engine = engine
|
||||
self._engine_kwargs = engine_kwargs
|
||||
self._session: Any = None
|
||||
|
||||
def _ensure_session(self) -> Any:
|
||||
if self._session is None:
|
||||
self._session = self._engine.createSession()
|
||||
return self._session
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
text: str,
|
||||
voice: str = "default",
|
||||
speed: float = 1.0,
|
||||
split_pattern: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Iterator[Any]:
|
||||
from abogen.tts_plugin.types import (
|
||||
AudioFormat,
|
||||
ParameterValues,
|
||||
SynthesisRequest,
|
||||
VoiceSelection,
|
||||
)
|
||||
|
||||
session = self._ensure_session()
|
||||
|
||||
params: dict[str, Any] = {"speed": speed}
|
||||
if split_pattern is not None:
|
||||
params["split_pattern"] = split_pattern
|
||||
params.update(kwargs)
|
||||
|
||||
request = SynthesisRequest(
|
||||
text=text,
|
||||
voice=VoiceSelection(source="builtin", key=voice),
|
||||
parameters=ParameterValues(values=params),
|
||||
format=AudioFormat(mime="audio/wav", extension="wav"),
|
||||
)
|
||||
|
||||
result = session.synthesize(request)
|
||||
audio_array = np.frombuffer(result.data, dtype=np.float32)
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class Segment:
|
||||
graphemes: str
|
||||
audio: np.ndarray
|
||||
|
||||
yield Segment(graphemes=text, audio=audio_array)
|
||||
|
||||
def dispose(self) -> None:
|
||||
if self._session is not None:
|
||||
try:
|
||||
self._session.dispose()
|
||||
except Exception:
|
||||
pass
|
||||
self._session = None
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.dispose()
|
||||
|
||||
|
||||
def create_pipeline(
|
||||
plugin_id: str,
|
||||
*,
|
||||
lang_code: str = "a",
|
||||
device: str = "cpu",
|
||||
) -> Pipeline:
|
||||
"""Create a callable TTS pipeline via the Plugin Architecture.
|
||||
|
||||
Builds a proper HostContext and EngineConfig, then delegates to the
|
||||
PluginManager to create the engine. Returns a :class:`Pipeline` whose
|
||||
``__call__`` interface matches the callable protocol used by consumers.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier (e.g., "kokoro", "supertonic").
|
||||
lang_code: Language code for the engine.
|
||||
device: Device to use (e.g., "cpu", "cuda:0").
|
||||
|
||||
Returns:
|
||||
A callable Pipeline instance.
|
||||
"""
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from abogen.tts_plugin.host_context import HostContext
|
||||
from abogen.tts_plugin.types import EngineConfig
|
||||
|
||||
manager = get_plugin_manager()
|
||||
|
||||
ctx = HostContext(
|
||||
config_dir=Path(tempfile.gettempdir()),
|
||||
logger=logging.getLogger(f"abogen.pipeline.{plugin_id}"),
|
||||
http_client=type("_StubHttpClient", (), {
|
||||
"get": staticmethod(lambda url, **kw: None),
|
||||
"post": staticmethod(lambda url, **kw: None),
|
||||
})(),
|
||||
)
|
||||
|
||||
config = EngineConfig(device=device, lang_code=lang_code)
|
||||
|
||||
engine = manager.create_engine(plugin_id, context=ctx, model_path=None, config=config)
|
||||
return Pipeline(engine)
|
||||
+250
-60
@@ -1,23 +1,52 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import warnings
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import re
|
||||
import sys
|
||||
import warnings
|
||||
from threading import Thread
|
||||
from typing import Dict, Optional
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from dotenv import load_dotenv, find_dotenv
|
||||
|
||||
|
||||
def _load_environment() -> None:
|
||||
explicit_path = os.environ.get("ABOGEN_ENV_FILE")
|
||||
if explicit_path:
|
||||
load_dotenv(explicit_path, override=False)
|
||||
return
|
||||
dotenv_path = find_dotenv(usecwd=True)
|
||||
if dotenv_path:
|
||||
load_dotenv(dotenv_path, override=False)
|
||||
|
||||
|
||||
_load_environment()
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
def detect_encoding(file_path):
|
||||
import chardet
|
||||
import charset_normalizer
|
||||
try:
|
||||
import chardet # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
chardet = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
import charset_normalizer # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - optional dependency
|
||||
charset_normalizer = None # type: ignore[assignment]
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
raw_data = f.read()
|
||||
detected_encoding = None
|
||||
for detectors in (charset_normalizer, chardet):
|
||||
if detectors is None:
|
||||
continue
|
||||
try:
|
||||
result = detectors.detect(raw_data)["encoding"]
|
||||
except Exception:
|
||||
@@ -28,6 +57,7 @@ def detect_encoding(file_path):
|
||||
encoding = detected_encoding if detected_encoding else "utf-8"
|
||||
return encoding.lower()
|
||||
|
||||
|
||||
def get_resource_path(package, resource):
|
||||
"""
|
||||
Get the path to a resource file, with fallback to local file system.
|
||||
@@ -75,50 +105,200 @@ def get_resource_path(package, resource):
|
||||
def get_version():
|
||||
"""Return the current version of the application."""
|
||||
try:
|
||||
with open(get_resource_path("/", "VERSION"), "r") as f:
|
||||
version_path = get_resource_path("/", "VERSION")
|
||||
if not version_path:
|
||||
raise FileNotFoundError("VERSION resource missing")
|
||||
with open(version_path, "r") as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
return "Unknown"
|
||||
|
||||
|
||||
# Define config path
|
||||
def get_user_config_path():
|
||||
def ensure_directory(path):
|
||||
resolved = os.path.abspath(os.path.expanduser(str(path)))
|
||||
os.makedirs(resolved, exist_ok=True)
|
||||
return resolved
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_user_settings_dir():
|
||||
override = os.environ.get("ABOGEN_SETTINGS_DIR")
|
||||
if override:
|
||||
return ensure_directory(override)
|
||||
|
||||
data_root = os.environ.get("ABOGEN_DATA") or os.environ.get("ABOGEN_DATA_DIR")
|
||||
if data_root:
|
||||
try:
|
||||
return ensure_directory(os.path.join(data_root, "settings"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
data_mount = "/data"
|
||||
if os.path.isdir(data_mount):
|
||||
try:
|
||||
return ensure_directory(os.path.join(data_mount, "settings"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
# TODO Config directory is changed for Linux and MacOS. But if old config exists, it will be used.
|
||||
# On non‑Windows, prefer ~/.config/abogen if it already exists
|
||||
if platform.system() != "Windows":
|
||||
custom_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen")
|
||||
if os.path.exists(custom_dir):
|
||||
config_dir = custom_dir
|
||||
else:
|
||||
config_dir = user_config_dir(
|
||||
"abogen", appauthor=False, roaming=True, ensure_exists=True
|
||||
)
|
||||
else:
|
||||
# Windows and fallback case
|
||||
config_dir = user_config_dir(
|
||||
"abogen", appauthor=False, roaming=True, ensure_exists=True
|
||||
)
|
||||
legacy_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen")
|
||||
if os.path.exists(legacy_dir):
|
||||
return ensure_directory(legacy_dir)
|
||||
|
||||
return os.path.join(config_dir, "config.json")
|
||||
config_dir = user_config_dir(
|
||||
"abogen", appauthor=False, roaming=True, ensure_exists=True
|
||||
)
|
||||
return ensure_directory(config_dir)
|
||||
|
||||
|
||||
def get_user_config_path():
|
||||
return os.path.join(get_user_settings_dir(), "config.json")
|
||||
|
||||
|
||||
# Define cache path
|
||||
def get_user_cache_path(folder=None):
|
||||
from platformdirs import user_cache_dir
|
||||
@lru_cache(maxsize=1)
|
||||
def get_user_cache_root():
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
cache_dir = user_cache_dir(
|
||||
"abogen", appauthor=False, opinion=True, ensure_exists=True
|
||||
def _try_paths(*paths):
|
||||
last_error = None
|
||||
for candidate in paths:
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
return ensure_directory(candidate)
|
||||
except OSError as exc:
|
||||
last_error = exc
|
||||
logger.debug("Unable to use cache directory %s: %s", candidate, exc)
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
|
||||
def _configure_cache_env(root: Optional[str]) -> None:
|
||||
temp_root = None
|
||||
if root:
|
||||
try:
|
||||
temp_root = ensure_directory(root)
|
||||
except OSError:
|
||||
temp_root = None
|
||||
|
||||
home_dir = os.environ.get("HOME")
|
||||
if not home_dir:
|
||||
home_dir = ensure_directory(os.path.join("/tmp", "abogen-home"))
|
||||
os.environ["HOME"] = home_dir
|
||||
else:
|
||||
home_dir = ensure_directory(home_dir)
|
||||
|
||||
cache_base = os.environ.get("XDG_CACHE_HOME")
|
||||
if cache_base:
|
||||
cache_base = ensure_directory(cache_base)
|
||||
elif temp_root:
|
||||
cache_base = temp_root
|
||||
os.environ["XDG_CACHE_HOME"] = cache_base
|
||||
else:
|
||||
cache_base = ensure_directory(os.path.join(home_dir, ".cache"))
|
||||
os.environ["XDG_CACHE_HOME"] = cache_base
|
||||
|
||||
hf_cache = os.environ.get("HF_HOME")
|
||||
if hf_cache:
|
||||
hf_cache = ensure_directory(hf_cache)
|
||||
elif temp_root:
|
||||
hf_cache = ensure_directory(os.path.join(temp_root, "huggingface"))
|
||||
os.environ["HF_HOME"] = hf_cache
|
||||
else:
|
||||
hf_cache = ensure_directory(os.path.join(cache_base, "huggingface"))
|
||||
os.environ["HF_HOME"] = hf_cache
|
||||
|
||||
for env_var in ("HUGGINGFACE_HUB_CACHE", "TRANSFORMERS_CACHE"):
|
||||
os.environ.setdefault(env_var, hf_cache)
|
||||
|
||||
os.environ.setdefault("ABOGEN_INTERNAL_CACHE_ROOT", cache_base)
|
||||
|
||||
cache_root: Optional[str] = None
|
||||
|
||||
override = os.environ.get("ABOGEN_TEMP_DIR")
|
||||
if override:
|
||||
try:
|
||||
cache_root = ensure_directory(override)
|
||||
except OSError as exc:
|
||||
logger.warning("ABOGEN_TEMP_DIR=%s is not writable: %s", override, exc)
|
||||
|
||||
if cache_root is None:
|
||||
from platformdirs import user_cache_dir
|
||||
|
||||
default_cache = user_cache_dir("abogen", appauthor=False, opinion=True)
|
||||
|
||||
data_root = os.environ.get("ABOGEN_DATA") or os.environ.get("ABOGEN_DATA_DIR")
|
||||
fallback_paths = [
|
||||
default_cache,
|
||||
os.path.join(data_root, "cache") if data_root else None,
|
||||
"/data/cache",
|
||||
"/tmp/abogen-cache",
|
||||
]
|
||||
|
||||
try:
|
||||
cache_root = _try_paths(*fallback_paths)
|
||||
except OSError:
|
||||
# Final safety net – attempt a tmp directory unique to this process.
|
||||
tmp_candidate = os.path.join("/tmp", f"abogen-cache-{os.getpid()}")
|
||||
logger.warning("Falling back to temp cache directory %s", tmp_candidate)
|
||||
cache_root = ensure_directory(tmp_candidate)
|
||||
|
||||
if cache_root is None:
|
||||
raise RuntimeError("Unable to determine cache directory")
|
||||
|
||||
_configure_cache_env(cache_root)
|
||||
return cache_root
|
||||
|
||||
|
||||
def get_internal_cache_root():
|
||||
root = os.environ.get("ABOGEN_INTERNAL_CACHE_ROOT") or os.environ.get(
|
||||
"XDG_CACHE_HOME"
|
||||
)
|
||||
if root:
|
||||
return ensure_directory(root)
|
||||
home_dir = os.environ.get("HOME") or os.path.join("/tmp", "abogen-home")
|
||||
home_dir = ensure_directory(home_dir)
|
||||
return ensure_directory(os.path.join(home_dir, ".cache"))
|
||||
|
||||
|
||||
def get_internal_cache_path(folder=None):
|
||||
base = get_internal_cache_root()
|
||||
if folder:
|
||||
cache_dir = os.path.join(cache_dir, folder)
|
||||
# Ensure the directory exists
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
return cache_dir
|
||||
return ensure_directory(os.path.join(base, folder))
|
||||
return base
|
||||
|
||||
|
||||
_sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes
|
||||
def get_user_cache_path(folder=None):
|
||||
base = get_user_cache_root()
|
||||
if folder:
|
||||
return ensure_directory(os.path.join(base, folder))
|
||||
return base
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_user_output_root():
|
||||
override = os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get(
|
||||
"ABOGEN_OUTPUT_ROOT"
|
||||
)
|
||||
if override:
|
||||
return ensure_directory(override)
|
||||
return ensure_directory(os.path.join(get_user_cache_root(), "outputs"))
|
||||
|
||||
|
||||
def get_user_output_path(folder=None):
|
||||
base = get_user_output_root()
|
||||
if folder:
|
||||
return ensure_directory(os.path.join(base, folder))
|
||||
return base
|
||||
|
||||
|
||||
_sleep_procs: Dict[str, Optional[subprocess.Popen[str]]] = {
|
||||
"Darwin": None,
|
||||
"Linux": None,
|
||||
} # Store sleep prevention processes
|
||||
|
||||
|
||||
def clean_text(text, *args, **kwargs):
|
||||
@@ -155,6 +335,11 @@ def create_process(cmd, stdin=None, text=True, capture_output=False):
|
||||
|
||||
# Determine shell usage: use shell only for string commands
|
||||
use_shell = isinstance(cmd, str)
|
||||
if use_shell:
|
||||
logger.warning(
|
||||
"Security Warning: create_process called with string command. Prefer using a list of arguments to avoid shell injection risks."
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
"shell": use_shell,
|
||||
"stdout": subprocess.PIPE,
|
||||
@@ -177,11 +362,14 @@ def create_process(cmd, stdin=None, text=True, capture_output=False):
|
||||
kwargs["stdin"] = stdin
|
||||
|
||||
if platform.system() == "Windows":
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
startupinfo.wShowWindow = subprocess.SW_HIDE
|
||||
startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined]
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined]
|
||||
startupinfo.wShowWindow = subprocess.SW_HIDE # type: ignore[attr-defined]
|
||||
kwargs.update(
|
||||
{"startupinfo": startupinfo, "creationflags": subprocess.CREATE_NO_WINDOW}
|
||||
{
|
||||
"startupinfo": startupinfo,
|
||||
"creationflags": subprocess.CREATE_NO_WINDOW, # type: ignore[attr-defined]
|
||||
}
|
||||
)
|
||||
|
||||
# Print the command being executed
|
||||
@@ -256,23 +444,23 @@ def calculate_text_length(text):
|
||||
|
||||
def get_gpu_acceleration(enabled):
|
||||
try:
|
||||
import torch
|
||||
from torch.cuda import is_available as cuda_available
|
||||
import torch # type: ignore[import-not-found]
|
||||
from torch.cuda import is_available as cuda_available # type: ignore[import-not-found]
|
||||
|
||||
if not enabled:
|
||||
return "GPU available but using CPU.", False
|
||||
|
||||
|
||||
# Check for Apple Silicon MPS
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
if torch.backends.mps.is_available():
|
||||
return "MPS GPU available and enabled.", True
|
||||
else:
|
||||
return "MPS GPU not available on Apple Silicon. Using CPU.", False
|
||||
|
||||
|
||||
# Check for CUDA
|
||||
if cuda_available():
|
||||
return "CUDA GPU available and enabled.", True
|
||||
|
||||
|
||||
# Gather CUDA diagnostic info if not available
|
||||
try:
|
||||
cuda_devices = torch.cuda.device_count()
|
||||
@@ -295,7 +483,7 @@ def prevent_sleep_start():
|
||||
if system == "Windows":
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(
|
||||
ctypes.windll.kernel32.SetThreadExecutionState( # type: ignore[attr-defined]
|
||||
0x80000000 | 0x00000001 | 0x00000040
|
||||
)
|
||||
elif system == "Darwin":
|
||||
@@ -329,30 +517,32 @@ def prevent_sleep_end():
|
||||
if system == "Windows":
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # ES_CONTINUOUS
|
||||
elif system in ("Darwin", "Linux") and _sleep_procs[system]:
|
||||
try:
|
||||
_sleep_procs[system].terminate()
|
||||
_sleep_procs[system] = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def load_numpy_kpipeline():
|
||||
import numpy as np
|
||||
from kokoro import KPipeline
|
||||
|
||||
return np, KPipeline
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # type: ignore[attr-defined]
|
||||
elif system in ("Darwin", "Linux"):
|
||||
proc = _sleep_procs.get(system)
|
||||
if proc:
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_sleep_procs[system] = None
|
||||
|
||||
|
||||
class LoadPipelineThread(Thread):
|
||||
def __init__(self, callback):
|
||||
def __init__(self, callback, lang_code="a", device="cpu"):
|
||||
super().__init__()
|
||||
self.callback = callback
|
||||
self.lang_code = lang_code
|
||||
self.device = device
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
np_module, kpipeline_class = load_numpy_kpipeline()
|
||||
self.callback(np_module, kpipeline_class, None)
|
||||
from abogen.tts_plugin.utils import create_pipeline
|
||||
|
||||
backend = create_pipeline(
|
||||
"kokoro", lang_code=self.lang_code, device=self.device
|
||||
)
|
||||
self.callback(backend, None)
|
||||
except Exception as e:
|
||||
self.callback(None, None, str(e))
|
||||
self.callback(None, str(e))
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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.tts_plugin.utils import get_voices
|
||||
|
||||
_CACHE_LOCK = threading.Lock()
|
||||
_CACHED_VOICES: Set[str] = set()
|
||||
_BOOTSTRAP_LOCK = threading.Lock()
|
||||
_BOOTSTRAPPED = False
|
||||
|
||||
|
||||
def _normalize_targets(voices: Optional[Iterable[str]]) -> Set[str]:
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
if not voices:
|
||||
return set(kokoro_voices)
|
||||
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 kokoro_voices:
|
||||
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
|
||||
|
||||
|
||||
def clear_voice_cache() -> None:
|
||||
"""Clear the in‑process voice cache (used during shutdown)."""
|
||||
with _CACHE_LOCK:
|
||||
_CACHED_VOICES.clear()
|
||||
global _BOOTSTRAPPED
|
||||
_BOOTSTRAPPED = False
|
||||
+7
-1444
File diff suppressed because it is too large
Load Diff
+49
-24
@@ -1,5 +1,7 @@
|
||||
import re
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
from typing import List, Tuple
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
|
||||
|
||||
# Calls parsing and loads the voice to gpu or cpu
|
||||
@@ -15,38 +17,57 @@ def get_new_voice(pipeline, formula, use_gpu):
|
||||
raise ValueError(f"Failed to create voice: {str(e)}")
|
||||
|
||||
|
||||
# Parse the formula and get the combined voice tensor
|
||||
def parse_voice_formula(pipeline, formula):
|
||||
if not formula.strip():
|
||||
def parse_formula_terms(formula: str) -> List[Tuple[str, float]]:
|
||||
if not formula or not formula.strip():
|
||||
raise ValueError("Empty voice formula")
|
||||
|
||||
# Initialize the weighted sum
|
||||
terms: List[Tuple[str, float]] = []
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
for segment in formula.split("+"):
|
||||
part = segment.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "*" not in part:
|
||||
raise ValueError("Each component must be in the form voice*weight")
|
||||
voice_name, raw_weight = part.split("*", 1)
|
||||
voice_name = voice_name.strip()
|
||||
if voice_name not in kokoro_voices:
|
||||
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
|
||||
|
||||
total_weight = calculate_sum_from_formula(formula)
|
||||
|
||||
# Split the formula into terms
|
||||
voices = formula.split("+")
|
||||
|
||||
for term in voices:
|
||||
# Parse each term (format: "voice_name*0.333")
|
||||
voice_name, weight = term.strip().split("*")
|
||||
weight = float(weight.strip())
|
||||
# normalize the weight
|
||||
weight /= total_weight if total_weight > 0 else 1.0
|
||||
voice_name = voice_name.strip()
|
||||
|
||||
# Get the voice tensor
|
||||
if voice_name not in VOICES_INTERNAL:
|
||||
raise ValueError(f"Unknown voice: {voice_name}")
|
||||
for voice_name, weight in terms:
|
||||
normalized_weight = weight / total_weight if total_weight > 0 else weight
|
||||
|
||||
voice_tensor = pipeline.load_single_voice(voice_name)
|
||||
|
||||
# Add to weighted sum
|
||||
if weighted_sum is None:
|
||||
weighted_sum = weight * voice_tensor
|
||||
weighted_sum = normalized_weight * voice_tensor
|
||||
else:
|
||||
weighted_sum += weight * voice_tensor
|
||||
weighted_sum += normalized_weight * voice_tensor
|
||||
|
||||
if weighted_sum is None:
|
||||
raise ValueError("Voice formula produced no components")
|
||||
|
||||
return weighted_sum
|
||||
|
||||
@@ -55,3 +76,7 @@ def calculate_sum_from_formula(formula):
|
||||
weights = re.findall(r"\* *([\d.]+)", formula)
|
||||
total_sum = sum(float(weight) for weight in weights)
|
||||
return total_sum
|
||||
|
||||
|
||||
def extract_voice_ids(formula: str) -> List[str]:
|
||||
return [voice for voice, _ in parse_formula_terms(formula)]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VoiceMetadata:
|
||||
"""
|
||||
Immutable metadata describing a voice from a TTS backend.
|
||||
|
||||
This model describes a voice independently of any backend implementation.
|
||||
Backends populate these objects; the application consumes them.
|
||||
|
||||
The ``backend_id`` field is set by the backend itself (via
|
||||
``self.metadata.id``) — the application never hardcodes it.
|
||||
This ensures renaming a backend does not require touching voice definitions.
|
||||
"""
|
||||
|
||||
id: str
|
||||
"""Unique voice identifier within the backend (e.g. ``"af_alloy"``, ``"M1"``)."""
|
||||
|
||||
display_name: str
|
||||
"""Human-readable display name (e.g. ``"Alloy"``, ``"Male 1"``)."""
|
||||
|
||||
language: str
|
||||
"""Language code — backend-specific format is acceptable (e.g. ``"a"``, ``"en"``)."""
|
||||
|
||||
gender: str
|
||||
"""Gender category: ``"female"``, ``"male"``, or ``"unknown"``."""
|
||||
|
||||
backend_id: str
|
||||
"""Identifier of the backend that owns this voice (e.g. ``"kokoro"``).
|
||||
|
||||
Set automatically by the backend — never hardcoded in voice definitions.
|
||||
"""
|
||||
+172
-1
@@ -1,5 +1,8 @@
|
||||
import os
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, Iterable, List, Tuple
|
||||
|
||||
from abogen.tts_plugin.utils import get_voices, is_plugin_registered
|
||||
from abogen.utils import get_user_config_path
|
||||
|
||||
|
||||
@@ -57,3 +60,171 @@ def export_profiles(export_path):
|
||||
profiles = load_profiles()
|
||||
with open(export_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
|
||||
|
||||
|
||||
def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]:
|
||||
"""Return profiles in canonical dictionary form."""
|
||||
return load_profiles()
|
||||
|
||||
|
||||
def _normalize_supertonic_voice(value: Any) -> str:
|
||||
raw = str(value or "").strip().upper()
|
||||
supertonic_voices = get_voices("supertonic")
|
||||
return raw if raw in 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 not is_plugin_registered(provider):
|
||||
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]] = []
|
||||
kokoro_voices = get_voices("kokoro")
|
||||
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 kokoro_voices:
|
||||
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,73 @@
|
||||
FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu22.04
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=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 ./
|
||||
RUN pip install uv \
|
||||
&& if [ -n "$TORCH_VERSION" ]; then \
|
||||
uv pip install --system torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \
|
||||
else \
|
||||
uv pip install --system torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
|
||||
fi \
|
||||
&& uv pip install --system . \
|
||||
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 \
|
||||
&& uv pip install --system "mutagen>=1.47.0"
|
||||
|
||||
COPY abogen ./abogen
|
||||
|
||||
# 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 \
|
||||
uv pip install --system 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,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from abogen import shutdown # noqa: F401
|
||||
shutdown.register_shutdown()
|
||||
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
|
||||
# Large books can submit four form fields per chapter. Werkzeug's
|
||||
# defaults reject those requests before the wizard route can process
|
||||
# them, even though the encoded payload is much smaller than the upload
|
||||
# limit above.
|
||||
"MAX_FORM_MEMORY_SIZE": 10 * 1024 * 1024,
|
||||
"MAX_FORM_PARTS": 10_000,
|
||||
}
|
||||
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")
|
||||
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user