Озвучка: фрагменты режутся группами (не по абзацу), защита от тихого обрыва склейки, досинтез потерянных фрагментов

This commit is contained in:
Илья Поляков
2026-08-03 11:23:11 +03:00
parent a0c26b41bf
commit 3bbd75b1c3
2 changed files with 208 additions and 38 deletions
+41 -23
View File
@@ -50,12 +50,12 @@ def test_verify_detects_gap(tmp: Path):
for n in (0, 1):
write_chapter(tts, n, [para] * 4)
mp3(temp / ("chapter_%03d_intro.mp3" % n), 1)
# у главы 1 намеренно пропущен последний абзац
count = 4 if n == 0 else 3
# у главы 1 намеренно пропущена последняя группа
count = 2 if n == 0 else 1
for i in range(count):
mp3(temp / ("chapter_%03d_para_%04d.mp3" % (n, i)), 6)
mp3(temp / ("chapter_%03d_group_%03d.mp3" % (n, i)), 9)
ns = type("ns", (), {"translations": tts, "audiobook": ab})
ns = type("ns", (), {"translations": tts, "audiobook": ab, "group_size": 3})
assert a.cmd_verify(ns) is False, "пропущенный фрагмент должен завалить проверку"
@@ -68,9 +68,8 @@ def test_verify_clean(tmp: Path):
for n in (0, 1):
write_chapter(tts, n, [para] * 3)
mp3(temp / ("chapter_%03d_intro.mp3" % n), 1)
for i in range(3):
mp3(temp / ("chapter_%03d_para_%04d.mp3" % (n, i)), 6)
ns = type("ns", (), {"translations": tts, "audiobook": ab})
mp3(temp / ("chapter_%03d_group_000.mp3" % n), 18)
ns = type("ns", (), {"translations": tts, "audiobook": ab, "group_size": 3})
assert a.cmd_verify(ns) is True, "целая книга должна проходить проверку"
@@ -80,17 +79,17 @@ def test_verify_detects_empty_file(tmp: Path):
temp = ab / "temp_audio"
temp.mkdir(parents=True)
para = "х" * 100
write_chapter(tts, 0, [para] * 3)
write_chapter(tts, 0, [para] * 9)
mp3(temp / "chapter_000_intro.mp3", 1)
mp3(temp / "chapter_000_para_0000.mp3", 6)
mp3(temp / "chapter_000_para_0001.mp3", 6)
(temp / "chapter_000_para_0002.mp3").touch() # нулевой размер
ns = type("ns", (), {"translations": tts, "audiobook": ab})
mp3(temp / "chapter_000_group_000.mp3", 18)
mp3(temp / "chapter_000_group_001.mp3", 18)
(temp / "chapter_000_group_002.mp3").touch() # нулевой размер
ns = type("ns", (), {"translations": tts, "audiobook": ab, "group_size": 3})
assert a.cmd_verify(ns) is False, "пустой файл должен завалить проверку"
def build_book(tmp: Path, chapters=2, paras=3):
"""Готовый синтез: подготовленные главы + фрагменты в temp_audio/."""
def build_book(tmp: Path, chapters=2, paras=3, group=3, secs=2):
"""Готовый синтез: главы + фрагменты, сгруппированные как в чужом скрипте."""
tts, ab = tmp / "tts", tmp / "audiobook"
tts.mkdir()
temp = ab / "temp_audio"
@@ -98,14 +97,15 @@ def build_book(tmp: Path, chapters=2, paras=3):
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)
for i in range(-(-paras // group)):
mp3(temp / ("chapter_%03d_group_%03d.mp3" % (n, i)), secs)
return tts, ab
def split_ns(tts, ab, out, **kw):
fields = {"translations": tts, "audiobook": ab, "output": out,
"album": "Книга", "author": "Автор", "gap": 0.3, "force": False}
"album": "Книга", "author": "Автор", "gap": 0.3,
"force": False, "group_size": 3}
fields.update(kw)
return type("ns", (), fields)
@@ -119,9 +119,9 @@ def test_split_makes_chapter_tracks(tmp: Path):
assert tracks[0].name.startswith("000 - "), tracks[0].name
assert not (out / ".tmp").exists(), "временный каталог должен убираться"
# длительность: 1 вводный + 3 абзаца + 3 паузы = 1 + 6 + 0.9 ≈ 7.9 с
# 1 вводный + 1 группа + 1 пауза = 1 + 2 + 0.3 ≈ 3.3 с
d = a.duration(tracks[0])
assert 7.0 < d < 9.0, d
assert 2.8 < d < 4.0, d
# теги проставлены
r = subprocess.run(
@@ -134,7 +134,7 @@ def test_split_makes_chapter_tracks(tmp: Path):
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()
(ab / "temp_audio" / "chapter_001_group_000.mp3").unlink()
out = tmp / "tracks"
assert a.cmd_split(split_ns(tts, ab, out)) is False, "неполная глава должна пропускаться"
assert len(list(out.glob("*.mp3"))) == 1, "целая глава всё равно нарезается"
@@ -144,12 +144,29 @@ def test_split_skips_incomplete_chapter(tmp: Path):
assert len(list(out2.glob("*.mp3"))) == 2, "--force режет обе"
def test_split_survives_broken_fragment(tmp: Path):
"""Регресс: пустой фрагмент обрывал concat молча, ffmpeg возвращал 0."""
tts, ab = build_book(tmp, chapters=1, paras=9, secs=4)
broken = ab / "temp_audio" / "chapter_000_group_001.mp3"
broken.write_bytes(b"") # ровно тот случай, что дал edge-tts
out = tmp / "tracks"
assert a.cmd_split(split_ns(tts, ab, out)) is False, \
"глава с битым фрагментом не должна выдаваться как готовая"
assert not list(out.glob("*.mp3")), "обрезанный трек не должен остаться на диске"
# с --force собирается из уцелевших, но без тихого обрыва посередине
out2 = tmp / "forced"
assert a.cmd_split(split_ns(tts, ab, out2, force=True, gap=0)) is True
d = a.duration(next(out2.glob("*.mp3")))
assert 8.5 < d < 9.5, d # вводный 1 с + две уцелевшие группы по 4 с
def test_split_without_gap(tmp: Path):
tts, ab = build_book(tmp, chapters=1, paras=2)
tts, ab = build_book(tmp, chapters=1, paras=6)
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 без пауз
assert 4.5 < d < 5.6, d # 1 вводный + 2 группы по 2 с, без пауз
if __name__ == "__main__":
@@ -157,7 +174,8 @@ if __name__ == "__main__":
sys.exit("нужен ffmpeg")
for fn in (test_prep, test_verify_detects_gap, test_verify_clean,
test_verify_detects_empty_file, test_split_makes_chapter_tracks,
test_split_skips_incomplete_chapter, test_split_without_gap):
test_split_skips_incomplete_chapter,
test_split_survives_broken_fragment, test_split_without_gap):
with tempfile.TemporaryDirectory() as d:
print("---", fn.__name__)
fn(Path(d))