pdftts 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdftts/__init__.py +8 -0
- pdftts/audio.py +139 -0
- pdftts/batch.py +81 -0
- pdftts/cache.py +130 -0
- pdftts/chapters.py +37 -0
- pdftts/chunk.py +99 -0
- pdftts/clean.py +100 -0
- pdftts/cli.py +275 -0
- pdftts/core.py +92 -0
- pdftts/device.py +88 -0
- pdftts/documents.py +250 -0
- pdftts/engines/__init__.py +73 -0
- pdftts/engines/base.py +61 -0
- pdftts/engines/chatterbox_engine.py +77 -0
- pdftts/engines/kokoro_engine.py +173 -0
- pdftts/engines/miso_engine.py +92 -0
- pdftts/engines/piper_engine.py +77 -0
- pdftts/engines/system_engine.py +57 -0
- pdftts/extract.py +118 -0
- pdftts/library.py +115 -0
- pdftts/ocr.py +49 -0
- pdftts/server.py +591 -0
- pdftts/subtitles.py +59 -0
- pdftts/tts.py +152 -0
- pdftts/tunnel.py +150 -0
- pdftts/vendor/ocrpdf.swift +38 -0
- pdftts/web/index.html +602 -0
- pdftts/web/sw.js +77 -0
- pdftts-0.2.0.dist-info/METADATA +468 -0
- pdftts-0.2.0.dist-info/RECORD +33 -0
- pdftts-0.2.0.dist-info/WHEEL +4 -0
- pdftts-0.2.0.dist-info/entry_points.txt +2 -0
- pdftts-0.2.0.dist-info/licenses/LICENSE +21 -0
pdftts/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""pdftts — turn PDFs and pasted text into spoken audio, entirely offline."""
|
|
2
|
+
|
|
3
|
+
from . import engines
|
|
4
|
+
from .core import Document, from_pdf, from_text, render
|
|
5
|
+
from .device import probe
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
|
+
__all__ = ["Document", "from_pdf", "from_text", "render", "engines", "probe"]
|
pdftts/audio.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Post-processing: compressed delivery formats and playback."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def have_ffmpeg() -> bool:
|
|
10
|
+
return shutil.which("ffmpeg") is not None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
#: ISO 639-1 -> 639-2/B. EPUB declares two-letter codes; MP4 stores three, and
|
|
14
|
+
#: ffmpeg drops a two-letter value without a word. Covers every language pdftts
|
|
15
|
+
#: can narrate plus the common European ones a library is likely to contain;
|
|
16
|
+
#: anything unmapped is left off rather than written wrong.
|
|
17
|
+
_ISO_639_2 = {
|
|
18
|
+
"en": "eng", "es": "spa", "fr": "fre", "hi": "hin", "it": "ita", "pt": "por",
|
|
19
|
+
"ja": "jpn", "zh": "chi", "de": "ger", "nl": "dut", "ru": "rus", "pl": "pol",
|
|
20
|
+
"sv": "swe", "da": "dan", "no": "nor", "fi": "fin", "tr": "tur", "ar": "ara",
|
|
21
|
+
"ko": "kor", "cs": "cze", "el": "gre", "he": "heb", "hu": "hun", "ro": "ron",
|
|
22
|
+
"uk": "ukr", "vi": "vie", "id": "ind", "th": "tha", "ca": "cat", "la": "lat",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def iso_639_2(code: str) -> str:
|
|
27
|
+
"""Three-letter form of a language code, or "" when it cannot be mapped."""
|
|
28
|
+
code = (code or "").strip().lower().replace("_", "-").split("-")[0]
|
|
29
|
+
if len(code) == 3:
|
|
30
|
+
return code
|
|
31
|
+
return _ISO_639_2.get(code, "")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _tag_args(tags: dict) -> list[str]:
|
|
35
|
+
"""ffmpeg -metadata flags. Language belongs to the audio stream in MP4:
|
|
36
|
+
passed as a container tag it is silently dropped."""
|
|
37
|
+
out: list[str] = []
|
|
38
|
+
for key, value in tags.items():
|
|
39
|
+
if key == "language":
|
|
40
|
+
value = iso_639_2(value)
|
|
41
|
+
if not value:
|
|
42
|
+
continue
|
|
43
|
+
out += ["-metadata:s:a:0" if key == "language" else "-metadata", f"{key}={value}"]
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def to_m4a(wav: Path, out: Path | None = None, bitrate: str = "64k", **tags: str) -> Path:
|
|
48
|
+
"""Convert to a tagged m4a — a 47-minute WAV is 130 MB, the m4a is 24 MB."""
|
|
49
|
+
if not have_ffmpeg():
|
|
50
|
+
raise RuntimeError("ffmpeg is required for m4a output (brew install ffmpeg)")
|
|
51
|
+
out = out or wav.with_suffix(".m4a")
|
|
52
|
+
cmd = ["ffmpeg", "-nostdin", "-loglevel", "error", "-y", "-i", str(wav),
|
|
53
|
+
"-c:a", "aac", "-b:a", bitrate, "-ac", "1"]
|
|
54
|
+
cmd += _tag_args(tags)
|
|
55
|
+
cmd.append(str(out))
|
|
56
|
+
subprocess.run(cmd, check=True)
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cover_file(cover: bytes, near: Path) -> Path | None:
|
|
61
|
+
"""Write jacket bytes beside the output so ffmpeg can read them as an input."""
|
|
62
|
+
if not cover:
|
|
63
|
+
return None
|
|
64
|
+
kind = "png" if cover[:8] == b"\x89PNG\r\n\x1a\n" else "jpg"
|
|
65
|
+
path = near.with_name(f".{near.stem}-cover.{kind}")
|
|
66
|
+
path.write_bytes(cover)
|
|
67
|
+
return path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def to_m4b(wav: Path, out: Path | None = None, chapters: str = "",
|
|
71
|
+
bitrate: str = "64k", cover: bytes = b"", **tags: str) -> Path:
|
|
72
|
+
"""Convert to a chaptered m4b — the format audiobook players expect.
|
|
73
|
+
|
|
74
|
+
An m4b remembers your position and exposes chapter navigation; an m4a of the
|
|
75
|
+
same audio does neither. A jacket image is attached as a still video stream
|
|
76
|
+
flagged `attached_pic`, which is how players find cover art in an MP4
|
|
77
|
+
container; without the flag they treat it as a video track and some refuse
|
|
78
|
+
to play the file at all.
|
|
79
|
+
"""
|
|
80
|
+
if not have_ffmpeg():
|
|
81
|
+
raise RuntimeError("ffmpeg is required for m4b output (brew install ffmpeg)")
|
|
82
|
+
out = out or wav.with_suffix(".m4b")
|
|
83
|
+
cmd = ["ffmpeg", "-nostdin", "-loglevel", "error", "-y", "-i", str(wav)]
|
|
84
|
+
meta: Path | None = None
|
|
85
|
+
art = _cover_file(cover, out)
|
|
86
|
+
next_input = 1
|
|
87
|
+
meta_input = art_input = None
|
|
88
|
+
if chapters:
|
|
89
|
+
meta = out.with_suffix(".ffmeta")
|
|
90
|
+
meta.write_text(chapters)
|
|
91
|
+
cmd += ["-i", str(meta)]
|
|
92
|
+
meta_input = next_input
|
|
93
|
+
next_input += 1
|
|
94
|
+
if art:
|
|
95
|
+
cmd += ["-i", str(art)]
|
|
96
|
+
art_input = next_input
|
|
97
|
+
cmd += ["-map", "0:a"]
|
|
98
|
+
if art_input is not None:
|
|
99
|
+
cmd += ["-map", f"{art_input}:v", "-c:v", "mjpeg", "-disposition:v:0", "attached_pic"]
|
|
100
|
+
if meta_input is not None:
|
|
101
|
+
cmd += ["-map_metadata", str(meta_input), "-map_chapters", str(meta_input)]
|
|
102
|
+
cmd += ["-c:a", "aac", "-b:a", bitrate, "-ac", "1"]
|
|
103
|
+
cmd += _tag_args(tags)
|
|
104
|
+
cmd.append(str(out))
|
|
105
|
+
try:
|
|
106
|
+
subprocess.run(cmd, check=True)
|
|
107
|
+
finally:
|
|
108
|
+
for scratch in (meta, art):
|
|
109
|
+
if scratch and scratch.exists():
|
|
110
|
+
scratch.unlink()
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def convert(wav: Path, fmt: str, out: Path | None = None, bitrate: str = "64k",
|
|
115
|
+
**tags: str) -> Path:
|
|
116
|
+
"""Encode to mp3/flac/opus/m4a. Lossless formats ignore the bitrate."""
|
|
117
|
+
codecs = {"mp3": "libmp3lame", "flac": "flac", "opus": "libopus", "m4a": "aac"}
|
|
118
|
+
if fmt not in codecs:
|
|
119
|
+
raise ValueError(f"unsupported format {fmt!r}; choose from {', '.join(codecs)}")
|
|
120
|
+
if not have_ffmpeg():
|
|
121
|
+
raise RuntimeError("ffmpeg is required for compressed output")
|
|
122
|
+
out = out or wav.with_suffix(f".{fmt}")
|
|
123
|
+
cmd = ["ffmpeg", "-nostdin", "-loglevel", "error", "-y", "-i", str(wav),
|
|
124
|
+
"-c:a", codecs[fmt], "-ac", "1"]
|
|
125
|
+
if fmt != "flac":
|
|
126
|
+
cmd += ["-b:a", bitrate]
|
|
127
|
+
cmd += _tag_args(tags)
|
|
128
|
+
cmd.append(str(out))
|
|
129
|
+
subprocess.run(cmd, check=True)
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def play(path: Path, rate: float = 1.0) -> None:
|
|
134
|
+
"""Play through whatever the platform provides; never fatal."""
|
|
135
|
+
for cmd in (["afplay", "-r", str(rate), str(path)],
|
|
136
|
+
["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", str(path)]):
|
|
137
|
+
if shutil.which(cmd[0]):
|
|
138
|
+
subprocess.run(cmd)
|
|
139
|
+
return
|
pdftts/batch.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Convert a whole folder in one run.
|
|
2
|
+
|
|
3
|
+
A shelf of books is the normal case for anyone who wants an audiobook library,
|
|
4
|
+
and doing it one command at a time means babysitting a machine for hours. The
|
|
5
|
+
rule here is that one bad file must not cost you the queue: every source is
|
|
6
|
+
tried, failures are recorded and reported at the end, and the run keeps going.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable, Iterator
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import documents
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Result:
|
|
19
|
+
source: Path
|
|
20
|
+
out: Path | None = None
|
|
21
|
+
minutes: float = 0.0
|
|
22
|
+
reused: int = 0
|
|
23
|
+
error: str = ""
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def ok(self) -> bool:
|
|
27
|
+
return self.error == ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sources(target: Path, recursive: bool = False) -> list[Path]:
|
|
31
|
+
"""Every readable document under `target`, in a stable order.
|
|
32
|
+
|
|
33
|
+
Hidden files and the artefacts of an earlier run (audio, subtitles) are
|
|
34
|
+
skipped, so pointing this at the same folder twice does not try to narrate
|
|
35
|
+
the narrations.
|
|
36
|
+
"""
|
|
37
|
+
if target.is_file():
|
|
38
|
+
return [target]
|
|
39
|
+
walk: Iterable[Path] = target.rglob("*") if recursive else target.iterdir()
|
|
40
|
+
found = [
|
|
41
|
+
p for p in walk
|
|
42
|
+
if p.is_file()
|
|
43
|
+
and p.suffix.lower() in documents.SUFFIXES
|
|
44
|
+
and not p.name.startswith(".")
|
|
45
|
+
]
|
|
46
|
+
return sorted(found)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def plan(targets: Iterable[Path], recursive: bool = False) -> list[Path]:
|
|
50
|
+
"""Flatten several files and folders into one de-duplicated work list."""
|
|
51
|
+
seen: dict[Path, None] = {}
|
|
52
|
+
for target in targets:
|
|
53
|
+
for path in sources(target, recursive=recursive):
|
|
54
|
+
seen.setdefault(path.resolve(), None)
|
|
55
|
+
return list(seen)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def summarise(results: list[Result]) -> str:
|
|
59
|
+
done = [r for r in results if r.ok]
|
|
60
|
+
failed = [r for r in results if not r.ok]
|
|
61
|
+
minutes = sum(r.minutes for r in done)
|
|
62
|
+
reused = sum(r.reused for r in done)
|
|
63
|
+
line = f"{len(done)}/{len(results)} converted, {minutes:.0f} min of audio"
|
|
64
|
+
if reused:
|
|
65
|
+
line += f" ({reused} chunks reused from cache)"
|
|
66
|
+
if failed:
|
|
67
|
+
line += "\nfailed:\n" + "\n".join(f" {r.source.name}: {r.error}" for r in failed)
|
|
68
|
+
return line
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run(paths: list[Path], convert, on_start=None) -> Iterator[Result]:
|
|
72
|
+
"""Apply `convert` to each path, turning any failure into a Result."""
|
|
73
|
+
for path in paths:
|
|
74
|
+
if on_start:
|
|
75
|
+
on_start(path)
|
|
76
|
+
try:
|
|
77
|
+
yield convert(path)
|
|
78
|
+
except KeyboardInterrupt:
|
|
79
|
+
raise
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
yield Result(source=path, error=f"{type(exc).__name__}: {exc}")
|
pdftts/cache.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Per-chunk render cache, so an interrupted book resumes instead of restarting.
|
|
2
|
+
|
|
3
|
+
A long render is a sequence of independent chunks, each costing real seconds of
|
|
4
|
+
CPU. If the process dies at chunk 900 of 1000 — a laptop lid, a dropped SSH
|
|
5
|
+
session, a Ctrl-C — there is no reason to pay for the first 899 again. Every
|
|
6
|
+
finished chunk is written here keyed by exactly the inputs that determine its
|
|
7
|
+
audio, so a re-run replays what is already on disk and synthesizes only the rest.
|
|
8
|
+
|
|
9
|
+
The key deliberately includes voice and speed: changing either changes the audio,
|
|
10
|
+
and silently reusing a stale chunk would be worse than re-rendering it.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import shutil
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _root() -> Path:
|
|
24
|
+
if sys.platform == "darwin":
|
|
25
|
+
base = Path.home() / "Library" / "Caches" / "pdftts"
|
|
26
|
+
elif sys.platform.startswith("win"):
|
|
27
|
+
base = Path.home() / "AppData" / "Local" / "pdftts" / "cache"
|
|
28
|
+
else:
|
|
29
|
+
import os
|
|
30
|
+
|
|
31
|
+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "pdftts"
|
|
32
|
+
return base / "chunks"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
ROOT = _root()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def key(text: str, engine: str, voice: str, speed: float, sample_rate: int) -> str:
|
|
39
|
+
"""Everything that changes the audio, and nothing that does not."""
|
|
40
|
+
seed = "\x1f".join([engine, voice, f"{speed:.4f}", str(sample_rate), text])
|
|
41
|
+
return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:32]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Store:
|
|
46
|
+
"""A cache rooted at `root`. Disabled stores are no-ops, so callers need no branches."""
|
|
47
|
+
root: Path = ROOT
|
|
48
|
+
enabled: bool = True
|
|
49
|
+
hits: int = 0
|
|
50
|
+
misses: int = 0
|
|
51
|
+
|
|
52
|
+
def _paths(self, digest: str) -> tuple[Path, Path]:
|
|
53
|
+
# One subdirectory per two hex chars keeps any single directory small.
|
|
54
|
+
shard = self.root / digest[:2]
|
|
55
|
+
return shard / f"{digest}.npy", shard / f"{digest}.json"
|
|
56
|
+
|
|
57
|
+
def get(self, digest: str):
|
|
58
|
+
"""Return (samples, words) if this chunk was rendered before, else None."""
|
|
59
|
+
if not self.enabled:
|
|
60
|
+
return None
|
|
61
|
+
wav_path, meta_path = self._paths(digest)
|
|
62
|
+
if not (wav_path.exists() and meta_path.exists()):
|
|
63
|
+
self.misses += 1
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
import numpy as np
|
|
67
|
+
|
|
68
|
+
samples = np.load(wav_path)
|
|
69
|
+
words = [tuple(w) for w in json.loads(meta_path.read_text())["words"]]
|
|
70
|
+
except Exception:
|
|
71
|
+
# A half-written or corrupt entry is a miss, never a crash.
|
|
72
|
+
wav_path.unlink(missing_ok=True)
|
|
73
|
+
meta_path.unlink(missing_ok=True)
|
|
74
|
+
self.misses += 1
|
|
75
|
+
return None
|
|
76
|
+
self.hits += 1
|
|
77
|
+
return samples, words
|
|
78
|
+
|
|
79
|
+
def put(self, digest: str, samples, words: list) -> None:
|
|
80
|
+
if not self.enabled:
|
|
81
|
+
return
|
|
82
|
+
wav_path, meta_path = self._paths(digest)
|
|
83
|
+
wav_path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
try:
|
|
85
|
+
import numpy as np
|
|
86
|
+
|
|
87
|
+
# Write to a temp name and rename, so a kill mid-write cannot leave
|
|
88
|
+
# a truncated .npy that later reads back as valid-looking audio.
|
|
89
|
+
# np.save() appends ".npy" to a *path* that lacks it, which would
|
|
90
|
+
# defeat the rename — hand it an open file object instead.
|
|
91
|
+
tmp = wav_path.with_name(wav_path.name + ".part")
|
|
92
|
+
with tmp.open("wb") as fh:
|
|
93
|
+
np.save(fh, samples)
|
|
94
|
+
tmp.replace(wav_path)
|
|
95
|
+
meta_path.write_text(json.dumps(
|
|
96
|
+
{"words": [list(w) for w in words], "saved": time.time()}))
|
|
97
|
+
except Exception:
|
|
98
|
+
for scratch in (wav_path, meta_path, wav_path.with_name(wav_path.name + ".part")):
|
|
99
|
+
scratch.unlink(missing_ok=True)
|
|
100
|
+
|
|
101
|
+
def usage(self) -> int:
|
|
102
|
+
if not self.root.exists():
|
|
103
|
+
return 0
|
|
104
|
+
return sum(f.stat().st_size for f in self.root.rglob("*") if f.is_file())
|
|
105
|
+
|
|
106
|
+
def clear(self) -> int:
|
|
107
|
+
freed = self.usage()
|
|
108
|
+
shutil.rmtree(self.root, ignore_errors=True)
|
|
109
|
+
return freed
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def prune(older_than_days: float = 30.0, root: Path = ROOT) -> int:
|
|
113
|
+
"""Drop entries untouched for a while. Returns bytes freed."""
|
|
114
|
+
if not root.exists():
|
|
115
|
+
return 0
|
|
116
|
+
cutoff = time.time() - older_than_days * 86_400
|
|
117
|
+
freed = 0
|
|
118
|
+
for meta_path in root.rglob("*.json"):
|
|
119
|
+
try:
|
|
120
|
+
saved = json.loads(meta_path.read_text()).get("saved", 0)
|
|
121
|
+
except Exception:
|
|
122
|
+
saved = 0
|
|
123
|
+
if saved >= cutoff:
|
|
124
|
+
continue
|
|
125
|
+
wav_path = meta_path.with_suffix(".npy")
|
|
126
|
+
for f in (wav_path, meta_path):
|
|
127
|
+
if f.exists():
|
|
128
|
+
freed += f.stat().st_size
|
|
129
|
+
f.unlink()
|
|
130
|
+
return freed
|
pdftts/chapters.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Map document chapters onto the finished audio timeline."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Marker:
|
|
9
|
+
title: str
|
|
10
|
+
start: float
|
|
11
|
+
end: float
|
|
12
|
+
|
|
13
|
+
def as_dict(self) -> dict:
|
|
14
|
+
return {"title": self.title, "start": round(self.start, 3),
|
|
15
|
+
"end": round(self.end, 3)}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def markers(titles: list[str], part_chapter: list[int],
|
|
19
|
+
part_spans: list[tuple[float, float]]) -> list[Marker]:
|
|
20
|
+
"""Turn per-chunk chapter ownership into one time span per chapter."""
|
|
21
|
+
out: list[Marker] = []
|
|
22
|
+
for idx, title in enumerate(titles):
|
|
23
|
+
mine = [span for span, owner in zip(part_spans, part_chapter) if owner == idx]
|
|
24
|
+
if not mine:
|
|
25
|
+
continue
|
|
26
|
+
out.append(Marker(title, mine[0][0], mine[-1][1]))
|
|
27
|
+
return out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ffmetadata(marks: list[Marker]) -> str:
|
|
31
|
+
"""ffmpeg chapter metadata — what makes an m4b navigable in a player."""
|
|
32
|
+
lines = [";FFMETADATA1"]
|
|
33
|
+
for m in marks:
|
|
34
|
+
lines += ["[CHAPTER]", "TIMEBASE=1/1000",
|
|
35
|
+
f"START={int(m.start * 1000)}", f"END={int(m.end * 1000)}",
|
|
36
|
+
f"title={m.title}"]
|
|
37
|
+
return "\n".join(lines) + "\n"
|
pdftts/chunk.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Split text into synthesizer-sized pieces at real sentence boundaries.
|
|
2
|
+
|
|
3
|
+
Naive splitting on `[.!?]` breaks inside initials and list markers, which is
|
|
4
|
+
inaudible as text and very audible as speech: the synthesizer treats "…by T. S."
|
|
5
|
+
as a finished sentence, drops its pitch, pauses, and then restarts on "Eliot".
|
|
6
|
+
Across a chapter that reads as constant glitching.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
# Kokoro degrades on very long inputs; ~380 characters keeps prosody natural
|
|
13
|
+
# while staying long enough that sentences are not clipped into fragments.
|
|
14
|
+
DEFAULT_LIMIT = 380
|
|
15
|
+
MIN_CHUNK = 60 # anything shorter gets folded into a neighbour
|
|
16
|
+
|
|
17
|
+
_SENTINEL = "\x00"
|
|
18
|
+
|
|
19
|
+
# Periods that do NOT end a sentence.
|
|
20
|
+
_ABBREV = re.compile(
|
|
21
|
+
r"""(?:
|
|
22
|
+
\b[A-Z]\. # initials: T. S. Eliot
|
|
23
|
+
| \b(?:Mr|Mrs|Ms|Dr|Prof|Rev|St|Jr|Sr|Hon|Gen|Col|Capt)\.
|
|
24
|
+
| \b(?:vs|etc|cf|ibid|al|Inc|Ltd|Co)\.
|
|
25
|
+
| \b(?:i\.e|e\.g|a\.m|p\.m|A\.D|B\.C)\.
|
|
26
|
+
| \b(?:ch|chap|pp|p|vol|no|fig|ed|trans)\. # citation shorthand
|
|
27
|
+
)""",
|
|
28
|
+
re.X,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# List markers ("1.", "II.") at the start of a line or straight after a sentence.
|
|
32
|
+
# Matched with the preceding context rather than a lookbehind, because the
|
|
33
|
+
# context is variable width; only the marker's own period is neutralised.
|
|
34
|
+
_MARKER = re.compile(r"(^|[.!?][\"'\u201d]?\s)(\s*)((?:\d{1,3}|[IVXivx]{1,5}))\.", re.M)
|
|
35
|
+
|
|
36
|
+
_SPLIT = re.compile(r"(?<=[.!?])[\"'\u201d\u2019)]?\s+|\n\n+")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _protect(text: str) -> str:
|
|
40
|
+
text = _ABBREV.sub(lambda m: m.group(0).replace(".", _SENTINEL), text)
|
|
41
|
+
return _MARKER.sub(lambda m: f"{m.group(1)}{m.group(2)}{m.group(3)}{_SENTINEL}", text)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _restore(text: str) -> str:
|
|
45
|
+
return text.replace(_SENTINEL, ".")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _merge_runts(parts: list[str], limit: int) -> list[str]:
|
|
49
|
+
"""Fold stubs into a neighbour — a two-word chunk gets read as its own breath."""
|
|
50
|
+
out: list[str] = []
|
|
51
|
+
for part in parts:
|
|
52
|
+
if out and len(part) < MIN_CHUNK and len(out[-1]) + len(part) + 1 <= limit:
|
|
53
|
+
out[-1] = f"{out[-1]} {part}"
|
|
54
|
+
else:
|
|
55
|
+
out.append(part)
|
|
56
|
+
# A short final chunk has no successor to absorb it, so pull it backwards.
|
|
57
|
+
if len(out) > 1 and len(out[-1]) < MIN_CHUNK:
|
|
58
|
+
tail = out.pop()
|
|
59
|
+
out[-1] = f"{out[-1]} {tail}"
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def chunks(text: str, limit: int = DEFAULT_LIMIT) -> list[str]:
|
|
64
|
+
protected = _protect(text)
|
|
65
|
+
out: list[str] = []
|
|
66
|
+
buf = ""
|
|
67
|
+
|
|
68
|
+
for piece in _SPLIT.split(protected):
|
|
69
|
+
piece = (piece or "").strip()
|
|
70
|
+
if not piece:
|
|
71
|
+
continue
|
|
72
|
+
if len(piece) > limit: # a sentence longer than the cap
|
|
73
|
+
for part in re.split(r"(?<=[,;:])\s+", piece):
|
|
74
|
+
if len(buf) + len(part) + 1 > limit and buf:
|
|
75
|
+
out.append(buf)
|
|
76
|
+
buf = part
|
|
77
|
+
else:
|
|
78
|
+
buf = f"{buf} {part}".strip()
|
|
79
|
+
continue
|
|
80
|
+
if len(buf) + len(piece) + 1 > limit and buf:
|
|
81
|
+
out.append(buf)
|
|
82
|
+
buf = piece
|
|
83
|
+
else:
|
|
84
|
+
buf = f"{buf} {piece}".strip()
|
|
85
|
+
if buf:
|
|
86
|
+
out.append(buf)
|
|
87
|
+
|
|
88
|
+
return [_restore(p) for p in _merge_runts(out, limit)]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def sentences(text: str) -> list[str]:
|
|
92
|
+
"""Split into sentences without breaking initials or list markers."""
|
|
93
|
+
parts = [_restore(p).strip() for p in _SPLIT.split(_protect(text))]
|
|
94
|
+
return [p for p in parts if p]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def estimate_minutes(text: str, speed: float = 1.0) -> float:
|
|
98
|
+
"""Rough runtime, calibrated against measured Kokoro output (~925 chars/min)."""
|
|
99
|
+
return len(text) / 925.0 / max(speed, 0.1)
|
pdftts/clean.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Turn extracted page text into something worth listening to.
|
|
2
|
+
|
|
3
|
+
Everything here exists because it was audible: a folio read out mid-sentence,
|
|
4
|
+
a drop cap pronounced as a letter, a vocabulary list run into the next
|
|
5
|
+
paragraph. Print artifacts are invisible on the page and glaring in audio.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import collections
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _folio_key(line: str) -> str:
|
|
14
|
+
"""Normalise a running head so 'Title 175' and 'Title 177' compare equal."""
|
|
15
|
+
return re.sub(r"^\d{1,4}\s*|\s*\d{1,4}$", "", line).strip()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def strip_running_heads(text: str, min_repeats: int = 3) -> str:
|
|
19
|
+
"""Drop page numbers and headers/footers that repeat across pages."""
|
|
20
|
+
lines = text.split("\n")
|
|
21
|
+
counts = collections.Counter(_folio_key(l.strip()) for l in lines if l.strip())
|
|
22
|
+
|
|
23
|
+
def keep(line: str) -> bool:
|
|
24
|
+
t = line.strip()
|
|
25
|
+
if not t:
|
|
26
|
+
return True
|
|
27
|
+
if re.fullmatch(r"[ivxlcdm\d]{1,6}[.,]?", t, re.I): # bare folio
|
|
28
|
+
return False
|
|
29
|
+
if len(t) == 1 and not t.isalpha(): # stray * or dagger
|
|
30
|
+
return False
|
|
31
|
+
key = _folio_key(t)
|
|
32
|
+
return not (key and len(t) < 60 and counts[key] >= min_repeats)
|
|
33
|
+
|
|
34
|
+
return "\n".join(l for l in lines if keep(l))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fix_drop_caps(text: str) -> str:
|
|
38
|
+
"""Rejoin a decorative initial split off from its word.
|
|
39
|
+
|
|
40
|
+
Scanners read a drop cap as its own line and usually mangle the letter
|
|
41
|
+
that follows it, so 'W\\nTe are' is really 'We are'.
|
|
42
|
+
"""
|
|
43
|
+
text = re.sub(r"^([A-Z])\n([A-Z])(?=[a-z])", r"\1", text, flags=re.M)
|
|
44
|
+
return re.sub(r"^([A-Z])\n(?=[a-z])", r"\1", text, flags=re.M)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def punctuate_word_lists(text: str) -> str:
|
|
48
|
+
"""Give vocabulary lists sentence breaks so they are not read as prose."""
|
|
49
|
+
def repl(m: re.Match) -> str:
|
|
50
|
+
words = [w.strip() for w in m.group(0).strip().split("\n") if w.strip()]
|
|
51
|
+
return "\n\n" + ".\n".join(words) + ".\n\n"
|
|
52
|
+
|
|
53
|
+
return re.sub(r"(?:^[A-Za-z][\w'-]*\.?$\n){3,}", repl, text, flags=re.M)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def strip_footnote_markers(text: str) -> str:
|
|
57
|
+
"""Remove reference numerals that would otherwise be read as digits."""
|
|
58
|
+
text = re.sub(r"\[\d{1,3}\]", "", text) # [12]
|
|
59
|
+
text = re.sub(r"(?<=[.,;:!?\"'])\s*\d{1,3}(?=\s|$)", "", text) # trailing 108
|
|
60
|
+
return re.sub(r"\s+\*(?=\s|$)", "", text) # lone asterisk
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def normalize_for_speech(text: str) -> str:
|
|
64
|
+
"""Remove or spell out characters the synthesizer mangles.
|
|
65
|
+
|
|
66
|
+
Stray typographic marks left by extraction (bullets, tildes, brackets around
|
|
67
|
+
editorial insertions) are silent on the page but come out as clicks, pauses
|
|
68
|
+
or literal words in the audio.
|
|
69
|
+
"""
|
|
70
|
+
text = text.replace("\u00b7", " ").replace("~", " ").replace("\u2022", " ")
|
|
71
|
+
text = re.sub(r"[\[\]{}<>|_*^]", " ", text)
|
|
72
|
+
text = re.sub(r"(?<=\w)/(?=\w)", " or ", text) # and/or -> and or
|
|
73
|
+
text = re.sub(r"(?<!\w)/(?!\w)", " ", text) # stray slashes from bad OCR
|
|
74
|
+
# Punctuation debris left where extraction mangled a heading, e.g. ( /" —
|
|
75
|
+
# silent on the page, audible as clicks and false pauses.
|
|
76
|
+
text = re.sub(r"(?<=\s)[^\w\s]{2,}(?=\s)", " ", text)
|
|
77
|
+
text = re.sub(r"\(\s*(?=[^\w(]*\s)", " ", text)
|
|
78
|
+
text = text.replace("&", " and ")
|
|
79
|
+
text = re.sub(r"\.{3,}", ", ", text) # ellipses read as pauses
|
|
80
|
+
text = re.sub(r"(?<=[a-zA-Z])-{2,}(?=[a-zA-Z])", ", ", text)
|
|
81
|
+
return re.sub(r"[ \t]{2,}", " ", text)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def unwrap(text: str) -> str:
|
|
85
|
+
"""Undo print line-wrapping so sentences reach the synthesizer whole."""
|
|
86
|
+
text = text.replace("", "") # soft hyphen
|
|
87
|
+
text = re.sub(r"(\w)-\n(?=\w)", r"\1", text) # de-hyphenate
|
|
88
|
+
text = re.sub(r"(?<![.!?:;\n])\n(?!\n)", " ", text) # join soft wraps
|
|
89
|
+
text = re.sub(r"[ \t]+", " ", text)
|
|
90
|
+
return re.sub(r"\n{3,}", "\n\n", text).strip()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def clean(text: str) -> str:
|
|
94
|
+
"""Full pipeline, in the order the fixes depend on each other."""
|
|
95
|
+
text = strip_running_heads(text)
|
|
96
|
+
text = fix_drop_caps(text)
|
|
97
|
+
text = punctuate_word_lists(text)
|
|
98
|
+
text = strip_footnote_markers(text)
|
|
99
|
+
text = normalize_for_speech(text)
|
|
100
|
+
return unwrap(text)
|