readalign 1.2.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.2.0 → readalign-1.3.0}/CHANGELOG.md +17 -0
- {readalign-1.2.0 → readalign-1.3.0}/PKG-INFO +73 -9
- {readalign-1.2.0 → readalign-1.3.0}/README.md +72 -8
- {readalign-1.2.0 → readalign-1.3.0}/pyproject.toml +1 -1
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/__init__.py +1 -1
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/audio.py +14 -6
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/cli.py +23 -10
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/fs.py +10 -0
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/members.py +13 -4
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/package.py +4 -2
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/sidecar.py +14 -1
- readalign-1.3.0/tests/test_audio.py +145 -0
- {readalign-1.2.0 → readalign-1.3.0}/tests/test_cli.py +105 -0
- {readalign-1.2.0 → readalign-1.3.0}/tests/test_fs.py +9 -1
- {readalign-1.2.0 → readalign-1.3.0}/tests/test_output.py +73 -1
- {readalign-1.2.0 → readalign-1.3.0}/tests/test_sidecar_report.py +25 -0
- readalign-1.2.0/tests/test_audio.py +0 -77
- {readalign-1.2.0 → readalign-1.3.0}/.gitignore +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/LICENSE +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/scripts/fetch_samples.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/__main__.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/align.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/asr.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/clock.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/epub.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/errors.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/refine.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/report.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/sentences.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/smil.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/textnorm.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/src/readalign/verify.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/tests/conftest.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/tests/test_align.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/tests/test_asr.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/tests/test_epub.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/tests/test_text.py +0 -0
- {readalign-1.2.0 → readalign-1.3.0}/tests/test_verify.py +0 -0
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
3
20
|
## 1.2.0 - 2026-10-22
|
|
4
21
|
|
|
5
22
|
- New: `--report PATH` selects a separate JSON report for each batch build, with guards
|
|
@@ -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
|
|
@@ -263,6 +263,7 @@ take precedence over this gate when more than one check fails.
|
|
|
263
263
|
--beam-size N whisper beam size (default: 5)
|
|
264
264
|
--no-vad disable the voice-activity filter
|
|
265
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
|
|
266
267
|
--verify [JAR] validate the result with epubcheck after writing it (needs Java);
|
|
267
268
|
pass its jar path, or leave the value off to search PATH,
|
|
268
269
|
EPUBCHECK_HOME and JAVA_HOME for one
|
|
@@ -276,6 +277,29 @@ Multiple files are ordered by natural sort, so `part2.mp3` comes before `part10.
|
|
|
276
277
|
file covering the whole book is fine, and so is a chapter split over several files; readalign
|
|
277
278
|
works from a continuous timeline and does not assume one file per chapter.
|
|
278
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
|
+
|
|
279
303
|
### The optional refine pass
|
|
280
304
|
|
|
281
305
|
`pip install 'readalign[refine]'` adds torch and torchaudio, and `--refine` then runs a CTC
|
|
@@ -304,13 +328,50 @@ it, and any run that uses it records the restriction in the report's warnings.
|
|
|
304
328
|
|
|
305
329
|
## Reader support
|
|
306
330
|
|
|
307
|
-
Media Overlays are part of EPUB 3, but support is uneven.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
314
375
|
against the reference Media Overlays title from the EPUB test suite at
|
|
315
376
|
<https://epubtest.org/test-books>.
|
|
316
377
|
|
|
@@ -338,9 +399,12 @@ python -m pytest tests -q
|
|
|
338
399
|
python -m ruff check .
|
|
339
400
|
```
|
|
340
401
|
|
|
341
|
-
The suite
|
|
402
|
+
The suite needs neither a GPU nor network access; it builds its own
|
|
342
403
|
EPUBs and synthesises word timings. A few cases need ffmpeg and skip without it, and one is
|
|
343
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.
|
|
344
408
|
|
|
345
409
|
## Distribution
|
|
346
410
|
|
|
@@ -230,6 +230,7 @@ take precedence over this gate when more than one check fails.
|
|
|
230
230
|
--beam-size N whisper beam size (default: 5)
|
|
231
231
|
--no-vad disable the voice-activity filter
|
|
232
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
|
|
233
234
|
--verify [JAR] validate the result with epubcheck after writing it (needs Java);
|
|
234
235
|
pass its jar path, or leave the value off to search PATH,
|
|
235
236
|
EPUBCHECK_HOME and JAVA_HOME for one
|
|
@@ -243,6 +244,29 @@ Multiple files are ordered by natural sort, so `part2.mp3` comes before `part10.
|
|
|
243
244
|
file covering the whole book is fine, and so is a chapter split over several files; readalign
|
|
244
245
|
works from a continuous timeline and does not assume one file per chapter.
|
|
245
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
|
+
|
|
246
270
|
### The optional refine pass
|
|
247
271
|
|
|
248
272
|
`pip install 'readalign[refine]'` adds torch and torchaudio, and `--refine` then runs a CTC
|
|
@@ -271,13 +295,50 @@ it, and any run that uses it records the restriction in the report's warnings.
|
|
|
271
295
|
|
|
272
296
|
## Reader support
|
|
273
297
|
|
|
274
|
-
Media Overlays are part of EPUB 3, but support is uneven.
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
281
342
|
against the reference Media Overlays title from the EPUB test suite at
|
|
282
343
|
<https://epubtest.org/test-books>.
|
|
283
344
|
|
|
@@ -305,9 +366,12 @@ python -m pytest tests -q
|
|
|
305
366
|
python -m ruff check .
|
|
306
367
|
```
|
|
307
368
|
|
|
308
|
-
The suite
|
|
369
|
+
The suite needs neither a GPU nor network access; it builds its own
|
|
309
370
|
EPUBs and synthesises word timings. A few cases need ffmpeg and skip without it, and one is
|
|
310
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.
|
|
311
375
|
|
|
312
376
|
## Distribution
|
|
313
377
|
|
|
@@ -199,10 +199,12 @@ def _chapters_from(probe: dict) -> list[Chapter]:
|
|
|
199
199
|
return chapters
|
|
200
200
|
|
|
201
201
|
|
|
202
|
-
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]:
|
|
203
203
|
"""Pick the extension, media type and whether a transcode is needed for a source file."""
|
|
204
204
|
stream = _audio_stream(probe, path)
|
|
205
205
|
codec = (stream.get("codec_name") or "").lower()
|
|
206
|
+
if audio_format == "mp3":
|
|
207
|
+
return ".mp3", "audio/mpeg", codec != "mp3"
|
|
206
208
|
names = (probe.get("format", {}).get("format_name") or "").split(",")
|
|
207
209
|
container = {name.strip() for name in names}
|
|
208
210
|
if codec == "mp3":
|
|
@@ -214,13 +216,17 @@ def _output_plan(path: str, probe: dict) -> tuple[str, str, bool]:
|
|
|
214
216
|
return ".m4a", "audio/mp4", True
|
|
215
217
|
|
|
216
218
|
|
|
217
|
-
def prepare_tracks(
|
|
219
|
+
def prepare_tracks(
|
|
220
|
+
sources: list[str], workdir: str, bitrate: str, log=print, *, audio_format: str = "auto",
|
|
221
|
+
) -> list[AudioTrack]:
|
|
218
222
|
"""Copy or transcode every source into an EPUB core media type and measure the result.
|
|
219
223
|
|
|
220
224
|
MP3, AAC-in-MP4 and Opus-in-Ogg are core media types in EPUB 3, so they are stream-copied.
|
|
221
225
|
Anything else (FLAC, WAV, ALAC, WMA) is transcoded to AAC, because a reading system is not
|
|
222
|
-
required to play it.
|
|
226
|
+
required to play it. Explicit MP3 output avoids AAC/Opus decoder limitations in some readers.
|
|
223
227
|
"""
|
|
228
|
+
if audio_format not in {"auto", "mp3"}:
|
|
229
|
+
raise InputError("audio format must be auto or mp3")
|
|
224
230
|
audio_dir = os.path.join(workdir, "audio")
|
|
225
231
|
os.makedirs(audio_dir, exist_ok=True)
|
|
226
232
|
tracks: list[AudioTrack] = []
|
|
@@ -228,15 +234,17 @@ def prepare_tracks(sources: list[str], workdir: str, bitrate: str, log=print) ->
|
|
|
228
234
|
for index, source in enumerate(sources, start=1):
|
|
229
235
|
probe = ffprobe(source)
|
|
230
236
|
_reject_drm(source, probe)
|
|
231
|
-
suffix, media_type, transcode = _output_plan(source, probe)
|
|
237
|
+
suffix, media_type, transcode = _output_plan(source, probe, audio_format)
|
|
232
238
|
target = os.path.join(audio_dir, f"part{index:03d}{suffix}")
|
|
233
239
|
if any(same_path(target, original) for original in sources):
|
|
234
240
|
raise InputError(f"the work directory would overwrite source audio: {target}")
|
|
235
241
|
label = f"[{index}/{len(sources)}] {os.path.basename(source)}"
|
|
236
|
-
|
|
242
|
+
codec_label = "MP3" if suffix == ".mp3" else "AAC"
|
|
243
|
+
log(f" {label} -> {'transcoding to ' + codec_label if transcode else 'stream copy'}")
|
|
237
244
|
command = ["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", source,
|
|
238
245
|
"-map", "0:a:0", "-map_chapters", "0", "-vn"]
|
|
239
|
-
|
|
246
|
+
codec = "libmp3lame" if suffix == ".mp3" else "aac"
|
|
247
|
+
command += ["-c:a", codec, "-b:a", bitrate] if transcode else ["-c:a", "copy"]
|
|
240
248
|
if suffix == ".m4a":
|
|
241
249
|
command += ["-movflags", "+faststart"]
|
|
242
250
|
result = _run([*command, target], f"preparing {source}")
|
|
@@ -21,7 +21,7 @@ from urllib.parse import unquote
|
|
|
21
21
|
|
|
22
22
|
from . import __version__
|
|
23
23
|
from .errors import InputError, ReadAlignError
|
|
24
|
-
from .fs import ensure_directory, same_path
|
|
24
|
+
from .fs import ensure_directory, is_within, same_path
|
|
25
25
|
from .members import open_archive
|
|
26
26
|
|
|
27
27
|
DEFAULT_MODEL = "large-v3-turbo"
|
|
@@ -79,6 +79,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
79
79
|
help="disable the voice-activity filter")
|
|
80
80
|
build.add_argument("--audio-bitrate", default="96k",
|
|
81
81
|
help="bitrate used when audio has to be transcoded (default: 96k)")
|
|
82
|
+
build.add_argument("--audio-format", choices=["auto", "mp3"], default="auto",
|
|
83
|
+
help="embedded audio format: auto preserves supported codecs; mp3 avoids "
|
|
84
|
+
"AAC/Opus decoder limitations (default: auto)")
|
|
82
85
|
build.add_argument("--verify", nargs="?", const="auto", default=None, metavar="EPUBCHECK",
|
|
83
86
|
help="validate the result with epubcheck after writing it (needs Java). "
|
|
84
87
|
"Pass its jar path, or leave the value off to search PATH, "
|
|
@@ -162,11 +165,8 @@ def run_build(args) -> int:
|
|
|
162
165
|
raise InputError("--report would overwrite the input or output EPUB")
|
|
163
166
|
directory = os.path.join(os.path.dirname(out_path),
|
|
164
167
|
os.path.splitext(os.path.basename(out_path))[0] + "-sidecars")
|
|
165
|
-
if args.sidecars:
|
|
166
|
-
|
|
167
|
-
report_real = os.path.normcase(os.path.realpath(report_path))
|
|
168
|
-
if report_real == sidecar_root or report_real.startswith(sidecar_root + os.sep):
|
|
169
|
-
raise InputError("--report must be outside the generated sidecar directory")
|
|
168
|
+
if args.sidecars and is_within(report_path, directory):
|
|
169
|
+
raise InputError("--report must be outside the generated sidecar directory")
|
|
170
170
|
|
|
171
171
|
verification_tool = None
|
|
172
172
|
if args.verify and not args.dry_run:
|
|
@@ -190,16 +190,28 @@ def run_build(args) -> int:
|
|
|
190
190
|
for source in sources:
|
|
191
191
|
if same_path(out_path, source) or same_path(report_path, source):
|
|
192
192
|
raise InputError(f"an output path would overwrite source audio: {source}")
|
|
193
|
+
if args.sidecars:
|
|
194
|
+
for path in [args.epub, out_path, *sources]:
|
|
195
|
+
if is_within(path, directory):
|
|
196
|
+
raise InputError(f"inputs and output EPUB must be outside the generated "
|
|
197
|
+
f"sidecar directory: {path}")
|
|
193
198
|
log(f"preparing {len(sources)} audio file(s)")
|
|
194
199
|
work_dir = args.work_dir or tempfile.mkdtemp(prefix="readalign-")
|
|
195
200
|
temporary = args.work_dir is None
|
|
196
201
|
ensure_directory(work_dir, "work directory")
|
|
197
202
|
try:
|
|
198
203
|
for index in range(1, len(sources) + 1):
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
204
|
+
# Reserve all prepared formats before ffmpeg can modify any existing output.
|
|
205
|
+
scratch_paths = [os.path.join(work_dir, "audio", f"part{index:03d}{suffix}")
|
|
206
|
+
for suffix in (".mp3", ".m4a", ".opus")]
|
|
207
|
+
scratch_paths.append(os.path.join(work_dir, "wav", f"part{index:03d}.wav"))
|
|
208
|
+
for path in scratch_paths:
|
|
209
|
+
if any(same_path(path, protected)
|
|
210
|
+
for protected in [args.epub, out_path, report_path, *sources]):
|
|
211
|
+
raise InputError(f"the work directory would overwrite an input or output: "
|
|
212
|
+
f"{path}")
|
|
213
|
+
tracks = prepare_tracks(sources, work_dir, args.audio_bitrate, log=log,
|
|
214
|
+
audio_format=args.audio_format)
|
|
203
215
|
if args.dry_run:
|
|
204
216
|
return _dry_run_plan(package, tracks, log)
|
|
205
217
|
|
|
@@ -279,6 +291,7 @@ def run_build(args) -> int:
|
|
|
279
291
|
drift_threshold=args.drift_threshold,
|
|
280
292
|
warnings=warnings,
|
|
281
293
|
extras={"elapsed_seconds": round(time.monotonic() - started, 1),
|
|
294
|
+
"audio_format": args.audio_format,
|
|
282
295
|
"token_match_gate": {
|
|
283
296
|
"threshold": args.min_token_match,
|
|
284
297
|
"passed": (result.matched_tokens / result.book_tokens
|
|
@@ -24,6 +24,16 @@ def same_path(first: str, second: str) -> bool:
|
|
|
24
24
|
and os.path.samefile(first, second)))
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def is_within(path: str, directory: str) -> bool:
|
|
28
|
+
"""Include the directory itself and resolve aliases before comparing path components."""
|
|
29
|
+
path = os.path.normcase(os.path.realpath(path))
|
|
30
|
+
directory = os.path.normcase(os.path.realpath(directory))
|
|
31
|
+
try:
|
|
32
|
+
return os.path.commonpath([path, directory]) == directory
|
|
33
|
+
except ValueError: # Windows paths can be on different drives.
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
|
|
27
37
|
def write_text(path: str, text: str) -> None:
|
|
28
38
|
try:
|
|
29
39
|
with atomic_output(path) as temporary, \
|
|
@@ -60,11 +60,20 @@ class MemberReader(AbstractContextManager):
|
|
|
60
60
|
def open(self, member: SourceFile | ArchiveMember) -> BinaryIO:
|
|
61
61
|
if isinstance(member, SourceFile):
|
|
62
62
|
return open(member.path, "rb")
|
|
63
|
-
|
|
63
|
+
return self._archive(member.epub_path).open(member.name)
|
|
64
|
+
|
|
65
|
+
def size(self, member: SourceFile | ArchiveMember) -> int:
|
|
66
|
+
"""Read metadata, not content, so large entries can select ZIP64 before streaming."""
|
|
67
|
+
if isinstance(member, SourceFile):
|
|
68
|
+
return os.path.getsize(member.path)
|
|
69
|
+
return self._archive(member.epub_path).getinfo(member.name).file_size
|
|
70
|
+
|
|
71
|
+
def _archive(self, path: str) -> zipfile.ZipFile:
|
|
72
|
+
archive = self._archives.get(path)
|
|
64
73
|
if archive is None:
|
|
65
|
-
archive = zipfile.ZipFile(
|
|
66
|
-
self._archives[
|
|
67
|
-
return archive
|
|
74
|
+
archive = zipfile.ZipFile(path)
|
|
75
|
+
self._archives[path] = archive
|
|
76
|
+
return archive
|
|
68
77
|
|
|
69
78
|
def __exit__(self, *_) -> None:
|
|
70
79
|
while self._archives:
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The OPF gains the audio and overlay manifest items, the ``media-overlay`` attribute on every
|
|
4
4
|
narrated content document, and the ``media:`` metadata EPUB 3.3 section 9.3.5 requires. The
|
|
5
|
-
archive is written mimetype-first
|
|
5
|
+
archive is written mimetype-first, with that entry stored as OCF demands.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -284,7 +284,7 @@ def build_output(
|
|
|
284
284
|
|
|
285
285
|
|
|
286
286
|
def write_epub(path: str, files: Member, original_order: list[str]) -> None:
|
|
287
|
-
"""Write an OCF archive: mimetype first and stored,
|
|
287
|
+
"""Write an OCF archive: mimetype first and stored, audio stored for direct seeking."""
|
|
288
288
|
ordered: list[str] = []
|
|
289
289
|
seen = set()
|
|
290
290
|
for name in ["mimetype", "META-INF/container.xml", *original_order]:
|
|
@@ -323,6 +323,8 @@ def _write_archive(path: str, files: Member, ordered: list[str]) -> None:
|
|
|
323
323
|
if isinstance(data, bytes):
|
|
324
324
|
archive.writestr(info, data)
|
|
325
325
|
else:
|
|
326
|
+
# zipfile must reserve a ZIP64 header before streaming a large audiobook.
|
|
327
|
+
info.file_size = read.size(data)
|
|
326
328
|
with read.open(data) as source, archive.open(info, "w") as target:
|
|
327
329
|
shutil.copyfileobj(source, target, 1 << 20)
|
|
328
330
|
|
|
@@ -11,6 +11,7 @@ import contextlib
|
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
13
|
import posixpath
|
|
14
|
+
import re
|
|
14
15
|
from collections import defaultdict
|
|
15
16
|
|
|
16
17
|
from .align import AlignmentResult, TimedSentence
|
|
@@ -101,6 +102,18 @@ def _clear_previous(directory: str) -> None:
|
|
|
101
102
|
os.unlink(os.path.join(directory, name))
|
|
102
103
|
|
|
103
104
|
|
|
105
|
+
def _sidecar_stem(href: str) -> str:
|
|
106
|
+
# EPUB paths are untrusted and can contain OS separators or Windows device names.
|
|
107
|
+
stem = posixpath.splitext(posixpath.basename(href.replace("\\", "/")))[0]
|
|
108
|
+
stem = re.sub(r'[<>:"|?*\x00-\x1f]', "_", stem).rstrip(" .") or "chapter"
|
|
109
|
+
device = stem.split(".", 1)[0].upper()
|
|
110
|
+
if device in {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"} or re.fullmatch(
|
|
111
|
+
r"(?:COM|LPT)[1-9¹²³]", device
|
|
112
|
+
):
|
|
113
|
+
stem = "_" + stem
|
|
114
|
+
return stem
|
|
115
|
+
|
|
116
|
+
|
|
104
117
|
def write_sidecars(
|
|
105
118
|
directory: str,
|
|
106
119
|
result: AlignmentResult,
|
|
@@ -128,7 +141,7 @@ def write_sidecars(
|
|
|
128
141
|
names = {}
|
|
129
142
|
for doc_index, track_index in sorted(grouped):
|
|
130
143
|
href = doc_hrefs.get(doc_index, f"doc{doc_index}")
|
|
131
|
-
stem =
|
|
144
|
+
stem = _sidecar_stem(href)
|
|
132
145
|
names[(doc_index, track_index)] = (
|
|
133
146
|
f"{stem}.part{track_index:03d}.vtt" if doc_index in multi_track else f"{stem}.vtt"
|
|
134
147
|
)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Input discovery: patterns, directories, and the ordering between numbered files."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from readalign.audio import (
|
|
9
|
+
_chapters_from,
|
|
10
|
+
_duration_of,
|
|
11
|
+
_output_plan,
|
|
12
|
+
discover_inputs,
|
|
13
|
+
ffprobe,
|
|
14
|
+
natural_key,
|
|
15
|
+
prepare_tracks,
|
|
16
|
+
)
|
|
17
|
+
from readalign.errors import DRMError, InputError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_natural_order_puts_part2_before_part10():
|
|
21
|
+
names = ["part10.mp3", "part2.mp3", "part1.mp3"]
|
|
22
|
+
assert sorted(names, key=natural_key) == ["part1.mp3", "part2.mp3", "part10.mp3"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_a_glob_pattern_is_expanded_in_natural_order(tmp_path):
|
|
26
|
+
"""The shell does not expand patterns for a native command on Windows, so readalign must."""
|
|
27
|
+
for name in ("part10.mp3", "part2.mp3", "part1.mp3"):
|
|
28
|
+
(tmp_path / name).write_bytes(b"x")
|
|
29
|
+
assert discover_inputs(str(tmp_path / "part*.mp3")) == [
|
|
30
|
+
str(tmp_path / "part1.mp3"),
|
|
31
|
+
str(tmp_path / "part2.mp3"),
|
|
32
|
+
str(tmp_path / "part10.mp3"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_a_glob_that_matches_nothing_is_a_clear_error(tmp_path):
|
|
37
|
+
with pytest.raises(InputError, match="matched no audio files"):
|
|
38
|
+
discover_inputs(str(tmp_path / "book-*.mp3"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_a_glob_skips_files_that_are_not_audio(tmp_path):
|
|
42
|
+
(tmp_path / "a.mp3").write_bytes(b"x")
|
|
43
|
+
(tmp_path / "cover.jpg").write_bytes(b"x")
|
|
44
|
+
assert discover_inputs(str(tmp_path / "*")) == [str(tmp_path / "a.mp3")]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_a_drm_file_a_glob_matches_is_refused(tmp_path):
|
|
48
|
+
(tmp_path / "a.mp3").write_bytes(b"x")
|
|
49
|
+
(tmp_path / "b.aax").write_bytes(b"x")
|
|
50
|
+
with pytest.raises(DRMError, match=r"b\.aax"):
|
|
51
|
+
discover_inputs(str(tmp_path / "*"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize("duration", ["inf", "nan", "-1", "N/A", {}])
|
|
55
|
+
def test_invalid_audio_duration_is_rejected(duration):
|
|
56
|
+
probe = {"format": {"duration": duration}, "streams": [{"codec_type": "audio"}]}
|
|
57
|
+
with pytest.raises(InputError, match="no readable duration"):
|
|
58
|
+
_duration_of(probe, "audio.mp3")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_invalid_chapter_marks_are_ignored():
|
|
62
|
+
probe = {"chapters": [
|
|
63
|
+
{"start_time": "nan", "end_time": "inf"},
|
|
64
|
+
{"start_time": "-1", "end_time": "2"},
|
|
65
|
+
{"start_time": "0", "end_time": "3"},
|
|
66
|
+
]}
|
|
67
|
+
assert len(_chapters_from(probe)) == 1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_work_directory_cannot_overwrite_any_source_audio(tmp_path, monkeypatch):
|
|
71
|
+
from readalign import audio
|
|
72
|
+
|
|
73
|
+
directory = tmp_path / "audio"
|
|
74
|
+
directory.mkdir()
|
|
75
|
+
source = directory / "part001.mp3"
|
|
76
|
+
source.write_bytes(b"original")
|
|
77
|
+
monkeypatch.setattr(audio, "ffprobe", lambda path: {
|
|
78
|
+
"streams": [{"codec_type": "audio", "codec_name": "mp3"}],
|
|
79
|
+
})
|
|
80
|
+
with pytest.raises(InputError, match="overwrite source audio"):
|
|
81
|
+
prepare_tracks([str(source)], str(tmp_path), "96k", log=lambda *_: None)
|
|
82
|
+
assert source.read_bytes() == b"original"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize("codec,container,expected", [
|
|
86
|
+
("mp3", "mp3", (".mp3", "audio/mpeg", False)),
|
|
87
|
+
("aac", "mov,mp4,m4a,3gp,3g2,mj2", (".m4a", "audio/mp4", False)),
|
|
88
|
+
("opus", "ogg", (".opus", "audio/ogg; codecs=opus", False)),
|
|
89
|
+
("flac", "flac", (".m4a", "audio/mp4", True)),
|
|
90
|
+
])
|
|
91
|
+
def test_output_format_defaults_preserve_existing_behavior(codec, container, expected):
|
|
92
|
+
probe = {"streams": [{"codec_type": "audio", "codec_name": codec}],
|
|
93
|
+
"format": {"format_name": container}}
|
|
94
|
+
assert _output_plan("source", probe) == expected
|
|
95
|
+
assert _output_plan("source", probe, "mp3") == (".mp3", "audio/mpeg", codec != "mp3")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_unknown_audio_format_is_rejected(tmp_path):
|
|
99
|
+
with pytest.raises(InputError, match="audio format"):
|
|
100
|
+
prepare_tracks([], str(tmp_path), "96k", audio_format="wav")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.skipif(not (shutil.which("ffmpeg") and shutil.which("ffprobe")),
|
|
104
|
+
reason="ffmpeg and ffprobe are needed")
|
|
105
|
+
@pytest.mark.parametrize("source_codec,suffix", [("aac", ".m4a"), ("libmp3lame", ".mp3")])
|
|
106
|
+
def test_mp3_mode_prepares_real_audio_without_changing_source(tmp_path, source_codec, suffix):
|
|
107
|
+
source = tmp_path / ("narration" + suffix)
|
|
108
|
+
subprocess.run([
|
|
109
|
+
"ffmpeg", "-nostdin", "-v", "error", "-y", "-f", "lavfi", "-i",
|
|
110
|
+
"sine=frequency=440:duration=2", "-c:a", source_codec, str(source),
|
|
111
|
+
], check=True)
|
|
112
|
+
original = source.read_bytes()
|
|
113
|
+
tracks = prepare_tracks([str(source)], str(tmp_path / "work"), "96k",
|
|
114
|
+
log=lambda *_: None, audio_format="mp3")
|
|
115
|
+
track, = tracks
|
|
116
|
+
assert track.media_type == "audio/mpeg"
|
|
117
|
+
assert track.path.endswith(".mp3")
|
|
118
|
+
assert track.transcoded == (source_codec != "libmp3lame")
|
|
119
|
+
assert 1.9 < track.duration < 2.2
|
|
120
|
+
assert ffprobe(track.path)["streams"][0]["codec_name"] == "mp3"
|
|
121
|
+
assert source.read_bytes() == original
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.mark.skipif(not (shutil.which("ffmpeg") and shutil.which("ffprobe")),
|
|
125
|
+
reason="ffmpeg and ffprobe are needed")
|
|
126
|
+
def test_mp3_conversion_downmixes_surround_and_retains_chapters(tmp_path):
|
|
127
|
+
source = tmp_path / "surround.m4a"
|
|
128
|
+
metadata = tmp_path / "chapters.txt"
|
|
129
|
+
metadata.write_text(";FFMETADATA1\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000\n"
|
|
130
|
+
"title=First\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=1000\nEND=2000\n"
|
|
131
|
+
"title=Second\n", encoding="utf-8")
|
|
132
|
+
subprocess.run([
|
|
133
|
+
"ffmpeg", "-nostdin", "-v", "error", "-y", "-f", "lavfi", "-i",
|
|
134
|
+
"anullsrc=r=48000:cl=5.1", "-i", str(metadata), "-map_metadata", "1",
|
|
135
|
+
"-t", "2", "-c:a", "aac", str(source),
|
|
136
|
+
], check=True)
|
|
137
|
+
original = source.read_bytes()
|
|
138
|
+
track, = prepare_tracks([str(source)], str(tmp_path / "work"), "96k",
|
|
139
|
+
log=lambda *_: None, audio_format="mp3")
|
|
140
|
+
probe = ffprobe(track.path)
|
|
141
|
+
assert probe["streams"][0]["codec_name"] == "mp3"
|
|
142
|
+
assert probe["streams"][0]["channels"] == 2
|
|
143
|
+
assert [chapter.title for chapter in track.chapters] == ["First", "Second"]
|
|
144
|
+
assert track.chapters[1].start == pytest.approx(1, abs=0.05)
|
|
145
|
+
assert source.read_bytes() == original
|
|
@@ -22,6 +22,7 @@ def test_defaults_match_the_documented_ones():
|
|
|
22
22
|
assert args.dry_run is False
|
|
23
23
|
assert args.language is None
|
|
24
24
|
assert args.report is None
|
|
25
|
+
assert args.audio_format == "auto"
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
def test_no_sidecars_flag():
|
|
@@ -31,6 +32,110 @@ def test_no_sidecars_flag():
|
|
|
31
32
|
assert args.sidecars is False
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def test_unknown_audio_format_is_an_argparse_error():
|
|
36
|
+
with pytest.raises(SystemExit) as error:
|
|
37
|
+
build_parser().parse_args(["build", "--audio", "a", "--epub", "b", "--out", "c",
|
|
38
|
+
"--audio-format", "wav"])
|
|
39
|
+
assert error.value.code == 2
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize("protected", ["ebook", "output", "report"])
|
|
43
|
+
@pytest.mark.parametrize("stage", ["audio", "wav"])
|
|
44
|
+
def test_work_files_cannot_overwrite_protected_paths(tmp_path, monkeypatch, capsys,
|
|
45
|
+
protected, stage):
|
|
46
|
+
from readalign import audio
|
|
47
|
+
|
|
48
|
+
work = tmp_path / "work"
|
|
49
|
+
target = work / stage / ("part001.mp3" if stage == "audio" else "part001.wav")
|
|
50
|
+
target.parent.mkdir(parents=True)
|
|
51
|
+
epub = build_epub(str(target if protected == "ebook" else tmp_path / "book.epub"))
|
|
52
|
+
if protected != "ebook":
|
|
53
|
+
target.write_bytes(b"previous successful output")
|
|
54
|
+
original = target.read_bytes()
|
|
55
|
+
narration = tmp_path / "narration.mp3"
|
|
56
|
+
narration.write_bytes(b"input")
|
|
57
|
+
monkeypatch.setattr(audio, "require_ffmpeg", lambda: None)
|
|
58
|
+
monkeypatch.setattr(audio, "ffprobe", lambda *_: pytest.fail("must refuse before preparing"))
|
|
59
|
+
args = ["build", "--audio", str(narration), "--epub", epub, "--out",
|
|
60
|
+
str(target if protected == "output" else tmp_path / "out.epub"),
|
|
61
|
+
"--work-dir", str(work)]
|
|
62
|
+
if protected == "report":
|
|
63
|
+
args += ["--report", str(target)]
|
|
64
|
+
assert main(args) == 1
|
|
65
|
+
assert "overwrite" in capsys.readouterr().err
|
|
66
|
+
assert target.read_bytes() == original
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.mark.parametrize("protected", ["ebook", "audio"])
|
|
70
|
+
def test_sidecar_directory_cannot_contain_protected_paths(tmp_path, monkeypatch, capsys,
|
|
71
|
+
protected):
|
|
72
|
+
from readalign import audio
|
|
73
|
+
|
|
74
|
+
directory = tmp_path / "out-sidecars"
|
|
75
|
+
directory.mkdir()
|
|
76
|
+
target = directory / ("chapter1.vtt" if protected != "audio" else "narration.mp3")
|
|
77
|
+
epub = build_epub(str(target if protected == "ebook" else tmp_path / "book.epub"))
|
|
78
|
+
narration = target if protected == "audio" else tmp_path / "narration.mp3"
|
|
79
|
+
narration.write_bytes(b"input")
|
|
80
|
+
original = target.read_bytes()
|
|
81
|
+
monkeypatch.setattr(audio, "require_ffmpeg", lambda: None)
|
|
82
|
+
monkeypatch.setattr(audio, "ffprobe", lambda *_: pytest.fail("must refuse before preparing"))
|
|
83
|
+
assert main(["build", "--audio", str(narration), "--epub", epub, "--out",
|
|
84
|
+
str(tmp_path / "out.epub")]) == 1
|
|
85
|
+
assert "sidecar" in capsys.readouterr().err
|
|
86
|
+
assert target.read_bytes() == original
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.skipif(not has_ffmpeg, reason="ffmpeg is needed")
|
|
90
|
+
def test_no_sidecars_allows_input_in_sidecar_directory(tmp_path):
|
|
91
|
+
directory = tmp_path / "out-sidecars"
|
|
92
|
+
directory.mkdir()
|
|
93
|
+
epub = build_epub(str(directory / "book.epub"))
|
|
94
|
+
original = (directory / "book.epub").read_bytes()
|
|
95
|
+
assert main(["build", "--audio", "examples/aesop/aesop-section-37.mp3", "--epub", epub,
|
|
96
|
+
"--out", str(tmp_path / "out.epub"), "--no-sidecars", "--dry-run"]) == 0
|
|
97
|
+
assert (directory / "book.epub").read_bytes() == original
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.skipif(not has_ffmpeg, reason="ffmpeg is needed")
|
|
101
|
+
def test_mp3_build_converts_aac_and_records_requested_format(tmp_path, monkeypatch):
|
|
102
|
+
import subprocess
|
|
103
|
+
|
|
104
|
+
from conftest import speak
|
|
105
|
+
from readalign.audio import ffprobe
|
|
106
|
+
from readalign.cli import check_epub
|
|
107
|
+
from readalign.epub import load_epub
|
|
108
|
+
|
|
109
|
+
epub = build_epub(str(tmp_path / "book.epub"))
|
|
110
|
+
package = load_epub(epub)
|
|
111
|
+
words = speak([sentence for doc in package.docs for sentence in doc.sentences])
|
|
112
|
+
audio = tmp_path / "narration.m4a"
|
|
113
|
+
subprocess.run([
|
|
114
|
+
"ffmpeg", "-nostdin", "-v", "error", "-y", "-f", "lavfi", "-i",
|
|
115
|
+
"anullsrc=r=22050:cl=mono", "-t", str(words[-1].end + 2), "-c:a", "aac", str(audio),
|
|
116
|
+
], check=True)
|
|
117
|
+
monkeypatch.setattr(
|
|
118
|
+
"readalign.asr.transcribe_tracks",
|
|
119
|
+
lambda *a, **k: (words, {"cached_tracks": 0, "seconds": 0, "device": "test"}),
|
|
120
|
+
)
|
|
121
|
+
out = tmp_path / "mp3.epub"
|
|
122
|
+
assert main(["build", "--audio", str(audio), "--epub", epub, "--out", str(out),
|
|
123
|
+
"--audio-format", "mp3"]) == 0
|
|
124
|
+
assert check_epub(str(out))["ok"]
|
|
125
|
+
report = json.loads((tmp_path / "readalign-report.json").read_text(encoding="utf-8"))
|
|
126
|
+
assert report["audio_format"] == "mp3"
|
|
127
|
+
assert report["input"]["transcoded"] == ["narration.m4a"]
|
|
128
|
+
with zipfile.ZipFile(out) as archive:
|
|
129
|
+
audio_name, = [name for name in archive.namelist() if name.endswith(".mp3")]
|
|
130
|
+
assert archive.getinfo(audio_name).compress_type == zipfile.ZIP_STORED
|
|
131
|
+
prepared = tmp_path / "embedded.mp3"
|
|
132
|
+
prepared.write_bytes(archive.read(audio_name))
|
|
133
|
+
assert ffprobe(str(prepared))["streams"][0]["codec_name"] == "mp3"
|
|
134
|
+
sidecars = json.loads((tmp_path / "mp3-sidecars" / "align.json").read_text(encoding="utf-8"))
|
|
135
|
+
assert sidecars["audio"][0]["href"] == "audio/part001.mp3"
|
|
136
|
+
assert all(row["audio"] == "audio/part001.mp3" for row in sidecars["alignment"])
|
|
137
|
+
|
|
138
|
+
|
|
34
139
|
def test_missing_subcommand_is_an_argparse_error(capsys):
|
|
35
140
|
with pytest.raises(SystemExit):
|
|
36
141
|
main([])
|
|
@@ -3,11 +3,19 @@
|
|
|
3
3
|
import pytest
|
|
4
4
|
|
|
5
5
|
from readalign.errors import InputError
|
|
6
|
-
from readalign.fs import unique_filenames, write_text
|
|
6
|
+
from readalign.fs import is_within, unique_filenames, write_text
|
|
7
7
|
from readalign.members import SourceFile
|
|
8
8
|
from readalign.package import write_epub
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
def test_directory_containment_compares_components(tmp_path):
|
|
12
|
+
root = tmp_path / "sidecars"
|
|
13
|
+
assert is_within(str(root / "chapter.vtt"), str(root))
|
|
14
|
+
assert is_within(str(root), str(root))
|
|
15
|
+
assert not is_within(str(tmp_path / "sidecars-other" / "chapter.vtt"), str(root))
|
|
16
|
+
assert not is_within(str(root / ".." / "book.epub"), str(root))
|
|
17
|
+
|
|
18
|
+
|
|
11
19
|
@pytest.mark.parametrize("exists", [False, True])
|
|
12
20
|
def test_failed_epub_write_leaves_no_partial_output(tmp_path, exists):
|
|
13
21
|
target = tmp_path / "out.epub"
|
|
@@ -13,7 +13,7 @@ from conftest import CHAPTERS, STRUCTURED, build_epub, make_tracks, speak
|
|
|
13
13
|
from readalign.align import align
|
|
14
14
|
from readalign.cli import check_epub
|
|
15
15
|
from readalign.epub import OPF_NS, load_epub, local_name
|
|
16
|
-
from readalign.members import SourceFile
|
|
16
|
+
from readalign.members import ArchiveMember, SourceFile
|
|
17
17
|
from readalign.package import ACTIVE_CLASS, build_output, ensure_mimetype, write_epub
|
|
18
18
|
from readalign.smil import EPUB_NS, SMIL_NS
|
|
19
19
|
|
|
@@ -211,6 +211,78 @@ def test_check_accepts_what_build_wrote(aligned, tmp_path):
|
|
|
211
211
|
assert findings["sentences"] == len(result.timed)
|
|
212
212
|
|
|
213
213
|
|
|
214
|
+
@pytest.mark.parametrize("from_archive", [False, True])
|
|
215
|
+
def test_streamed_audio_selects_zip64_before_writing(tmp_path, monkeypatch, from_archive):
|
|
216
|
+
"""Lower the threshold to exercise multi-gigabyte audio without a huge fixture."""
|
|
217
|
+
payload = b"a" * 4096
|
|
218
|
+
if from_archive:
|
|
219
|
+
original = tmp_path / "original.epub"
|
|
220
|
+
with zipfile.ZipFile(original, "w") as archive:
|
|
221
|
+
archive.writestr("audio.mp3", payload)
|
|
222
|
+
member = ArchiveMember(str(original), "audio.mp3")
|
|
223
|
+
else:
|
|
224
|
+
original = tmp_path / "audio.mp3"
|
|
225
|
+
original.write_bytes(payload)
|
|
226
|
+
member = SourceFile(str(original))
|
|
227
|
+
monkeypatch.setattr(zipfile, "ZIP64_LIMIT", 1024)
|
|
228
|
+
out = tmp_path / "large.epub"
|
|
229
|
+
small = tmp_path / "small.mp3"
|
|
230
|
+
small.write_bytes(b"small")
|
|
231
|
+
files = {"audio.mp3": member, "small.mp3": SourceFile(str(small))}
|
|
232
|
+
ensure_mimetype(files)
|
|
233
|
+
write_epub(str(out), files, ["small.mp3"])
|
|
234
|
+
with zipfile.ZipFile(out) as archive:
|
|
235
|
+
assert archive.read("audio.mp3") == payload
|
|
236
|
+
assert archive.getinfo("audio.mp3").extract_version == 45
|
|
237
|
+
assert archive.getinfo("small.mp3").extract_version == 20
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@pytest.mark.parametrize("shared_track", [True, False])
|
|
241
|
+
def test_long_book_uses_file_local_clocks_and_stored_audio(tmp_path, shared_track):
|
|
242
|
+
from readalign.align import TimedSentence
|
|
243
|
+
from readalign.clock import parse_clock
|
|
244
|
+
from readalign.sidecar import build_vtt
|
|
245
|
+
|
|
246
|
+
chapters = [(f"Chapter {index}", "".join(
|
|
247
|
+
f"<p>Voyage sentence {n} describes the sea.</p>" for n in range(450)
|
|
248
|
+
)) for index in range(2)]
|
|
249
|
+
package = load_epub(build_epub(str(tmp_path / "long.epub"), chapters=chapters))
|
|
250
|
+
tracks = make_tracks([16 * 3600] if shared_track else [8 * 3600, 8 * 3600])
|
|
251
|
+
# These bytes exercise packaging only, not audio decoding or reader playback.
|
|
252
|
+
for track in tracks:
|
|
253
|
+
path = tmp_path / f"track{track.index}.mp3"
|
|
254
|
+
path.write_bytes(b"synthetic audio")
|
|
255
|
+
track.path = str(path)
|
|
256
|
+
by_doc = {}
|
|
257
|
+
for index, doc in enumerate(package.docs):
|
|
258
|
+
start = index * 8 * 3600 + 15.125
|
|
259
|
+
by_doc[doc.manifest_id] = [
|
|
260
|
+
TimedSentence(sentence, start + n * 2, start + n * 2 + 1.5,
|
|
261
|
+
confidence=1.0, track_index=1 if shared_track else index + 1)
|
|
262
|
+
for n, sentence in enumerate(doc.sentences)
|
|
263
|
+
]
|
|
264
|
+
files, summary = build_output(package, by_doc, tracks)
|
|
265
|
+
assert summary["audio_files"] == len(tracks)
|
|
266
|
+
out = str(tmp_path / "out.epub")
|
|
267
|
+
ensure_mimetype(files)
|
|
268
|
+
write_epub(out, files, package.order)
|
|
269
|
+
assert check_epub(out, probe_audio=False)["ok"]
|
|
270
|
+
with zipfile.ZipFile(out) as archive:
|
|
271
|
+
for index, doc in enumerate(package.docs):
|
|
272
|
+
root = etree.fromstring(archive.read(f"EPUB/media-overlays/chapter{index + 1}.smil"))
|
|
273
|
+
clips = list(root.iter(f"{{{SMIL_NS}}}audio"))
|
|
274
|
+
expected_start = index * 8 * 3600 + 15.125 if shared_track else 15.125
|
|
275
|
+
assert parse_clock(clips[0].get("clipBegin")) == expected_start
|
|
276
|
+
assert len(clips) == len(doc.sentences) == 450
|
|
277
|
+
track = tracks[0 if shared_track else index]
|
|
278
|
+
vtt = build_vtt(by_doc[doc.manifest_id], track, track.href)
|
|
279
|
+
assert ("08:00:15.125" if shared_track and index else "00:00:15.125") in vtt
|
|
280
|
+
for info in archive.infolist():
|
|
281
|
+
if info.filename.endswith(".mp3"):
|
|
282
|
+
assert info.compress_type == zipfile.ZIP_STORED
|
|
283
|
+
assert archive.read(info) == b"synthetic audio"
|
|
284
|
+
|
|
285
|
+
|
|
214
286
|
def _clip_past_the_end(root):
|
|
215
287
|
root.find(f".//{{{SMIL_NS}}}audio").set("clipEnd", "9:59:59.000")
|
|
216
288
|
|
|
@@ -42,6 +42,31 @@ def test_collision_suffix_does_not_overwrite_an_existing_chapter_name(tmp_path):
|
|
|
42
42
|
assert all(Path(path).is_file() for path in paths)
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
@pytest.mark.parametrize("href", [
|
|
46
|
+
r"..\escaped.xhtml", r"C:\escaped.xhtml", "CON.xhtml", "NUL.part.xhtml",
|
|
47
|
+
"chapter:one.xhtml", "chapter?.xhtml", "...xhtml", "COM1.xhtml", "LPT².xhtml",
|
|
48
|
+
"CONIN$.xhtml", "control\x01.xhtml",
|
|
49
|
+
])
|
|
50
|
+
def test_sidecar_names_are_safe_local_leaves(tmp_path, href):
|
|
51
|
+
_, result, tracks = prepared(tmp_path)
|
|
52
|
+
directory = tmp_path / "sidecars"
|
|
53
|
+
paths = write_sidecars(str(directory), result, tracks, {0: href, 1: "chapter2.xhtml"}, {})
|
|
54
|
+
assert all(Path(path).resolve().parent == directory.resolve() for path in paths)
|
|
55
|
+
assert all(Path(path).is_file() for path in paths)
|
|
56
|
+
assert not (tmp_path / "escaped.vtt").exists()
|
|
57
|
+
names = [Path(path).name for path in paths]
|
|
58
|
+
assert all(not any(c in name for c in '<>:"|?*\\') for name in names)
|
|
59
|
+
devices = {"CON", "NUL", "COM1", "LPT²", "CONIN$"}
|
|
60
|
+
assert all(name.split(".", 1)[0].upper() not in devices for name in names)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_sanitized_chapter_names_remain_unique(tmp_path):
|
|
64
|
+
_, result, tracks = prepared(tmp_path)
|
|
65
|
+
paths = write_sidecars(str(tmp_path / "sidecars"), result, tracks,
|
|
66
|
+
{0: "chapter?.xhtml", 1: "chapter*.xhtml"}, {})
|
|
67
|
+
assert len(paths) == len({Path(path).name.casefold() for path in paths}) == 3
|
|
68
|
+
|
|
69
|
+
|
|
45
70
|
def prepared(tmp_path):
|
|
46
71
|
package = load_epub(build_epub(str(tmp_path / "in.epub")))
|
|
47
72
|
sentences = [sentence for doc in package.docs for sentence in doc.sentences]
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
"""Input discovery: patterns, directories, and the ordering between numbered files."""
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from readalign.audio import (
|
|
6
|
-
_chapters_from,
|
|
7
|
-
_duration_of,
|
|
8
|
-
discover_inputs,
|
|
9
|
-
natural_key,
|
|
10
|
-
prepare_tracks,
|
|
11
|
-
)
|
|
12
|
-
from readalign.errors import DRMError, InputError
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def test_natural_order_puts_part2_before_part10():
|
|
16
|
-
names = ["part10.mp3", "part2.mp3", "part1.mp3"]
|
|
17
|
-
assert sorted(names, key=natural_key) == ["part1.mp3", "part2.mp3", "part10.mp3"]
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_a_glob_pattern_is_expanded_in_natural_order(tmp_path):
|
|
21
|
-
"""The shell does not expand patterns for a native command on Windows, so readalign must."""
|
|
22
|
-
for name in ("part10.mp3", "part2.mp3", "part1.mp3"):
|
|
23
|
-
(tmp_path / name).write_bytes(b"x")
|
|
24
|
-
assert discover_inputs(str(tmp_path / "part*.mp3")) == [
|
|
25
|
-
str(tmp_path / "part1.mp3"),
|
|
26
|
-
str(tmp_path / "part2.mp3"),
|
|
27
|
-
str(tmp_path / "part10.mp3"),
|
|
28
|
-
]
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def test_a_glob_that_matches_nothing_is_a_clear_error(tmp_path):
|
|
32
|
-
with pytest.raises(InputError, match="matched no audio files"):
|
|
33
|
-
discover_inputs(str(tmp_path / "book-*.mp3"))
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def test_a_glob_skips_files_that_are_not_audio(tmp_path):
|
|
37
|
-
(tmp_path / "a.mp3").write_bytes(b"x")
|
|
38
|
-
(tmp_path / "cover.jpg").write_bytes(b"x")
|
|
39
|
-
assert discover_inputs(str(tmp_path / "*")) == [str(tmp_path / "a.mp3")]
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def test_a_drm_file_a_glob_matches_is_refused(tmp_path):
|
|
43
|
-
(tmp_path / "a.mp3").write_bytes(b"x")
|
|
44
|
-
(tmp_path / "b.aax").write_bytes(b"x")
|
|
45
|
-
with pytest.raises(DRMError, match=r"b\.aax"):
|
|
46
|
-
discover_inputs(str(tmp_path / "*"))
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@pytest.mark.parametrize("duration", ["inf", "nan", "-1", "N/A", {}])
|
|
50
|
-
def test_invalid_audio_duration_is_rejected(duration):
|
|
51
|
-
probe = {"format": {"duration": duration}, "streams": [{"codec_type": "audio"}]}
|
|
52
|
-
with pytest.raises(InputError, match="no readable duration"):
|
|
53
|
-
_duration_of(probe, "audio.mp3")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def test_invalid_chapter_marks_are_ignored():
|
|
57
|
-
probe = {"chapters": [
|
|
58
|
-
{"start_time": "nan", "end_time": "inf"},
|
|
59
|
-
{"start_time": "-1", "end_time": "2"},
|
|
60
|
-
{"start_time": "0", "end_time": "3"},
|
|
61
|
-
]}
|
|
62
|
-
assert len(_chapters_from(probe)) == 1
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def test_work_directory_cannot_overwrite_any_source_audio(tmp_path, monkeypatch):
|
|
66
|
-
from readalign import audio
|
|
67
|
-
|
|
68
|
-
directory = tmp_path / "audio"
|
|
69
|
-
directory.mkdir()
|
|
70
|
-
source = directory / "part001.mp3"
|
|
71
|
-
source.write_bytes(b"original")
|
|
72
|
-
monkeypatch.setattr(audio, "ffprobe", lambda path: {
|
|
73
|
-
"streams": [{"codec_type": "audio", "codec_name": "mp3"}],
|
|
74
|
-
})
|
|
75
|
-
with pytest.raises(InputError, match="overwrite source audio"):
|
|
76
|
-
prepare_tracks([str(source)], str(tmp_path), "96k", log=lambda *_: None)
|
|
77
|
-
assert source.read_bytes() == b"original"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|