readalign 1.1.0__tar.gz → 1.3.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 (40) hide show
  1. {readalign-1.1.0 → readalign-1.3.0}/.gitignore +20 -20
  2. {readalign-1.1.0 → readalign-1.3.0}/CHANGELOG.md +41 -0
  3. {readalign-1.1.0 → readalign-1.3.0}/PKG-INFO +87 -10
  4. {readalign-1.1.0 → readalign-1.3.0}/README.md +86 -9
  5. {readalign-1.1.0 → readalign-1.3.0}/pyproject.toml +1 -1
  6. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/__init__.py +1 -1
  7. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/asr.py +32 -13
  8. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/audio.py +23 -9
  9. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/cli.py +119 -25
  10. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/clock.py +15 -9
  11. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/epub.py +674 -671
  12. readalign-1.3.0/src/readalign/fs.py +74 -0
  13. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/members.py +81 -72
  14. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/package.py +77 -56
  15. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/sidecar.py +23 -5
  16. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/smil.py +4 -3
  17. {readalign-1.1.0 → readalign-1.3.0}/tests/test_asr.py +51 -0
  18. readalign-1.3.0/tests/test_audio.py +145 -0
  19. readalign-1.3.0/tests/test_cli.py +397 -0
  20. readalign-1.3.0/tests/test_fs.py +59 -0
  21. {readalign-1.1.0 → readalign-1.3.0}/tests/test_output.py +183 -1
  22. {readalign-1.1.0 → readalign-1.3.0}/tests/test_sidecar_report.py +58 -0
  23. readalign-1.1.0/src/readalign/fs.py +0 -22
  24. readalign-1.1.0/tests/test_audio.py +0 -40
  25. readalign-1.1.0/tests/test_cli.py +0 -178
  26. {readalign-1.1.0 → readalign-1.3.0}/LICENSE +0 -0
  27. {readalign-1.1.0 → readalign-1.3.0}/scripts/fetch_samples.py +0 -0
  28. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/__main__.py +0 -0
  29. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/align.py +0 -0
  30. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/errors.py +0 -0
  31. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/refine.py +0 -0
  32. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/report.py +0 -0
  33. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/sentences.py +0 -0
  34. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/textnorm.py +0 -0
  35. {readalign-1.1.0 → readalign-1.3.0}/src/readalign/verify.py +0 -0
  36. {readalign-1.1.0 → readalign-1.3.0}/tests/conftest.py +0 -0
  37. {readalign-1.1.0 → readalign-1.3.0}/tests/test_align.py +0 -0
  38. {readalign-1.1.0 → readalign-1.3.0}/tests/test_epub.py +0 -0
  39. {readalign-1.1.0 → readalign-1.3.0}/tests/test_text.py +0 -0
  40. {readalign-1.1.0 → readalign-1.3.0}/tests/test_verify.py +0 -0
