Нарезка аудиокниги по главам: треки с ID3-тегами вместо одного слитного файла
This commit is contained in:
@@ -190,6 +190,10 @@ cd out/tr && python3 ~/Projects/book_translator/05_create_audiobook.py \
|
|||||||
|
|
||||||
# 3. проверка ДО того, как скрипт уберёт temp_audio/
|
# 3. проверка ДО того, как скрипт уберёт temp_audio/
|
||||||
python3 scripts/audiobook.py verify out/tr/translations_tts out/tr/audiobook
|
python3 scripts/audiobook.py verify out/tr/translations_tts out/tr/audiobook
|
||||||
|
|
||||||
|
# 4. нарезка по главам — тоже до уборки temp_audio/
|
||||||
|
python3 scripts/audiobook.py split out/tr/translations_tts out/tr/audiobook \
|
||||||
|
out/tr/tracks --album "Название книги" --author "Автор" --gap 0.3
|
||||||
```
|
```
|
||||||
|
|
||||||
`verify` находит главы с недостающими фрагментами, битые и нулевые mp3 и главы,
|
`verify` находит главы с недостающими фрагментами, битые и нулевые mp3 и главы,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: pdf-to-kindle
|
name: pdf-to-kindle
|
||||||
description: Convert a text-layer PDF (usually one generated by calibre from an ebook) into EPUB/AZW3 for Kindle while preserving italics, bold, chapter headings, sidebars/journal blocks, images and cross-page paragraphs. Use when the user asks to read a PDF book on Kindle or another e-reader, to convert a PDF to EPUB/AZW3/MOBI, or complains that a converted book lost its italics, merged its chapters, or reflows badly on the device. Not for scanned PDFs (no text layer) — those need OCR first. Also covers translating such a book into another language before packing it, delegated to the external book_translator project, and generating a TTS audiobook from it with an integrity check for dropped fragments.
|
description: Convert a text-layer PDF (usually one generated by calibre from an ebook) into EPUB/AZW3 for Kindle while preserving italics, bold, chapter headings, sidebars/journal blocks, images and cross-page paragraphs. Use when the user asks to read a PDF book on Kindle or another e-reader, to convert a PDF to EPUB/AZW3/MOBI, or complains that a converted book lost its italics, merged its chapters, or reflows badly on the device. Not for scanned PDFs (no text layer) — those need OCR first. Also covers translating such a book into another language before packing it, delegated to the external book_translator project, and generating a TTS audiobook from it with an integrity check for dropped fragments and a split into per-chapter tagged tracks.
|
||||||
---
|
---
|
||||||
|
|
||||||
# PDF to Kindle
|
# PDF to Kindle
|
||||||
@@ -185,12 +185,31 @@ the external repo is still never edited.
|
|||||||
file that already exists *and is non-empty*, so delete the bad ones `verify`
|
file that already exists *and is non-empty*, so delete the bad ones `verify`
|
||||||
named and run it again. It never re-checks that an existing file is sane,
|
named and run it again. It never re-checks that an existing file is sane,
|
||||||
which is exactly why step 3 exists.
|
which is exactly why step 3 exists.
|
||||||
|
5. **Split into chapter tracks**, also before the temp files go:
|
||||||
|
|
||||||
Known limits of the external stage: it merges everything into a single
|
```bash
|
||||||
`audiobook_complete.mp3` with no per-chapter files and no chapter marks —
|
python scripts/audiobook.py split <workdir>/translations_tts <workdir>/audiobook \
|
||||||
poor for players; splitting is not implemented here. The phonetics stage
|
<workdir>/tracks --album "Название" --author "Автор" --gap 0.3
|
||||||
(`07_extract_terms.py` + `08_generate_phonetics.py`) is worth running first for
|
```
|
||||||
a technical book, or the Russian voice will mangle every English term.
|
|
||||||
|
The external stage only ever produces one merged `audiobook_complete.mp3`
|
||||||
|
with no chapter marks, which is bad for players. `split` rebuilds per-chapter
|
||||||
|
`NNN - Title.mp3` from the same fragments, with ID3 album/artist/track/title
|
||||||
|
tags, and refuses a chapter whose fragments are incomplete (`--force`
|
||||||
|
overrides). Concatenation is stream-copy, falling back to a re-encode only if
|
||||||
|
the mp3 streams don't line up; `--gap` inserts silence between paragraphs,
|
||||||
|
generated to match the fragments' own codec parameters so the copy path
|
||||||
|
stays viable. Hand the result to the `prepare-audiobooks` skill for covers
|
||||||
|
and library layout.
|
||||||
|
|
||||||
|
The phonetics stage (`07_extract_terms.py` + `08_generate_phonetics.py`) is
|
||||||
|
worth running first for a technical book, or the Russian voice will mangle
|
||||||
|
every English term.
|
||||||
|
|
||||||
|
**Ordering constraint for the whole stage:** `verify` and `split` both read
|
||||||
|
`audiobook/temp_audio/`, and the external script's `cleanup_temp_files()`
|
||||||
|
deletes it right after merging. Run both before that, or the fragments are gone
|
||||||
|
and only the merged file's total duration can be checked.
|
||||||
|
|
||||||
`scripts/test_audiobook.py` builds real silent mp3s with ffmpeg and checks that
|
`scripts/test_audiobook.py` builds real silent mp3s with ffmpeg and checks that
|
||||||
`verify` catches a missing fragment, an empty file, and passes a clean book.
|
`verify` catches a missing fragment, an empty file, and passes a clean book.
|
||||||
|
|||||||
@@ -169,6 +169,127 @@ def check_final(audiobook, total_chars, rate):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def chapter_fragments(temp, num):
|
||||||
|
"""Фрагменты главы в порядке воспроизведения: вводный, затем абзацы."""
|
||||||
|
intro = temp / ("chapter_%03d_intro.mp3" % num)
|
||||||
|
paras = sorted(temp.glob("chapter_%03d_para_*.mp3" % num))
|
||||||
|
return ([intro] if intro.exists() else []) + paras
|
||||||
|
|
||||||
|
|
||||||
|
def audio_params(sample):
|
||||||
|
"""Параметры кодирования первого фрагмента — чтобы тишина совпала и
|
||||||
|
склейка прошла без перекодирования."""
|
||||||
|
r = subprocess.run(
|
||||||
|
["ffprobe", "-v", "error", "-select_streams", "a:0",
|
||||||
|
"-show_entries", "stream=sample_rate,channels,bit_rate",
|
||||||
|
"-of", "default=nw=1", str(sample)],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
p = dict(l.split("=", 1) for l in r.stdout.strip().splitlines() if "=" in l)
|
||||||
|
return (p.get("sample_rate", "24000"), p.get("channels", "1"),
|
||||||
|
p.get("bit_rate", "48000"))
|
||||||
|
|
||||||
|
|
||||||
|
def make_silence(path, seconds, params):
|
||||||
|
rate, channels, bitrate = params
|
||||||
|
subprocess.run(
|
||||||
|
["ffmpeg", "-v", "error", "-y", "-f", "lavfi",
|
||||||
|
"-i", "anullsrc=r=%s:cl=%s" % (rate, "mono" if channels == "1" else "stereo"),
|
||||||
|
"-t", str(seconds), "-b:a", bitrate, str(path)],
|
||||||
|
check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def concat(files, out, tmp, meta):
|
||||||
|
"""Склейка через concat-демультиплексор. Сначала без перекодирования;
|
||||||
|
если mp3-потоки не сошлись — пересжатие."""
|
||||||
|
listing = tmp / "concat.txt"
|
||||||
|
listing.write_text(
|
||||||
|
"".join("file '%s'\n" % str(f.resolve()).replace("'", r"'\''") for f in files),
|
||||||
|
encoding="utf-8")
|
||||||
|
tags = []
|
||||||
|
for k, v in meta.items():
|
||||||
|
tags += ["-metadata", "%s=%s" % (k, v)]
|
||||||
|
base = ["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", "0",
|
||||||
|
"-i", str(listing)]
|
||||||
|
r = subprocess.run(base + ["-c", "copy"] + tags + [str(out)],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if r.returncode == 0 and out.exists() and out.stat().st_size:
|
||||||
|
return True
|
||||||
|
r = subprocess.run(base + ["-c:a", "libmp3lame"] + tags + [str(out)],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if r.returncode:
|
||||||
|
print(" ffmpeg:", r.stderr.strip()[:200])
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def safe_name(s, limit=70):
|
||||||
|
s = re.sub(r"[/\\\x00-\x1f]", " ", s)
|
||||||
|
s = re.sub(r"\s+", " ", s).strip(" .")
|
||||||
|
return s[:limit] or "Без названия"
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_split(args):
|
||||||
|
"""Треки по главам вместо одного слитного файла."""
|
||||||
|
temp = args.audiobook / "temp_audio"
|
||||||
|
if not temp.is_dir():
|
||||||
|
sys.exit("нет %s — нарезать не из чего. Каталог удаляется методом "
|
||||||
|
"cleanup_temp_files() стороннего скрипта: режь до уборки." % temp)
|
||||||
|
expect = expected_fragments(args.translations)
|
||||||
|
titles = {}
|
||||||
|
for f in sorted(args.translations.glob("chapter_*_translated*.json")):
|
||||||
|
n = int(re.search(r"chapter_(\d+)", f.name).group(1))
|
||||||
|
titles[n] = json.loads(f.read_text(encoding="utf-8")).get("title", "")
|
||||||
|
|
||||||
|
args.output.mkdir(parents=True, exist_ok=True)
|
||||||
|
work = args.output / ".tmp"
|
||||||
|
work.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
first = next(iter(sorted(temp.glob("*.mp3"))), None)
|
||||||
|
if first is None:
|
||||||
|
sys.exit("в %s нет mp3" % temp)
|
||||||
|
params = audio_params(first)
|
||||||
|
silence = None
|
||||||
|
if args.gap > 0:
|
||||||
|
silence = work / "silence.mp3"
|
||||||
|
make_silence(silence, args.gap, params)
|
||||||
|
|
||||||
|
made = skipped = 0
|
||||||
|
for n in sorted(expect):
|
||||||
|
frags = chapter_fragments(temp, n)
|
||||||
|
want = expect[n]["paragraphs"] + 1
|
||||||
|
if len(frags) < want and not args.force:
|
||||||
|
print("глава %03d: %d из %d фрагментов — пропущена (--force чтобы всё равно)"
|
||||||
|
% (n, len(frags), want))
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
seq = frags
|
||||||
|
if silence:
|
||||||
|
seq = []
|
||||||
|
for f in frags:
|
||||||
|
seq += [f, silence]
|
||||||
|
seq = seq[:-1]
|
||||||
|
title = safe_name(titles.get(n) or ("Глава %d" % n))
|
||||||
|
out = args.output / ("%03d - %s.mp3" % (n, title))
|
||||||
|
meta = {"title": title, "track": "%d/%d" % (n + 1, len(expect)),
|
||||||
|
"album": args.album, "artist": args.author,
|
||||||
|
"album_artist": args.author, "genre": "Audiobook"}
|
||||||
|
if concat(seq, out, work, meta):
|
||||||
|
made += 1
|
||||||
|
else:
|
||||||
|
print("глава %03d: склейка не удалась" % n)
|
||||||
|
|
||||||
|
for f in work.glob("*"):
|
||||||
|
f.unlink()
|
||||||
|
work.rmdir()
|
||||||
|
|
||||||
|
total = sum(duration(f) or 0 for f in args.output.glob("*.mp3"))
|
||||||
|
size = sum(f.stat().st_size for f in args.output.glob("*.mp3")) / 1024 / 1024
|
||||||
|
print("\nтреков: %d, пропущено глав: %d, звучание %.1f ч, %.0f МБ"
|
||||||
|
% (made, skipped, total / 3600, size))
|
||||||
|
print("каталог: %s" % args.output)
|
||||||
|
return skipped == 0 and made > 0
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description=__doc__,
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
@@ -184,6 +305,18 @@ def main():
|
|||||||
v.add_argument("audiobook", type=Path, help="каталог audiobook/")
|
v.add_argument("audiobook", type=Path, help="каталог audiobook/")
|
||||||
v.set_defaults(func=cmd_verify)
|
v.set_defaults(func=cmd_verify)
|
||||||
|
|
||||||
|
s = sub.add_parser("split", help="нарезать по главам вместо одного файла")
|
||||||
|
s.add_argument("translations", type=Path, help="каталог, поданный в озвучку")
|
||||||
|
s.add_argument("audiobook", type=Path, help="каталог audiobook/ с temp_audio/")
|
||||||
|
s.add_argument("output", type=Path, help="куда сложить треки")
|
||||||
|
s.add_argument("--album", default="Аудиокнига", help="название книги для тегов")
|
||||||
|
s.add_argument("--author", default="", help="автор для тегов")
|
||||||
|
s.add_argument("--gap", type=float, default=0.3,
|
||||||
|
help="пауза между абзацами, секунд (0 — без пауз)")
|
||||||
|
s.add_argument("--force", action="store_true",
|
||||||
|
help="резать даже главы с недостающими фрагментами")
|
||||||
|
s.set_defaults(func=cmd_split)
|
||||||
|
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
result = args.func(args)
|
result = args.func(args)
|
||||||
if result is False:
|
if result is False:
|
||||||
|
|||||||
@@ -89,11 +89,75 @@ def test_verify_detects_empty_file(tmp: Path):
|
|||||||
assert a.cmd_verify(ns) is False, "пустой файл должен завалить проверку"
|
assert a.cmd_verify(ns) is False, "пустой файл должен завалить проверку"
|
||||||
|
|
||||||
|
|
||||||
|
def build_book(tmp: Path, chapters=2, paras=3):
|
||||||
|
"""Готовый синтез: подготовленные главы + фрагменты в temp_audio/."""
|
||||||
|
tts, ab = tmp / "tts", tmp / "audiobook"
|
||||||
|
tts.mkdir()
|
||||||
|
temp = ab / "temp_audio"
|
||||||
|
temp.mkdir(parents=True)
|
||||||
|
for n in range(chapters):
|
||||||
|
write_chapter(tts, n, ["х" * 100] * paras)
|
||||||
|
mp3(temp / ("chapter_%03d_intro.mp3" % n), 1)
|
||||||
|
for i in range(paras):
|
||||||
|
mp3(temp / ("chapter_%03d_para_%04d.mp3" % (n, i)), 2)
|
||||||
|
return tts, ab
|
||||||
|
|
||||||
|
|
||||||
|
def split_ns(tts, ab, out, **kw):
|
||||||
|
fields = {"translations": tts, "audiobook": ab, "output": out,
|
||||||
|
"album": "Книга", "author": "Автор", "gap": 0.3, "force": False}
|
||||||
|
fields.update(kw)
|
||||||
|
return type("ns", (), fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_makes_chapter_tracks(tmp: Path):
|
||||||
|
tts, ab = build_book(tmp, chapters=2, paras=3)
|
||||||
|
out = tmp / "tracks"
|
||||||
|
assert a.cmd_split(split_ns(tts, ab, out)) is True
|
||||||
|
tracks = sorted(out.glob("*.mp3"))
|
||||||
|
assert len(tracks) == 2, "по треку на главу"
|
||||||
|
assert tracks[0].name.startswith("000 - "), tracks[0].name
|
||||||
|
assert not (out / ".tmp").exists(), "временный каталог должен убираться"
|
||||||
|
|
||||||
|
# длительность: 1 вводный + 3 абзаца + 3 паузы = 1 + 6 + 0.9 ≈ 7.9 с
|
||||||
|
d = a.duration(tracks[0])
|
||||||
|
assert 7.0 < d < 9.0, d
|
||||||
|
|
||||||
|
# теги проставлены
|
||||||
|
r = subprocess.run(
|
||||||
|
["ffprobe", "-v", "error", "-show_entries",
|
||||||
|
"format_tags=album,artist,track,title", "-of", "default=nw=1",
|
||||||
|
str(tracks[0])], capture_output=True, text=True)
|
||||||
|
assert "Книга" in r.stdout and "Автор" in r.stdout, r.stdout
|
||||||
|
assert "track=1/2" in r.stdout, r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_skips_incomplete_chapter(tmp: Path):
|
||||||
|
tts, ab = build_book(tmp, chapters=2, paras=3)
|
||||||
|
(ab / "temp_audio" / "chapter_001_para_0002.mp3").unlink()
|
||||||
|
out = tmp / "tracks"
|
||||||
|
assert a.cmd_split(split_ns(tts, ab, out)) is False, "неполная глава должна пропускаться"
|
||||||
|
assert len(list(out.glob("*.mp3"))) == 1, "целая глава всё равно нарезается"
|
||||||
|
|
||||||
|
out2 = tmp / "forced"
|
||||||
|
assert a.cmd_split(split_ns(tts, ab, out2, force=True)) is True
|
||||||
|
assert len(list(out2.glob("*.mp3"))) == 2, "--force режет обе"
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_without_gap(tmp: Path):
|
||||||
|
tts, ab = build_book(tmp, chapters=1, paras=2)
|
||||||
|
out = tmp / "tracks"
|
||||||
|
assert a.cmd_split(split_ns(tts, ab, out, gap=0)) is True
|
||||||
|
d = a.duration(next(out.glob("*.mp3")))
|
||||||
|
assert 4.5 < d < 5.6, d # 1 + 2 + 2 без пауз
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if subprocess.run(["which", "ffmpeg"], capture_output=True).returncode:
|
if subprocess.run(["which", "ffmpeg"], capture_output=True).returncode:
|
||||||
sys.exit("нужен ffmpeg")
|
sys.exit("нужен ffmpeg")
|
||||||
for fn in (test_prep, test_verify_detects_gap, test_verify_clean,
|
for fn in (test_prep, test_verify_detects_gap, test_verify_clean,
|
||||||
test_verify_detects_empty_file):
|
test_verify_detects_empty_file, test_split_makes_chapter_tracks,
|
||||||
|
test_split_skips_incomplete_chapter, test_split_without_gap):
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
print("---", fn.__name__)
|
print("---", fn.__name__)
|
||||||
fn(Path(d))
|
fn(Path(d))
|
||||||
|
|||||||
Reference in New Issue
Block a user