bitscuit a1796e82aa pdf-to-audiobook: PDF -> narrated MP3 via edge-tts
Pipeline: extract (pdftotext) -> scan (OCR-defect report + fix template)
-> clean (noise/header removal, hyphenation, exact-string/regex OCR
repairs, chapter-heading detection) -> voices (sample + comparison)
-> chunk (sentence-aware) -> synth (resumable edge-tts) ->
stitch (ffmpeg concat + RMS verify) -> chapters (ID3v2.4 CHAP).

Config-driven per book; validated end-to-end on a clean digital PDF
and on a 546-page scanned book (byte-identical clean output to the
reference run, 95 auto-detected chapters).
2026-08-25 14:15:50 +02:00

107 lines
4.1 KiB
Python

"""Map chapter text positions onto audio time and tag the MP3 (ID3v2.4 CHAP).
Mapping: probe each chunk MP3's duration once (ffprobe) to get chunk start
times in ms; use chunk_offsets.json (start char-offset of each chunk in the
cleaned text) to binary-search each chapter's position. Each chapter spans
from its start to the next chapter's start (last: end of file).
Uses mutagen's CHAP class (1.x kwargs: start_time/end_time in ms,
element_id, sub_frames). Hand-rolling the CHAP bytes is error-prone.
Pitfalls baked in (all hit in the reference 2026-08 session):
- work on a COPY of the audio (ID3() + save() to a missing path writes a
tag-only file that ffprobe cannot read)
- TXXX uses text= (value= silently stores empty)
- many players display element_id, not the TXXX subframe, so the title
goes into element_id too (Latin1, CHnnn fallback for non-Latin1)
"""
import json
import os
import shutil
import subprocess
import sys
from bisect import bisect_right
def probe_chunk_times(audio_dir: str) -> tuple:
"""Return (offsets_ms, total_ms): start time of each chunk in ms."""
files = sorted(f for f in os.listdir(audio_dir) if f.endswith(".mp3"))
offsets = []
t = 0
for fn in files:
offsets.append(t)
r = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", os.path.join(audio_dir, fn)],
capture_output=True, text=True, check=True,
)
t += int(float(r.stdout.strip()) * 1000)
return offsets, t
def main(clean_path: str, audio_dir: str, in_mp3: str, out_mp3: str):
# clean.py writes <clean>.chapters.json / <clean>.chunk_offsets.json
stem = os.path.splitext(clean_path)[0]
chapters = json.load(open(stem + ".chapters.json"))
offsets_path = stem + ".chunk_offsets.json"
if os.path.exists(offsets_path):
char_offsets = json.load(open(offsets_path))
else:
# fallback: even distribution (crude, but keeps the tool working)
total_chars = len(open(clean_path, encoding="utf-8").read())
n = len([f for f in os.listdir(audio_dir) if f.endswith(".mp3")])
char_offsets = [int(i * total_chars / n) for i in range(n)]
print("WARNING: chunk_offsets.json missing; using even char distribution")
offsets_ms, total_ms = probe_chunk_times(audio_dir)
r = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", in_mp3],
capture_output=True, text=True, check=True,
)
file_end_ms = int(float(r.stdout.strip()) * 1000)
entries = []
for pos, title in chapters:
i = bisect_right(char_offsets, pos) - 1
i = max(0, min(i, len(offsets_ms) - 1))
entries.append([offsets_ms[i], title])
entries.sort(key=lambda e: e[0])
# drop two chapters that land in the same chunk (zero-length span)
dedup = []
for st, title in entries:
if dedup and dedup[-1][0] == st:
continue
dedup.append([st, title])
entries = dedup
if not entries:
raise SystemExit("no usable chapters; check heading_pattern in the config")
from mutagen.id3 import CHAP, ID3, ID3Tags, TXXX
shutil.copy(in_mp3, out_mp3)
tags = ID3(out_mp3)
for n, (st, title) in enumerate(entries, 1):
en = entries[n][0] if n < len(entries) else file_end_ms
elem_id = title if title.encode("latin-1", "replace") == title.encode("latin-1") \
else f"CH{n:03d}"
sub = ID3Tags()
sub.add(TXXX(desc="CHAPTER", text=title))
tags.add(CHAP(element_id=elem_id, start_time=int(st),
end_time=int(en), sub_frames=sub))
tags.save(out_mp3, v2_version=4)
print(json.dumps({
"out": out_mp3,
"chapters": len(entries),
"total_ms": total_ms,
"file_end_ms": file_end_ms,
"first": entries[0],
"last": entries[-1],
}, indent=1))
print("verify: ffprobe -v error -show_chapters " + out_mp3)
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4])