@@ -1,20 +1,20 @@
1
- __pycache__/
2
- *.py[cod]
3
- *.egg-info/
4
- .eggs/
5
- build/
6
- dist/
7
- .venv/
8
- venv/
9
- .pytest_cache/
10
- .ruff_cache/
11
- .coverage
12
- htmlcov/
13
- *.epub
14
- !examples/aesop/*.epub
15
- readalign-report.json
16
- *-sidecars/
17
- .readalign-cache/
18
- examples/output/
19
- .DS_Store
20
- Thumbs.db
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ *.epub
14
+ !examples/aesop/*.epub
15
+ readalign-report.json
16
+ *-sidecars/
17
+ .readalign-cache/
18
+ examples/output/
19
+ .DS_Store
20
+ Thumbs.db
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 - 2026-10-25
4
+
5
+ - Fixed: work-directory audio/WAV paths cannot overwrite the input ebook, output EPUB or
6
+ report. Inputs must stay outside the generated sidecar directory when sidecars are enabled.
7
+ - Fixed: chapter-derived subtitle names cannot escape the sidecar directory through Windows
8
+ separators or use reserved device names. Sanitized names still receive collision handling.
9
+ - Added a real surround-AAC to stereo-MP3 regression checking retained chapter marks and
10
+ unchanged source bytes.
11
+ - New: `--audio-format mp3` stream-copies existing MP3 sources and transcodes other codecs
12
+ to MP3 for readers with AAC/Opus decoder limitations. The default `auto` retains the existing
13
+ behavior. Timing uses the prepared audio and the report records the selected format.
14
+ - Fixed: streamed audiobook entries now declare their size before ZIP writing so large files
15
+ select ZIP64 headers instead of failing at the end of the write. Small entries retain their
16
+ existing archive format.
17
+ - Added long-book packaging regressions and evidence-linked reader guidance distinguishing
18
+ codec support, large-track loading, sentence highlighting and narration-position restoration.
19
+
20
+ ## 1.2.0 - 2026-10-22
21
+
22
+ - New: `--report PATH` selects a separate JSON report for each batch build, with guards
23
+ against overwriting input audio, the input/output EPUB or generated sidecars.
24
+ - Fixed: colliding overlay names and URL-special characters in EPUB resource paths.
25
+ Generated audio and CSS also avoid overwriting unrelated existing resources.
26
+ - Fixed: transcript cache identity now hashes prepared audio bytes and includes chapter
27
+ boundaries and the effective language. Malformed cached words are ignored instead of crashing.
28
+ - Fixed: EPUB, report and cache writes replace their destinations only after a complete write.
29
+ - Fixed: work-directory audio aliases, invalid durations, chapter marks and beam sizes are
30
+ rejected; missing epubcheck paths fail before transcription.
31
+ - Fixed: `check` rejects unmanifested audio, overlays pointing outside their associated document,
32
+ empty overlays, duplicate manifest ids and mismatched durations. Corrupt archives produce
33
+ readable errors; audio-probe scratch filenames never come from archive member names.
34
+ - New: `--min-token-match FRACTION` optionally gates builds on the fraction of book tokens
35
+ matched to narration. Values range from 0 to 1; the default 0 preserves existing behaviour.
36
+ A failed gate exits 5 after writing the EPUB, report and enabled sidecars. Its threshold and
37
+ verdict are recorded under `token_match_gate`; drift and epubcheck failures retain precedence.
38
+ - Fixed: chapters with identical basenames in different EPUB directories could overwrite each
39
+ other's WebVTT sidecars. Colliding names now gain a numeric suffix, including case-only
40
+ collisions on Windows, without changing filenames for non-colliding exports.
41
+ - Fixed: the ebook overwrite guard now recognises symbolic links, hard links and path aliases.
42
+ - Fixed: non-finite drift thresholds such as NaN and infinity are rejected before processing.
43
+
3
44
  ## 1.1.0 - 2026-10-04
4
45
 
5
46
  - New: `--verify [JAR]` runs epubcheck on the result right after the build, prints its verdict,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: readalign
3
- Version: 1.1.0
3
+ Version: 1.3.0
4
4
  Summary: Turn an audiobook and the matching ebook you own into one EPUB 3 with Media Overlays.
5
5
  Project-URL: Homepage, https://github.com/Booyaka101/readalign
6
6
  Project-URL: Repository, https://github.com/Booyaka101/readalign
@@ -210,13 +210,23 @@ Drift is how far a sentence's start could be wrong, in seconds.
210
210
  `--drift-threshold` (default 2.5s) is a gate on the maximum, not the average. The EPUB is always
211
211
  written; exceeding the threshold only changes the exit code to 3 so a script can catch it.
212
212
 
213
+ For batch builds, `--min-token-match 0.9` also requires at least 90% of the ebook's normalised
214
+ tokens to match the narration. This optional gate defaults to 0 (disabled). A failed gate exits 5
215
+ but keeps the EPUB, report and enabled sidecars for inspection. The report's `token_match_gate`
216
+ records the threshold and verdict using the unrounded match fraction. Unnarrated front matter
217
+ and abridgements lower this score, so choose a threshold appropriate to your books. This is a
218
+ screening aid, not proof that every sentence is correctly timed. Drift and epubcheck failures
219
+ take precedence over this gate when more than one check fails.
220
+
213
221
  ## How it works
214
222
 
215
223
  1. **ffprobe** reads every audio file. FLAC, WAV, ALAC and WMA are transcoded to AAC because
216
224
  EPUB 3 only takes MP3, MP4 audio and Opus; MP3, M4A/M4B and Opus are stream-copied, so no
217
225
  audio is re-encoded unless it has to be.
218
226
  2. **faster-whisper** transcribes with word timestamps. Transcripts are cached per file, keyed by
219
- content and settings, so a second run over the same audiobook costs nothing.
227
+ a streamed SHA-256 of the prepared audio, chapter boundaries and recogniser settings.
228
+ A second run avoids transcription, but still prepares and hashes the audio. Older cache keys
229
+ are invalidated once; malformed entries are ignored and rebuilt.
220
230
  3. Both sides are normalised to comparable tokens: case folded, accents stripped, punctuation
221
231
  dropped, digits and roman numerals spelled out ("Chapter XVIII" and "chapter eighteen" become
222
232
  the same tokens).
@@ -240,9 +250,11 @@ written; exceeding the threshold only changes the exit code to 3 so a script can
240
250
  --audio PATH a file, a directory of numbered files, or a glob
241
251
  --epub PATH the matching DRM-free EPUB
242
252
  --out PATH the EPUB to write
253
+ --report PATH JSON report path (default: readalign-report.json beside --out)
243
254
  --model NAME faster-whisper model (default: large-v3-turbo)
244
255
  --language CODE ISO code of the narration (default: detect)
245
256
  --drift-threshold SECS exit 3 if any sentence drifts more than this (default: 2.5)
257
+ --min-token-match N exit 5 if matched book-token fraction is below N (0 to 1; default: 0)
246
258
  --sidecars/--no-sidecars write WebVTT and align.json next to the output (default: on)
247
259
  --dry-run probe the inputs and print the plan, transcribe nothing
248
260
  --narrator NAME value for the media:narrator metadata
@@ -251,6 +263,7 @@ written; exceeding the threshold only changes the exit code to 3 so a script can
251
263
  --beam-size N whisper beam size (default: 5)
252
264
  --no-vad disable the voice-activity filter
253
265
  --audio-bitrate RATE bitrate when audio has to be transcoded (default: 96k)
266
+ --audio-format auto|mp3 preserve supported codecs (default: auto), or use MP3 for compatibility
254
267
  --verify [JAR] validate the result with epubcheck after writing it (needs Java);
255
268
  pass its jar path, or leave the value off to search PATH,
256
269
  EPUBCHECK_HOME and JAVA_HOME for one
@@ -264,6 +277,29 @@ Multiple files are ordered by natural sort, so `part2.mp3` comes before `part10.
264
277
  file covering the whole book is fine, and so is a chapter split over several files; readalign
265
278
  works from a continuous timeline and does not assume one file per chapter.
266
279
 
280
+ For a reader that cannot decode AAC or Opus, select MP3 output:
281
+
282
+ The `--audio-format` option requires readalign 1.3.0 or later.
283
+
284
+ ```
285
+ readalign build --audio book.m4b --epub book.epub --out read-along.epub --audio-format mp3
286
+ ```
287
+
288
+ Existing MP3 sources are stream-copied. Other codecs are re-encoded with ffmpeg's `libmp3lame`
289
+ encoder at `--audio-bitrate` (default 96k). Re-encoding takes time and can reduce quality; it
290
+ does not change your source files. Timings are measured against the prepared MP3, not the
291
+ original AAC/Opus track. Changing the prepared audio also changes the transcript cache key,
292
+ so the first build in this mode may need to transcribe again. The report records the requested
293
+ `audio_format` and which source files were transcoded. This option does not fix reader resume
294
+ bugs or split a long track into chapters.
295
+
296
+ Use a dedicated `--work-dir`: its `audio/partNNN.mp3`, `.m4a`, `.opus` and
297
+ `wav/partNNN.wav` paths are reserved for generated files. Builds refuse aliases of the input
298
+ ebook, source audio, output EPUB or report at those paths before preparing audio. The generated
299
+ `<output-name>-sidecars/` directory must also be separate from your inputs and report; previous
300
+ `align.json` and `.vtt` files there are replaced on a rerun. Chapter-derived VTT filenames are
301
+ sanitized for safe local paths, while `align.json` retains the original document hrefs.
302
+
267
303
  ### The optional refine pass
268
304
 
269
305
  `pip install 'readalign[refine]'` adds torch and torchaudio, and `--refine` then runs a CTC
@@ -292,13 +328,50 @@ it, and any run that uses it records the restriction in the report's warnings.
292
328
 
293
329
  ## Reader support
294
330
 
295
- Media Overlays are part of EPUB 3, but support is uneven. Readium-based readers (Thorium Reader
296
- on desktop, Colibrio-based web readers) and Apple Books implement them. Kindle does not read EPUB
297
- overlays at all, and several popular mobile readers ignore the `media-overlay` attribute and just
298
- show the book. If a reader shows the text but plays no audio, it does not support overlays; the
299
- file is still a valid EPUB and still readable.
300
-
301
- readalign's output is validated against epubcheck 5.2.1, and `readalign check` is validated
331
+ Media Overlays are part of EPUB 3, but support is uneven. Text rendering, audio decoding,
332
+ sentence highlighting and saving the narration position are separate capabilities. A silent
333
+ book can mean an unsupported codec or a reader bug, not just missing overlay support.
334
+
335
+ ### Compatibility evidence (October 25, 2026)
336
+
337
+ This is a record of available evidence, **not a personally tested reader certification table**.
338
+ Reader fixes merged upstream may not yet be in the version you have installed.
339
+
340
+ | Reader / platform | Evidence and limits |
341
+ | --- | --- |
342
+ | Calibre and Thorium, reporter's PC | A [Readest reporter](https://github.com/readest/readest/issues/6989) says their large narrated EPUB plays in both. Versions and complete seek/reopen behavior were not recorded. |
343
+ | Readest, web and desktop | [#7004](https://github.com/readest/readest/pull/7004) fixes loading a large stored audio entry without copying it all into memory. Upstream tested a 921 MB shared MP3, including an eight-hour seek. This is upstream testing, not a readalign reader test. |
344
+ | Readest, Android and iOS | [#7010](https://github.com/readest/readest/pull/7010) and [#7019](https://github.com/readest/readest/pull/7019) extend direct audio access to native players. Upstream tests are described in those PRs; they do not establish playback of every readalign book. |
345
+ | Readest, Linux app | The maintainer [reports no AAC decoder](https://github.com/readest/readest/issues/6989#issuecomment-6357568308). Try `--audio-format mp3`; updating for large-track fixes alone does not resolve that codec limitation. |
346
+ | Episteme, Android | An [independent readalign-output user](https://github.com/Aryan-Raj3112/episteme/issues/579) reports narration restarting at the beginning of a long section after reopening. The same book highlights sentences correctly on PC. No readalign-side fix is established. |
347
+ | Apple Books and other readers | Overlay support alone is not proof of long-track seeking or saved narration position. These behaviors have not been verified here. |
348
+ | Kindle | EPUB Media Overlays are not supported. |
349
+
350
+ ### Long books and playback troubleshooting
351
+
352
+ 1. Run `readalign check read-along.epub` and, if available, epubcheck. A clean result checks
353
+ package structure and clip bounds, not whether a specific reader plays or resumes correctly.
354
+ 2. Check the reader version and audio codec. MP3 output is an option for AAC/Opus decoder
355
+ limitations; an EPUB can be valid while a reader cannot decode its narration.
356
+ 3. For large books, use a reader version containing its large-track fixes. readalign stores
357
+ prepared MP3/M4A/Opus entries without ZIP compression so readers can seek directly. A reader
358
+ may still extract or copy the whole track before playback. One overlay per content document
359
+ does not mean the audio is split into separate files. If you already own chapter-sized
360
+ narration files, pass their directory; they are naturally sorted.
361
+ 4. Test a later chapter, cross a chapter boundary, pause/resume, then close and reopen the book.
362
+ Saving the text position is not the same as saving the narration sentence and audio offset.
363
+ Re-encoding to MP3 is not a guaranteed workaround for narration-position loss.
364
+ 5. Compare the same EPUB in another overlay-capable reader. If only one reader fails, include
365
+ that comparison when reporting it rather than assuming the EPUB generation is defective.
366
+
367
+ When reporting a problem, include readalign's version, reader version, OS/device, audio format,
368
+ approximate track duration/size, the chapter and sentence where it happens, and whether it is
369
+ startup, seeking, highlighting or reopening that fails. Include the output of `readalign check`
370
+ and a short public-domain reproduction if possible. Reports and sidecars can contain local paths
371
+ and quoted book text: inspect them before sharing. Do not upload a copyrighted book or narration
372
+ unless you have permission.
373
+
374
+ readalign's sample output is validated against epubcheck 5.2.1, and `readalign check` is validated
302
375
  against the reference Media Overlays title from the EPUB test suite at
303
376
  <https://epubtest.org/test-books>.
304
377
 
@@ -311,6 +384,7 @@ against the reference Media Overlays title from the EPUB test suite at
311
384
  | 2 | the input is DRM-protected |
312
385
  | 3 | the EPUB was written but max drift exceeded `--drift-threshold` |
313
386
  | 4 | the EPUB was written but epubcheck reported problems (only with `--verify`) |
387
+ | 5 | the EPUB was written but token match was below `--min-token-match` |
314
388
  | 130 | interrupted |
315
389
 
316
390
  Failures print one line, not a traceback.
@@ -325,9 +399,12 @@ python -m pytest tests -q
325
399
  python -m ruff check .
326
400
  ```
327
401
 
328
- The suite runs in about a second and needs neither a GPU nor network access; it builds its own
402
+ The suite needs neither a GPU nor network access; it builds its own
329
403
  EPUBs and synthesises word timings. A few cases need ffmpeg and skip without it, and one is
330
404
  skipped unless `READALIGN_MO_TESTBOOK` points at a copy of the epubtest.org Media Overlays book.
405
+ Long-book packaging tests cover shared 16-hour timelines, separate-track clock resets, stored
406
+ audio entries and ZIP64 selection using a lowered size threshold. Synthetic timings and a lowered
407
+ threshold do not substitute for a real multi-gigabyte book or reader playback test.
331
408
 
332
409
  ## Distribution
333
410
 
@@ -177,13 +177,23 @@ Drift is how far a sentence's start could be wrong, in seconds.
177
177
  `--drift-threshold` (default 2.5s) is a gate on the maximum, not the average. The EPUB is always
178
178
  written; exceeding the threshold only changes the exit code to 3 so a script can catch it.
179
179
 
180
+ For batch builds, `--min-token-match 0.9` also requires at least 90% of the ebook's normalised
181
+ tokens to match the narration. This optional gate defaults to 0 (disabled). A failed gate exits 5
182
+ but keeps the EPUB, report and enabled sidecars for inspection. The report's `token_match_gate`
183
+ records the threshold and verdict using the unrounded match fraction. Unnarrated front matter
184
+ and abridgements lower this score, so choose a threshold appropriate to your books. This is a
185
+ screening aid, not proof that every sentence is correctly timed. Drift and epubcheck failures
186
+ take precedence over this gate when more than one check fails.
187
+
180
188
  ## How it works
181
189
 
182
190
  1. **ffprobe** reads every audio file. FLAC, WAV, ALAC and WMA are transcoded to AAC because
183
191
  EPUB 3 only takes MP3, MP4 audio and Opus; MP3, M4A/M4B and Opus are stream-copied, so no
184
192
  audio is re-encoded unless it has to be.
185
193
  2. **faster-whisper** transcribes with word timestamps. Transcripts are cached per file, keyed by
186
- content and settings, so a second run over the same audiobook costs nothing.
194
+ a streamed SHA-256 of the prepared audio, chapter boundaries and recogniser settings.
195
+ A second run avoids transcription, but still prepares and hashes the audio. Older cache keys
196
+ are invalidated once; malformed entries are ignored and rebuilt.
187
197
  3. Both sides are normalised to comparable tokens: case folded, accents stripped, punctuation
188
198
  dropped, digits and roman numerals spelled out ("Chapter XVIII" and "chapter eighteen" become
189
199
  the same tokens).
@@ -207,9 +217,11 @@ written; exceeding the threshold only changes the exit code to 3 so a script can
207
217
  --audio PATH a file, a directory of numbered files, or a glob
208
218
  --epub PATH the matching DRM-free EPUB
209
219
  --out PATH the EPUB to write
220
+ --report PATH JSON report path (default: readalign-report.json beside --out)
210
221
  --model NAME faster-whisper model (default: large-v3-turbo)
211
222
  --language CODE ISO code of the narration (default: detect)
212
223
  --drift-threshold SECS exit 3 if any sentence drifts more than this (default: 2.5)
224
+ --min-token-match N exit 5 if matched book-token fraction is below N (0 to 1; default: 0)
213
225
  --sidecars/--no-sidecars write WebVTT and align.json next to the output (default: on)
214
226
  --dry-run probe the inputs and print the plan, transcribe nothing
215
227
  --narrator NAME value for the media:narrator metadata
@@ -218,6 +230,7 @@ written; exceeding the threshold only changes the exit code to 3 so a script can
218
230
  --beam-size N whisper beam size (default: 5)
219
231
  --no-vad disable the voice-activity filter
220
232
  --audio-bitrate RATE bitrate when audio has to be transcoded (default: 96k)
233
+ --audio-format auto|mp3 preserve supported codecs (default: auto), or use MP3 for compatibility
221
234
  --verify [JAR] validate the result with epubcheck after writing it (needs Java);
222
235
  pass its jar path, or leave the value off to search PATH,
223
236
  EPUBCHECK_HOME and JAVA_HOME for one
@@ -231,6 +244,29 @@ Multiple files are ordered by natural sort, so `part2.mp3` comes before `part10.
231
244
  file covering the whole book is fine, and so is a chapter split over several files; readalign
232
245
  works from a continuous timeline and does not assume one file per chapter.
233
246
 
247
+ For a reader that cannot decode AAC or Opus, select MP3 output:
248
+
249
+ The `--audio-format` option requires readalign 1.3.0 or later.
250
+
251
+ ```
252
+ readalign build --audio book.m4b --epub book.epub --out read-along.epub --audio-format mp3
253
+ ```
254
+
255
+ Existing MP3 sources are stream-copied. Other codecs are re-encoded with ffmpeg's `libmp3lame`
256
+ encoder at `--audio-bitrate` (default 96k). Re-encoding takes time and can reduce quality; it
257
+ does not change your source files. Timings are measured against the prepared MP3, not the
258
+ original AAC/Opus track. Changing the prepared audio also changes the transcript cache key,
259
+ so the first build in this mode may need to transcribe again. The report records the requested
260
+ `audio_format` and which source files were transcoded. This option does not fix reader resume
261
+ bugs or split a long track into chapters.
262
+
263
+ Use a dedicated `--work-dir`: its `audio/partNNN.mp3`, `.m4a`, `.opus` and
264
+ `wav/partNNN.wav` paths are reserved for generated files. Builds refuse aliases of the input
265
+ ebook, source audio, output EPUB or report at those paths before preparing audio. The generated
266
+ `<output-name>-sidecars/` directory must also be separate from your inputs and report; previous
267
+ `align.json` and `.vtt` files there are replaced on a rerun. Chapter-derived VTT filenames are
268
+ sanitized for safe local paths, while `align.json` retains the original document hrefs.
269
+
234
270
  ### The optional refine pass
235
271
 
236
272
  `pip install 'readalign[refine]'` adds torch and torchaudio, and `--refine` then runs a CTC
@@ -259,13 +295,50 @@ it, and any run that uses it records the restriction in the report's warnings.
259
295
 
260
296
  ## Reader support
261
297
 
262
- Media Overlays are part of EPUB 3, but support is uneven. Readium-based readers (Thorium Reader
263
- on desktop, Colibrio-based web readers) and Apple Books implement them. Kindle does not read EPUB
264
- overlays at all, and several popular mobile readers ignore the `media-overlay` attribute and just
265
- show the book. If a reader shows the text but plays no audio, it does not support overlays; the
266
- file is still a valid EPUB and still readable.
267
-
268
- readalign's output is validated against epubcheck 5.2.1, and `readalign check` is validated
298
+ Media Overlays are part of EPUB 3, but support is uneven. Text rendering, audio decoding,
299
+ sentence highlighting and saving the narration position are separate capabilities. A silent
300
+ book can mean an unsupported codec or a reader bug, not just missing overlay support.
301
+
302
+ ### Compatibility evidence (October 25, 2026)
303
+
304
+ This is a record of available evidence, **not a personally tested reader certification table**.
305
+ Reader fixes merged upstream may not yet be in the version you have installed.
306
+
307
+ | Reader / platform | Evidence and limits |
308
+ | --- | --- |
309
+ | Calibre and Thorium, reporter's PC | A [Readest reporter](https://github.com/readest/readest/issues/6989) says their large narrated EPUB plays in both. Versions and complete seek/reopen behavior were not recorded. |
310
+ | Readest, web and desktop | [#7004](https://github.com/readest/readest/pull/7004) fixes loading a large stored audio entry without copying it all into memory. Upstream tested a 921 MB shared MP3, including an eight-hour seek. This is upstream testing, not a readalign reader test. |
311
+ | Readest, Android and iOS | [#7010](https://github.com/readest/readest/pull/7010) and [#7019](https://github.com/readest/readest/pull/7019) extend direct audio access to native players. Upstream tests are described in those PRs; they do not establish playback of every readalign book. |
312
+ | Readest, Linux app | The maintainer [reports no AAC decoder](https://github.com/readest/readest/issues/6989#issuecomment-6357568308). Try `--audio-format mp3`; updating for large-track fixes alone does not resolve that codec limitation. |
313
+ | Episteme, Android | An [independent readalign-output user](https://github.com/Aryan-Raj3112/episteme/issues/579) reports narration restarting at the beginning of a long section after reopening. The same book highlights sentences correctly on PC. No readalign-side fix is established. |
314
+ | Apple Books and other readers | Overlay support alone is not proof of long-track seeking or saved narration position. These behaviors have not been verified here. |
315
+ | Kindle | EPUB Media Overlays are not supported. |
316
+
317
+ ### Long books and playback troubleshooting
318
+
319
+ 1. Run `readalign check read-along.epub` and, if available, epubcheck. A clean result checks
320
+ package structure and clip bounds, not whether a specific reader plays or resumes correctly.
321
+ 2. Check the reader version and audio codec. MP3 output is an option for AAC/Opus decoder
322
+ limitations; an EPUB can be valid while a reader cannot decode its narration.
323
+ 3. For large books, use a reader version containing its large-track fixes. readalign stores
324
+ prepared MP3/M4A/Opus entries without ZIP compression so readers can seek directly. A reader
325
+ may still extract or copy the whole track before playback. One overlay per content document
326
+ does not mean the audio is split into separate files. If you already own chapter-sized
327
+ narration files, pass their directory; they are naturally sorted.
328
+ 4. Test a later chapter, cross a chapter boundary, pause/resume, then close and reopen the book.
329
+ Saving the text position is not the same as saving the narration sentence and audio offset.
330
+ Re-encoding to MP3 is not a guaranteed workaround for narration-position loss.
331
+ 5. Compare the same EPUB in another overlay-capable reader. If only one reader fails, include
332
+ that comparison when reporting it rather than assuming the EPUB generation is defective.
333
+
334
+ When reporting a problem, include readalign's version, reader version, OS/device, audio format,
335
+ approximate track duration/size, the chapter and sentence where it happens, and whether it is
336
+ startup, seeking, highlighting or reopening that fails. Include the output of `readalign check`
337
+ and a short public-domain reproduction if possible. Reports and sidecars can contain local paths
338
+ and quoted book text: inspect them before sharing. Do not upload a copyrighted book or narration
339
+ unless you have permission.
340
+
341
+ readalign's sample output is validated against epubcheck 5.2.1, and `readalign check` is validated
269
342
  against the reference Media Overlays title from the EPUB test suite at
270
343
  <https://epubtest.org/test-books>.
271
344
 
@@ -278,6 +351,7 @@ against the reference Media Overlays title from the EPUB test suite at
278
351
  | 2 | the input is DRM-protected |
279
352
  | 3 | the EPUB was written but max drift exceeded `--drift-threshold` |
280
353
  | 4 | the EPUB was written but epubcheck reported problems (only with `--verify`) |
354
+ | 5 | the EPUB was written but token match was below `--min-token-match` |
281
355
  | 130 | interrupted |
282
356
 
283
357
  Failures print one line, not a traceback.
@@ -292,9 +366,12 @@ python -m pytest tests -q
292
366
  python -m ruff check .
293
367
  ```
294
368
 
295
- The suite runs in about a second and needs neither a GPU nor network access; it builds its own
369
+ The suite needs neither a GPU nor network access; it builds its own
296
370
  EPUBs and synthesises word timings. A few cases need ffmpeg and skip without it, and one is
297
371
  skipped unless `READALIGN_MO_TESTBOOK` points at a copy of the epubtest.org Media Overlays book.
372
+ Long-book packaging tests cover shared 16-hour timelines, separate-track clock resets, stored
373
+ audio entries and ZIP64 selection using a lowered size threshold. Synthetic timings and a lowered
374
+ threshold do not substitute for a real multi-gigabyte book or reader playback test.
298
375
 
299
376
  ## Distribution
300
377
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "readalign"
7
- version = "1.1.0"
7
+ version = "1.3.0"
8
8
  description = "Turn an audiobook and the matching ebook you own into one EPUB 3 with Media Overlays."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """readalign: build an EPUB 3 with Media Overlays from an audiobook and the matching ebook."""
2
2
 
3
- __version__ = "1.1.0"
3
+ __version__ = "1.3.0"
@@ -10,6 +10,7 @@ import contextlib
10
10
  import glob
11
11
  import hashlib
12
12
  import json
13
+ import math
13
14
  import os
14
15
  import site
15
16
  import time
@@ -17,6 +18,7 @@ from dataclasses import asdict, dataclass
17
18
 
18
19
  from .audio import AudioTrack, read_wav_segment
19
20
  from .errors import DependencyError
21
+ from .fs import atomic_output
20
22
 
21
23
  _CUDA_READY = False
22
24
 
@@ -152,13 +154,17 @@ def _cache_key(
152
154
  track: AudioTrack, model: str, language: str | None, vad: bool, beam_size: int,
153
155
  window_seconds: float,
154
156
  ) -> str:
155
- stat = os.stat(track.path)
156
- payload = "|".join(
157
- [os.path.basename(track.source), str(stat.st_size), f"{track.duration:.3f}",
158
- model, language or "auto", str(vad), f"beam{beam_size}",
159
- f"window{window_seconds:g}"]
160
- )
161
- return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:20]
157
+ digest = hashlib.sha256()
158
+ # Size and duration are not identities: edited tracks can retain both.
159
+ with open(track.path, "rb") as handle:
160
+ for chunk in iter(lambda: handle.read(1 << 20), b""):
161
+ digest.update(chunk)
162
+ settings = {"version": 2, "model": model, "language": language, "vad": vad,
163
+ "beam_size": beam_size, "window_seconds": window_seconds,
164
+ "duration": track.duration,
165
+ "chapters": [(chapter.start, chapter.end) for chapter in track.chapters]}
166
+ digest.update(json.dumps(settings, sort_keys=True).encode("utf-8"))
167
+ return digest.hexdigest()
162
168
 
163
169
 
164
170
  def _load_cached(cache_dir: str | None, key: str) -> tuple[list[Word], str | None] | None:
@@ -169,13 +175,26 @@ def _load_cached(cache_dir: str | None, key: str) -> tuple[list[Word], str | Non
169
175
  try:
170
176
  with open(path, encoding="utf-8") as handle:
171
177
  data = json.load(handle)
172
- except (OSError, json.JSONDecodeError):
178
+ except (OSError, ValueError, UnicodeError):
173
179
  return None
174
180
  try:
175
181
  # Caches written before the language was recorded are a bare list of words.
176
- if isinstance(data, list):
177
- return [Word(**entry) for entry in data], None
178
- return [Word(**entry) for entry in data["words"]], data.get("language")
182
+ if not isinstance(data, (dict, list)):
183
+ return None
184
+ rows = data if isinstance(data, list) else data["words"]
185
+ language = None if isinstance(data, list) else data.get("language")
186
+ if not isinstance(rows, list) or (language is not None and not isinstance(language, str)):
187
+ return None
188
+ words = [Word(**entry) for entry in rows]
189
+ for word in words:
190
+ if not isinstance(word.text, str) or not word.text.strip():
191
+ return None
192
+ values = (word.start, word.end, word.probability)
193
+ if any(type(value) not in (int, float) or not math.isfinite(value) for value in values):
194
+ return None
195
+ if word.start < 0 or word.end < word.start or not 0 <= word.probability <= 1:
196
+ return None
197
+ return words, language
179
198
  except (KeyError, TypeError):
180
199
  return None
181
200
 
@@ -188,7 +207,7 @@ def _store_cached(
188
207
  try:
189
208
  os.makedirs(cache_dir, exist_ok=True)
190
209
  path = os.path.join(cache_dir, f"{key}.json")
191
- with open(path, "w", encoding="utf-8") as handle:
210
+ with atomic_output(path) as temporary, open(temporary, "w", encoding="utf-8") as handle:
192
211
  json.dump({"language": language, "words": [asdict(w) for w in words]}, handle)
193
212
  except OSError as exc:
194
213
  log(f" warning: could not write the transcript cache: {exc}")
@@ -220,7 +239,7 @@ def transcribe_tracks(
220
239
 
221
240
  for track in tracks:
222
241
  key = (
223
- _cache_key(track, model_name, language, vad, beam_size, window_seconds)
242
+ _cache_key(track, model_name, settings.language, vad, beam_size, window_seconds)
224
243
  if cache_dir
225
244
  else ""
226
245
  )
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import glob as globlib
11
11
  import json
12
+ import math
12
13
  import os
13
14
  import re
14
15
  import shutil
@@ -19,6 +20,7 @@ from dataclasses import dataclass, field
19
20
  import numpy as np
20
21
 
21
22
  from .errors import DependencyError, DRMError, InputError
23
+ from .fs import same_path
22
24
 
23
25
  AUDIO_EXTENSIONS = {
24
26
  ".mp3", ".m4a", ".m4b", ".mp4", ".flac", ".ogg", ".oga", ".opus", ".wav", ".aiff", ".aif",
@@ -117,9 +119,9 @@ def _duration_of(probe: dict, path: str) -> float:
117
119
  if value:
118
120
  try:
119
121
  duration = float(value)
120
- except ValueError:
122
+ except (TypeError, ValueError):
121
123
  continue
122
- if duration > 0:
124
+ if math.isfinite(duration) and duration > 0:
123
125
  return duration
124
126
  raise InputError(f"{path} has no readable duration; is it a complete audio file?")
125
127
 
@@ -190,17 +192,19 @@ def _chapters_from(probe: dict) -> list[Chapter]:
190
192
  end = float(entry.get("end_time", 0.0))
191
193
  except (TypeError, ValueError):
192
194
  continue
193
- if end <= start:
195
+ if not math.isfinite(start) or not math.isfinite(end) or start < 0 or end <= start:
194
196
  continue
195
197
  title = (entry.get("tags") or {}).get("title") or f"Chapter {len(chapters) + 1}"
196
198
  chapters.append(Chapter(start=start, end=end, title=title))
197
199
  return chapters
198
200
 
199
201
 
200
- def _output_plan(path: str, probe: dict) -> tuple[str, str, bool]:
202
+ def _output_plan(path: str, probe: dict, audio_format: str = "auto") -> tuple[str, str, bool]:
201
203
  """Pick the extension, media type and whether a transcode is needed for a source file."""
202
204
  stream = _audio_stream(probe, path)
203
205
  codec = (stream.get("codec_name") or "").lower()
206
+ if audio_format == "mp3":
207
+ return ".mp3", "audio/mpeg", codec != "mp3"
204
208
  names = (probe.get("format", {}).get("format_name") or "").split(",")
205
209
  container = {name.strip() for name in names}
206
210
  if codec == "mp3":
@@ -212,13 +216,17 @@ def _output_plan(path: str, probe: dict) -> tuple[str, str, bool]:
212
216
  return ".m4a", "audio/mp4", True
213
217
 
214
218
 
215
- def prepare_tracks(sources: list[str], workdir: str, bitrate: str, log=print) -> list[AudioTrack]:
219
+ def prepare_tracks(
220
+ sources: list[str], workdir: str, bitrate: str, log=print, *, audio_format: str = "auto",
221
+ ) -> list[AudioTrack]:
216
222
  """Copy or transcode every source into an EPUB core media type and measure the result.
217
223
 
218
224
  MP3, AAC-in-MP4 and Opus-in-Ogg are core media types in EPUB 3, so they are stream-copied.
219
225
  Anything else (FLAC, WAV, ALAC, WMA) is transcoded to AAC, because a reading system is not
220
- required to play it.
226
+ required to play it. Explicit MP3 output avoids AAC/Opus decoder limitations in some readers.
221
227
  """
228
+ if audio_format not in {"auto", "mp3"}:
229
+ raise InputError("audio format must be auto or mp3")
222
230
  audio_dir = os.path.join(workdir, "audio")
223
231
  os.makedirs(audio_dir, exist_ok=True)
224
232
  tracks: list[AudioTrack] = []
@@ -226,13 +234,17 @@ def prepare_tracks(sources: list[str], workdir: str, bitrate: str, log=print) ->
226
234
  for index, source in enumerate(sources, start=1):
227
235
  probe = ffprobe(source)
228
236
  _reject_drm(source, probe)
229
- suffix, media_type, transcode = _output_plan(source, probe)
237
+ suffix, media_type, transcode = _output_plan(source, probe, audio_format)
230
238
  target = os.path.join(audio_dir, f"part{index:03d}{suffix}")
239
+ if any(same_path(target, original) for original in sources):
240
+ raise InputError(f"the work directory would overwrite source audio: {target}")
231
241
  label = f"[{index}/{len(sources)}] {os.path.basename(source)}"
232
- log(f" {label} -> {'transcoding to AAC' if transcode else 'stream copy'}")
242
+ codec_label = "MP3" if suffix == ".mp3" else "AAC"
243
+ log(f" {label} -> {'transcoding to ' + codec_label if transcode else 'stream copy'}")
233
244
  command = ["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", source,
234
245
  "-map", "0:a:0", "-map_chapters", "0", "-vn"]
235
- command += ["-c:a", "aac", "-b:a", bitrate] if transcode else ["-c:a", "copy"]
246
+ codec = "libmp3lame" if suffix == ".mp3" else "aac"
247
+ command += ["-c:a", codec, "-b:a", bitrate] if transcode else ["-c:a", "copy"]
236
248
  if suffix == ".m4a":
237
249
  command += ["-movflags", "+faststart"]
238
250
  result = _run([*command, target], f"preparing {source}")
@@ -265,6 +277,8 @@ def decode_wav(track: AudioTrack, workdir: str) -> str:
265
277
  wav_dir = os.path.join(workdir, "wav")
266
278
  os.makedirs(wav_dir, exist_ok=True)
267
279
  target = os.path.join(wav_dir, f"part{track.index:03d}.wav")
280
+ if same_path(target, track.source):
281
+ raise InputError(f"the work directory would overwrite source audio: {target}")
268
282
  result = _run(
269
283
  [
270
284
  "ffmpeg", "-nostdin", "-v", "error", "-y", "-i", track.path,