pdftts 0.2.0__tar.gz

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.
Files changed (55) hide show
  1. pdftts-0.2.0/.dockerignore +16 -0
  2. pdftts-0.2.0/.github/workflows/publish.yml +30 -0
  3. pdftts-0.2.0/.github/workflows/test.yml +98 -0
  4. pdftts-0.2.0/.gitignore +21 -0
  5. pdftts-0.2.0/.python-version +1 -0
  6. pdftts-0.2.0/CHANGELOG.md +80 -0
  7. pdftts-0.2.0/CONTRIBUTING.md +48 -0
  8. pdftts-0.2.0/Dockerfile +78 -0
  9. pdftts-0.2.0/LICENSE +21 -0
  10. pdftts-0.2.0/PKG-INFO +468 -0
  11. pdftts-0.2.0/README.md +427 -0
  12. pdftts-0.2.0/pyproject.toml +69 -0
  13. pdftts-0.2.0/src/pdftts/__init__.py +8 -0
  14. pdftts-0.2.0/src/pdftts/audio.py +139 -0
  15. pdftts-0.2.0/src/pdftts/batch.py +81 -0
  16. pdftts-0.2.0/src/pdftts/cache.py +130 -0
  17. pdftts-0.2.0/src/pdftts/chapters.py +37 -0
  18. pdftts-0.2.0/src/pdftts/chunk.py +99 -0
  19. pdftts-0.2.0/src/pdftts/clean.py +100 -0
  20. pdftts-0.2.0/src/pdftts/cli.py +275 -0
  21. pdftts-0.2.0/src/pdftts/core.py +92 -0
  22. pdftts-0.2.0/src/pdftts/device.py +88 -0
  23. pdftts-0.2.0/src/pdftts/documents.py +250 -0
  24. pdftts-0.2.0/src/pdftts/engines/__init__.py +73 -0
  25. pdftts-0.2.0/src/pdftts/engines/base.py +61 -0
  26. pdftts-0.2.0/src/pdftts/engines/chatterbox_engine.py +77 -0
  27. pdftts-0.2.0/src/pdftts/engines/kokoro_engine.py +173 -0
  28. pdftts-0.2.0/src/pdftts/engines/miso_engine.py +92 -0
  29. pdftts-0.2.0/src/pdftts/engines/piper_engine.py +77 -0
  30. pdftts-0.2.0/src/pdftts/engines/system_engine.py +57 -0
  31. pdftts-0.2.0/src/pdftts/extract.py +118 -0
  32. pdftts-0.2.0/src/pdftts/library.py +115 -0
  33. pdftts-0.2.0/src/pdftts/ocr.py +49 -0
  34. pdftts-0.2.0/src/pdftts/server.py +591 -0
  35. pdftts-0.2.0/src/pdftts/subtitles.py +59 -0
  36. pdftts-0.2.0/src/pdftts/tts.py +152 -0
  37. pdftts-0.2.0/src/pdftts/tunnel.py +150 -0
  38. pdftts-0.2.0/tests/test_auth.py +79 -0
  39. pdftts-0.2.0/tests/test_batch.py +109 -0
  40. pdftts-0.2.0/tests/test_cache.py +130 -0
  41. pdftts-0.2.0/tests/test_clean.py +83 -0
  42. pdftts-0.2.0/tests/test_dashboard_script.py +131 -0
  43. pdftts-0.2.0/tests/test_documents.py +150 -0
  44. pdftts-0.2.0/tests/test_engines.py +129 -0
  45. pdftts-0.2.0/tests/test_extract.py +24 -0
  46. pdftts-0.2.0/tests/test_kindle.py +87 -0
  47. pdftts-0.2.0/tests/test_languages.py +65 -0
  48. pdftts-0.2.0/tests/test_server_api.py +104 -0
  49. pdftts-0.2.0/tests/test_subtitles.py +52 -0
  50. pdftts-0.2.0/tests/test_timeline.py +38 -0
  51. pdftts-0.2.0/tests/test_tunnel.py +145 -0
  52. pdftts-0.2.0/uv.lock +4802 -0
  53. pdftts-0.2.0/vendor/ocrpdf.swift +38 -0
  54. pdftts-0.2.0/web/index.html +602 -0
  55. pdftts-0.2.0/web/sw.js +77 -0
