diff --git a/README.md b/README.md index 1fd2206..b01cf13 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,10 @@ cd out/tr && python3 ~/Projects/book_translator/05_create_audiobook.py \ # 3. проверка ДО того, как скрипт уберёт temp_audio/ 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 и главы, diff --git a/SKILL.md b/SKILL.md index 277e425..04867c5 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- 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 @@ -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` named and run it again. It never re-checks that an existing file is sane, 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 -`audiobook_complete.mp3` with no per-chapter files and no chapter marks — -poor for players; splitting is not implemented here. 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. + ```bash + python scripts/audiobook.py split /translations_tts /audiobook \ + /tracks --album "Название" --author "Автор" --gap 0.3 + ``` + + 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 `verify` catches a missing fragment, an empty file, and passes a clean book. diff --git a/scripts/audiobook.py b/scripts/audiobook.py index 2fd8dca..0279a18 100644 --- a/scripts/audiobook.py +++ b/scripts/audiobook.py @@ -169,6 +169,127 @@ def check_final(audiobook, total_chars, rate): 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(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) @@ -184,6 +305,18 @@ def main(): v.add_argument("audiobook", type=Path, help="каталог audiobook/") 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() result = args.func(args) if result is False: diff --git a/scripts/test_audiobook.py b/scripts/test_audiobook.py index 272b6ce..7e83fce 100644 --- a/scripts/test_audiobook.py +++ b/scripts/test_audiobook.py @@ -89,11 +89,75 @@ def test_verify_detects_empty_file(tmp: Path): 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 subprocess.run(["which", "ffmpeg"], capture_output=True).returncode: sys.exit("нужен ffmpeg") 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: print("---", fn.__name__) fn(Path(d))