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.
- {readalign-1.1.0 → readalign-1.3.0}/.gitignore +20 -20
- {readalign-1.1.0 → readalign-1.3.0}/CHANGELOG.md +41 -0
- {readalign-1.1.0 → readalign-1.3.0}/PKG-INFO +87 -10
- {readalign-1.1.0 → readalign-1.3.0}/README.md +86 -9
- {readalign-1.1.0 → readalign-1.3.0}/pyproject.toml +1 -1
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/__init__.py +1 -1
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/asr.py +32 -13
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/audio.py +23 -9
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/cli.py +119 -25
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/clock.py +15 -9
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/epub.py +674 -671
- readalign-1.3.0/src/readalign/fs.py +74 -0
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/members.py +81 -72
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/package.py +77 -56
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/sidecar.py +23 -5
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/smil.py +4 -3
- {readalign-1.1.0 → readalign-1.3.0}/tests/test_asr.py +51 -0
- readalign-1.3.0/tests/test_audio.py +145 -0
- readalign-1.3.0/tests/test_cli.py +397 -0
- readalign-1.3.0/tests/test_fs.py +59 -0
- {readalign-1.1.0 → readalign-1.3.0}/tests/test_output.py +183 -1
- {readalign-1.1.0 → readalign-1.3.0}/tests/test_sidecar_report.py +58 -0
- readalign-1.1.0/src/readalign/fs.py +0 -22
- readalign-1.1.0/tests/test_audio.py +0 -40
- readalign-1.1.0/tests/test_cli.py +0 -178
- {readalign-1.1.0 → readalign-1.3.0}/LICENSE +0 -0
- {readalign-1.1.0 → readalign-1.3.0}/scripts/fetch_samples.py +0 -0
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/__main__.py +0 -0
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/align.py +0 -0
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/errors.py +0 -0
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/refine.py +0 -0
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/report.py +0 -0
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/sentences.py +0 -0
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/textnorm.py +0 -0
- {readalign-1.1.0 → readalign-1.3.0}/src/readalign/verify.py +0 -0
- {readalign-1.1.0 → readalign-1.3.0}/tests/conftest.py +0 -0
- {readalign-1.1.0 → readalign-1.3.0}/tests/test_align.py +0 -0
- {readalign-1.1.0 → readalign-1.3.0}/tests/test_epub.py +0 -0
- {readalign-1.1.0 → readalign-1.3.0}/tests/test_text.py +0 -0
- {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.
|
|
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
|
-
|
|
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.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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,
|
|
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
|
|
178
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|