@@ -0,0 +1,16 @@
1
+ .git
2
+ .github
3
+ .venv
4
+ dist
5
+ build
6
+ *.egg-info
7
+ __pycache__
8
+ .pytest_cache
9
+ tests
10
+ uv.lock
11
+ *.wav
12
+ *.m4a
13
+ *.m4b
14
+ *.mp3
15
+ *.pdf
16
+ *.epub
@@ -0,0 +1,30 @@
1
+ # Publish to PyPI on a tagged release, using a Trusted Publisher — no token is
2
+ # stored anywhere. Inert until the project is claimed on PyPI and this workflow
3
+ # is registered as its publisher; until then, tagging simply does nothing.
4
+ name: publish
5
+
6
+ on:
7
+ release:
8
+ types: [published]
9
+ workflow_dispatch:
10
+
11
+ jobs:
12
+ publish:
13
+ runs-on: ubuntu-latest
14
+ environment: pypi
15
+ permissions:
16
+ id-token: write # what Trusted Publishing authenticates with
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: astral-sh/setup-uv@v5
20
+ - run: uv build
21
+ - name: the wheel carries its assets
22
+ run: |
23
+ uv run --no-project python - <<'PY'
24
+ import glob, zipfile, sys
25
+ names = zipfile.ZipFile(glob.glob("dist/*.whl")[0]).namelist()
26
+ missing = [p for p in ("pdftts/web/index.html", "pdftts/web/sw.js",
27
+ "pdftts/vendor/ocrpdf.swift") if p not in names]
28
+ sys.exit(f"missing from wheel: {missing}" if missing else 0)
29
+ PY
30
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,98 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ concurrency:
10
+ group: ${{ github.workflow }}-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ test:
15
+ # Both platforms matter: OCR is macOS-only, so Linux proves the rest of the
16
+ # pipeline does not quietly depend on it.
17
+ runs-on: ${{ matrix.os }}
18
+ strategy:
19
+ fail-fast: false
20
+ matrix:
21
+ os: [ubuntu-latest, macos-latest]
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ - uses: astral-sh/setup-uv@v5
25
+ with:
26
+ enable-cache: true
27
+ cache-dependency-glob: uv.lock
28
+ - uses: actions/setup-node@v4
29
+ with: { node-version: "22" } # the dashboard-script test executes the page
30
+ - run: uv sync --locked
31
+ - run: uv run pytest -q
32
+
33
+ build:
34
+ # A wheel that omits the dashboard or the OCR helper installs fine and fails
35
+ # at runtime, so check the assets are actually inside it.
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+ - uses: astral-sh/setup-uv@v5
40
+ - run: uv build
41
+ - name: the wheel carries its assets
42
+ run: |
43
+ uv run --no-project python - <<'PY'
44
+ import glob, zipfile, sys
45
+ names = zipfile.ZipFile(glob.glob("dist/*.whl")[0]).namelist()
46
+ missing = [p for p in ("pdftts/web/index.html", "pdftts/web/sw.js",
47
+ "pdftts/vendor/ocrpdf.swift") if p not in names]
48
+ sys.exit(f"missing from wheel: {missing}" if missing else 0)
49
+ PY
50
+ - name: it installs and runs from the wheel alone
51
+ run: |
52
+ uv venv /tmp/probe
53
+ VIRTUAL_ENV=/tmp/probe uv pip install dist/*.whl
54
+ /tmp/probe/bin/pdftts --list-languages
55
+
56
+ docker:
57
+ # This image cannot be built on the machine I develop on — no Docker daemon
58
+ # there — so CI is what proves it works, not a claim in the README.
59
+ runs-on: ubuntu-latest
60
+ steps:
61
+ - uses: actions/checkout@v4
62
+ - uses: docker/setup-buildx-action@v3
63
+ - name: build
64
+ uses: docker/build-push-action@v6
65
+ with:
66
+ context: .
67
+ load: true
68
+ tags: pdftts:ci
69
+ cache-from: type=gha
70
+ cache-to: type=gha,mode=max
71
+ - name: the phonemiser model is baked in, not downloaded at runtime
72
+ run: |
73
+ docker run --rm --entrypoint python pdftts:ci -c \
74
+ "import spacy.util; assert spacy.util.is_package('en_core_web_sm')"
75
+ - name: it runs, and knows what it can do
76
+ run: |
77
+ docker run --rm pdftts:ci --list-languages
78
+ docker run --rm pdftts:ci --list-engines
79
+ - name: it narrates a file and writes a tagged m4b
80
+ run: |
81
+ mkdir -p books
82
+ printf 'The first sentence. A second one follows it. And a third.\n' > books/sample.txt
83
+ docker run --rm --user "$(id -u):$(id -g)" \
84
+ -v "$PWD/books:/books" pdftts:ci /books/sample.txt --m4a --srt
85
+ ls -l books/
86
+ test -s books/sample.wav
87
+ test -s books/sample.m4a
88
+ test -s books/sample.srt
89
+ test "$(stat -c %u books/sample.wav)" = "$(id -u)" # written as the caller
90
+ - name: the dashboard answers over a published port
91
+ run: |
92
+ docker run -d --name dash -p 8765:8765 pdftts:ci --serve --lan
93
+ for i in $(seq 1 60); do
94
+ curl -sf http://127.0.0.1:8765/api/engines >/dev/null && break || sleep 2
95
+ done
96
+ curl -sf http://127.0.0.1:8765/ | grep -q "Drop a document here"
97
+ curl -sf "http://127.0.0.1:8765/api/voices?engine=kokoro" | grep -q af_heart
98
+ docker rm -f dash
@@ -0,0 +1,21 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+
10
+ # Audio output and working files
11
+ *.wav
12
+ *.m4a
13
+ *.mp3
14
+ narration.*
15
+
16
+ # Source documents: never commit copyrighted readings
17
+ samples/*.pdf
18
+ *.pdf
19
+
20
+ # OS
21
+ .DS_Store
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,80 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ ### Added
6
+ - **Resume after an interruption.** Every finished chunk is cached by exactly the
7
+ inputs that determine its audio, so a book that dies at chunk 900 of 1000
8
+ synthesizes 100 chunks on the next run, not 1000. `--no-cache` opts out,
9
+ `--clear-cache` empties it, and the dashboard reports and clears it too.
10
+ - **Folder conversion.** `pdftts shelf/` converts everything readable in a
11
+ directory, `-r` descends into sub-folders, and one unreadable file costs only
12
+ itself — the queue finishes and the failures are listed at the end.
13
+ - **Nine languages, 54 voices.** American and British English, Spanish, French,
14
+ Hindi, Italian and Brazilian Portuguese work on the base install; Japanese and
15
+ Mandarin need one extra each and say so instead of failing at synthesis time.
16
+ `--lang` picks one, `--list-languages` shows them, and the dashboard groups the
17
+ voice picker by language.
18
+ - **Cover art and book metadata in the m4b.** The EPUB jacket is attached as
19
+ cover art, and title, author, language and publication date are written as tags.
20
+ - **Kindle input.** `.mobi`, `.azw`, `.azw3` and `.prc` are unpacked into the
21
+ same XHTML an EPUB holds, so chapters, metadata and cover art come through one
22
+ code path. Pure Python — no Calibre, no external tools.
23
+ - **A Docker image**, built and smoke-tested in CI because the machine I develop
24
+ on has no daemon: it narrates a file and answers on a published port before
25
+ the build is called green. No OCR inside it — Vision is macOS-only.
26
+ - Continuous integration on Linux and macOS, including a check that the built
27
+ wheel actually contains the dashboard and the OCR helper.
28
+ - A release workflow that publishes to PyPI through a Trusted Publisher, so no
29
+ token is ever stored.
30
+
31
+ - **`--tunnel`: a phone on cellular, with nothing installed on it.** Publishes a
32
+ temporary `trycloudflare.com` address for the run and shows it as a QR on the
33
+ Phone tab. The address points at your own machine and dies with the process, so
34
+ two people running `pdftts` get two separate addresses to their own machines
35
+ rather than sharing anyone's server. It refuses to publish without a password,
36
+ generating one if you did not set it, and it ignores any `~/.cloudflared`
37
+ config it finds — otherwise it silently runs *that* tunnel instead.
38
+ - **A password for the dashboard.** `--password` (or `PDFTTS_PASSWORD`) gates
39
+ every route, not just the page — the audio, the library and the service worker
40
+ included. Any username is accepted; the password is what is checked, because a
41
+ single-user dashboard does not need a user list.
42
+ - **`--public-url`**, for when the dashboard sits behind a tunnel or reverse
43
+ proxy: the machine cannot discover its own public hostname, so it is told, and
44
+ the Phone tab offers it as the QR that works on cellular.
45
+
46
+ ### Fixed
47
+ - **New did not start a new session.** The finished narration and its player
48
+ stayed on screen, and appeared under the pairing QR code on the Phone tab too.
49
+ New now clears the last render — it is already saved under Past — and the
50
+ player is scoped to the views it belongs to. A long paste is deliberately kept.
51
+ - **The pairing QR handed out a VPN address.** Asking the routing table for "my
52
+ address" returns the tunnel when a VPN owns the default route, which a phone on
53
+ the same wifi cannot reach. The LAN address and the mesh address are now told
54
+ apart and offered separately — the first for a phone in the house, the second
55
+ for one out on cellular.
56
+ - **The dashboard was dead in every browser.** The service-worker registration
57
+ referenced `refreshOffline` twelve lines before its `const` declaration, which
58
+ throws `ReferenceError` in the temporal dead zone and aborts the entire script:
59
+ no engine list, no voice list, and every button on the page inert. It looked
60
+ like a slow network. The callback is now a hoisted function declaration, and
61
+ the script is executed in a browser-shaped environment in the test suite so
62
+ this class of error cannot ship again.
63
+ - EPUB content is XHTML and was being parsed with the HTML parser, which warned
64
+ on every book and is the less reliable of the two.
65
+ - A Piper-style voice id passed to Kokoro selected a pipeline from its first
66
+ letter alone, so `en_US-amy-medium` narrated English text through the Spanish
67
+ phonemizer instead of being rejected.
68
+ - `language` was written as a container tag, where MP4 ignores it, and as a
69
+ two-letter code, which MP4 does not accept. It is now an ISO 639-2 code on the
70
+ audio stream.
71
+ - Chapter titles were taken from the first line of body text, so an illustrated
72
+ edition put eighty characters of prose — or a plate's caption — in the player's
73
+ chapter menu. They now come from the chapter's own heading, with any caption
74
+ ahead of the chapter marker trimmed off.
75
+ - Tables of contents and lists of illustrations were narrated as prose, arriving
76
+ mid-book as "Chapter: one, two, three, four...". They are recognised and
77
+ skipped.
78
+ - The dashboard re-probed the hardware on every page load — about a second of
79
+ "Checking what this machine can run…" before it could offer a choice. The
80
+ survey is computed once and warmed at startup.
@@ -0,0 +1,48 @@
1
+ # Contributing
2
+
3
+ Bug reports and patches are welcome. This is a small project, so the bar is
4
+ simple: it should keep working on a laptop with no GPU and no network.
5
+
6
+ ## Getting set up
7
+
8
+ ```sh
9
+ git clone https://github.com/JosiahMcj/pdftts.git && cd pdftts
10
+ uv sync
11
+ uv run pytest
12
+ ```
13
+
14
+ That is the whole loop. There is no build step and nothing to configure.
15
+
16
+ ## What I look for in a change
17
+
18
+ - **A test that fails without it.** The tests here are mostly small and
19
+ text-only; they run in a couple of seconds and do not synthesize audio. If a
20
+ change needs a real engine to test, fake the engine — `tests/test_cache.py` has
21
+ one that counts calls.
22
+ - **Measurements over adjectives.** If a change makes something faster, say how
23
+ much faster on what hardware. I would rather ship a blank number than an
24
+ estimate presented as a measurement; the README leaves MisoTTS's speed empty
25
+ for exactly this reason.
26
+ - **No new required dependency** for something that only some users need. Put it
27
+ behind an extra, like `piper`, `chatterbox`, `ja` and `zh` already are, and
28
+ make the failure message name the extra.
29
+ - Comments that explain *why*. The code says what it does.
30
+
31
+ ## Things that are deliberate, not oversights
32
+
33
+ - **OCR is macOS-only.** It calls Apple's Vision framework through a small Swift
34
+ helper. On other platforms a scanned PDF is refused with a clear message rather
35
+ than silently producing an empty file. A Tesseract path would be welcome as
36
+ long as it stays optional.
37
+ - **Column detection assumes one body column.** True two-column journal layouts
38
+ extract in visual order. Fixing that properly means real reading-order
39
+ detection, not a heuristic.
40
+ - **The dashboard has no authentication.** It binds to `127.0.0.1` by default and
41
+ `--lan` says plainly what it exposes. Adding accounts would be a bigger project
42
+ than this is.
43
+
44
+ ## Reporting a bug in the audio
45
+
46
+ Extraction bugs are hard to see and easy to hear. If something reads wrong,
47
+ `pdftts yourfile.pdf --dry-run` prints the exact text that would have been
48
+ spoken — that output, plus the page it came from, is the most useful bug report.
@@ -0,0 +1,78 @@
1
+ # The dashboard and CLI, minus OCR.
2
+ #
3
+ # OCR calls Apple's Vision framework through a Swift helper, so it cannot cross
4
+ # to Linux; a scanned PDF in this image is refused with a clear message rather
5
+ # than silently producing nothing. Everything else — every input format, every
6
+ # output format, Kokoro and Piper, the resume cache, the phone connector — works.
7
+ #
8
+ # docker build -t pdftts .
9
+ # docker run --rm -p 8765:8765 -v "$PWD:/books" pdftts --serve --lan
10
+ # docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/books" pdftts /books/novel.epub --m4b
11
+ #
12
+ # `--user` matters whenever output is written back to a bind mount: the files
13
+ # belong to whoever runs the container, and without it they are written as this
14
+ # image's own user — which usually cannot write to your directory at all.
15
+ #
16
+ # Models and finished chunks live in named volumes, so a rebuilt container does
17
+ # not re-download Kokoro or re-synthesize what it already has:
18
+ # docker run --rm --user "$(id -u):$(id -g)" \
19
+ # -v pdftts-models:/cache/huggingface -v pdftts-cache:/cache/pdftts \
20
+ # -v "$PWD:/books" pdftts /books/novel.epub
21
+
22
+ FROM python:3.12-slim AS build
23
+
24
+ # Torch ships a CUDA build by default and it is gigabytes of driver this image
25
+ # will never use. The CPU index keeps the layer to a fraction of that.
26
+ ENV UV_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cpu \
27
+ UV_INDEX_STRATEGY=unsafe-best-match \
28
+ UV_LINK_MODE=copy \
29
+ UV_PYTHON_DOWNLOADS=never
30
+
31
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
32
+
33
+ WORKDIR /src
34
+ COPY pyproject.toml README.md ./
35
+ COPY src ./src
36
+ COPY web ./web
37
+ COPY vendor ./vendor
38
+ RUN uv venv /opt/venv \
39
+ && VIRTUAL_ENV=/opt/venv uv pip install --no-cache . \
40
+ # Kokoro's English phonemiser calls spacy.cli.download() the first time it runs,
41
+ # which needs pip or uv and a network. Neither belongs in the runtime image, so
42
+ # the model is baked in here and the download never happens.
43
+ && VIRTUAL_ENV=/opt/venv uv run --no-project python -m spacy download en_core_web_sm \
44
+ && VIRTUAL_ENV=/opt/venv uv run --no-project python -c \
45
+ "import spacy.util, sys; sys.exit(0 if spacy.util.is_package('en_core_web_sm') else 'spacy model missing')" \
46
+ && find /opt/venv -name '__pycache__' -type d -prune -exec rm -rf {} +
47
+
48
+
49
+ FROM python:3.12-slim
50
+
51
+ # espeak-ng is Kokoro's fallback phonemiser; ffmpeg is needed for anything that
52
+ # is not a WAV. Both are small next to the model weights.
53
+ RUN apt-get update \
54
+ && apt-get install -y --no-install-recommends ffmpeg espeak-ng \
55
+ && rm -rf /var/lib/apt/lists/*
56
+
57
+ # Not root: this container is handed a directory of the user's own books.
58
+ # The caches sit outside any home directory and are world-writable, so the image
59
+ # still works when it is run as the caller's own uid — which is how anyone
60
+ # writing output back to a bind mount has to run it.
61
+ RUN useradd --create-home --uid 1000 reader \
62
+ && mkdir -p /cache/huggingface /cache/pdftts /data \
63
+ && chmod 1777 /cache /cache/huggingface /cache/pdftts /data
64
+ COPY --from=build /opt/venv /opt/venv
65
+ ENV PATH="/opt/venv/bin:$PATH" \
66
+ HF_HOME=/cache/huggingface \
67
+ XDG_CACHE_HOME=/cache \
68
+ XDG_DATA_HOME=/data \
69
+ HOME=/cache
70
+
71
+ USER reader
72
+ WORKDIR /books
73
+ EXPOSE 8765
74
+
75
+ # --serve alone binds 127.0.0.1, which is unreachable from outside a container.
76
+ # --lan is what makes a published port work, and it says what it exposes.
77
+ ENTRYPOINT ["pdftts"]
78
+ CMD ["--serve", "--lan"]
pdftts-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Josiah McJunkin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.