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

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
+167 -15
View File
@@ -15,10 +15,14 @@ import re
import statistics
import subprocess
import sys
import time
from pathlib import Path
MARK = re.compile(r"⟦/?[ib]⟧")
FRAG = re.compile(r"^chapter_(\d{3})_(intro|para_\d{4})\.mp3$")
# сторонний скрипт режет абзацы группами и пишет chapter_NNN_group_NNN.mp3;
# ветка с _para_ в нём есть, но не используется — принимаем обе
FRAG = re.compile(r"^chapter_(\d{3})_(intro|group_\d+|para_\d+)\.mp3$")
GROUP_SIZE = 3 # значение --paragraphs-per-group по умолчанию
# ниже этой доли от расчётной длительности глава считается обрезанной
SHORT = 0.75
LONG = 1.6
@@ -70,22 +74,27 @@ def duration(path):
return None
def expected_fragments(translations):
"""{номер главы: число абзацев} по подготовленным для TTS файлам."""
def expected_fragments(translations, group_size=GROUP_SIZE):
"""{номер главы: сколько фрагментов ждать и сколько в главе знаков}.
Абзацы склеиваются группами по group_size, плюс один вводный фрагмент.
"""
out = {}
for f in sorted(translations.glob("chapter_*_translated*.json")):
m = re.search(r"chapter_(\d+)", f.name)
data = json.loads(f.read_text(encoding="utf-8"))
paragraphs = [p for p in data.get("paragraphs", []) if p.strip()]
groups = -(-len(paragraphs) // group_size) if paragraphs else 0
out[int(m.group(1))] = {
"paragraphs": len(paragraphs),
"fragments": groups + 1, # +1 вводный
"chars": sum(len(p) for p in paragraphs),
}
return out
def cmd_verify(args):
expect = expected_fragments(args.translations)
expect = expected_fragments(args.translations, args.group_size)
if not expect:
sys.exit("в %s нет подготовленных глав" % args.translations)
temp = args.audiobook / "temp_audio"
@@ -114,7 +123,7 @@ def cmd_verify(args):
problems = 0
for n in sorted(expect):
want = expect[n]["paragraphs"] + 1 # +1 вводный фрагмент
want = expect[n]["fragments"]
got = len(found.get(n, []))
secs = sum(found.get(n, []))
pred = expect[n]["chars"] * rate
@@ -133,7 +142,7 @@ def cmd_verify(args):
total = sum(sum(v) for v in found.values())
print("\nфрагментов: %d из %d, звучание %.1f ч, темп %.1f знака/с"
% (sum(len(v) for v in found.values()),
sum(v["paragraphs"] + 1 for v in expect.values()),
sum(v["fragments"] for v in expect.values()),
total / 3600, (1 / rate) if rate else 0))
if bad:
print("битых или пустых файлов: %d (удали их и перезапусти синтез — "
@@ -169,11 +178,124 @@ def check_final(audiobook, total_chars, rate):
return True
def group_texts(translations, group_size):
"""{(глава, номер группы): текст} — ровно так, как их режет чужой скрипт."""
out = {}
for f in sorted(translations.glob("chapter_*_translated*.json")):
n = int(re.search(r"chapter_(\d+)", f.name).group(1))
data = json.loads(f.read_text(encoding="utf-8"))
paras = [p for p in data.get("paragraphs", [])
if p and not p.startswith("[IMAGE_")]
for i in range(0, len(paras), group_size):
out[(n, i // group_size)] = "\n\n".join(paras[i:i + group_size])
out[(n, "intro")] = "Глава %d. %s." % (n, data.get("title", ""))
return out
def speech_prep(repo, voice, rate, volume, outdir):
"""Их же подготовка текста (фонетические замены), чтобы добранный фрагмент
звучал так же, как соседние. Без репозитория — текст как есть."""
if repo is None:
return lambda s: s
sys.path.insert(0, str(repo))
try:
import importlib
mod = importlib.import_module("05_create_audiobook".lstrip("0") or "x")
except Exception:
try:
import importlib.util
spec = importlib.util.spec_from_file_location(
"ab_creator", repo / "05_create_audiobook.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
except Exception as e:
print("не удалось подключить подготовку текста (%s) — беру текст как есть"
% type(e).__name__)
return lambda s: s
try:
c = mod.AudiobookCreator(output_dir=str(outdir))
c.selected_voice, c.rate, c.volume = voice, rate, volume
return c.prepare_text_for_speech
except Exception as e:
print("не удалось создать AudiobookCreator (%s) — беру текст как есть"
% type(e).__name__)
return lambda s: s
def cmd_repair(args):
"""Досинтез потерянных фрагментов — последовательно, без параллели,
которая и приводит к отказам edge-tts."""
try:
import asyncio
import edge_tts
except ImportError:
sys.exit("нужен edge-tts: pip install edge-tts")
temp = args.audiobook / "temp_audio"
if not temp.is_dir():
sys.exit("нет %s" % temp)
expect = expected_fragments(args.translations, args.group_size)
texts = group_texts(args.translations, args.group_size)
prep = speech_prep(args.repo, args.voice, args.rate, args.volume, args.audiobook)
missing = []
for n in sorted(expect):
intro = temp / ("chapter_%03d_intro.mp3" % n)
if not duration(intro):
missing.append((intro, texts.get((n, "intro"), "")))
for g in range(expect[n]["fragments"] - 1):
f = temp / ("chapter_%03d_group_%03d.mp3" % (n, g))
if not duration(f):
missing.append((f, texts.get((n, g), "")))
if not missing:
print("добирать нечего — все фрагменты на месте")
return True
print("к досинтезу: %d фрагментов" % len(missing))
fixed = 0
for path, text in missing:
text = prep(text)
if not text.strip():
print(" %s: пустой текст, пропуск" % path.name)
continue
ok = False
for attempt in range(3):
try:
path.unlink(missing_ok=True)
asyncio.run(edge_tts.Communicate(
text, args.voice, rate=args.rate, volume=args.volume
).save(str(path)))
if duration(path):
ok = True
break
except Exception as e:
print(" %s: попытка %d — %s" % (path.name, attempt + 1, type(e).__name__))
time.sleep(2 * (attempt + 1))
if ok:
fixed += 1
print(" %s: готово (%.1f с)" % (path.name, duration(path)))
else:
path.unlink(missing_ok=True)
print(" %s: НЕ УДАЛОСЬ" % path.name)
print("досинтезировано %d из %d" % (fixed, len(missing)))
return fixed == len(missing)
def chapter_fragments(temp, num):
"""Фрагменты главы в порядке воспроизведения: вводный, затем абзацы."""
"""Фрагменты главы в порядке воспроизведения: вводный, затем группы абзацев.
Пустые и нечитаемые файлы отбрасываются: concat-демультиплексор на таком
файле обрывает склейку молча, с нулевым кодом возврата.
"""
intro = temp / ("chapter_%03d_intro.mp3" % num)
paras = sorted(temp.glob("chapter_%03d_para_*.mp3" % num))
return ([intro] if intro.exists() else []) + paras
body = sorted(temp.glob("chapter_%03d_group_*.mp3" % num)) \
or sorted(temp.glob("chapter_%03d_para_*.mp3" % num))
files = ([intro] if intro.exists() else []) + body
good, bad = [], []
for f in files:
(good if duration(f) else bad).append(f)
return good, bad
def audio_params(sample):
@@ -234,7 +356,7 @@ def cmd_split(args):
if not temp.is_dir():
sys.exit("нет %s — нарезать не из чего. Каталог удаляется методом "
"cleanup_temp_files() стороннего скрипта: режь до уборки." % temp)
expect = expected_fragments(args.translations)
expect = expected_fragments(args.translations, args.group_size)
titles = {}
for f in sorted(args.translations.glob("chapter_*_translated*.json")):
n = int(re.search(r"chapter_(\d+)", f.name).group(1))
@@ -255,8 +377,11 @@ def cmd_split(args):
made = skipped = 0
for n in sorted(expect):
frags = chapter_fragments(temp, n)
want = expect[n]["paragraphs"] + 1
frags, bad = chapter_fragments(temp, n)
want = expect[n]["fragments"]
if bad:
print("глава %03d: %d битых фрагментов отброшено (%s)"
% (n, len(bad), ", ".join(f.name for f in bad[:3])))
if len(frags) < want and not args.force:
print("глава %03d: %d из %d фрагментов — пропущена (--force чтобы всё равно)"
% (n, len(frags), want))
@@ -273,10 +398,22 @@ def cmd_split(args):
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:
if not concat(seq, out, work, meta):
print("глава %03d: склейка не удалась" % n)
skipped += 1
continue
# ffmpeg может оборвать concat молча и вернуть 0 — сверяем результат
# с суммой входов
want_secs = sum(duration(f) or 0 for f in seq)
got_secs = duration(out) or 0
if want_secs and got_secs < want_secs * 0.98:
print("глава %03d: трек короче суммы фрагментов (%.1f мин против %.1f) "
"— склейка оборвалась, файл удалён"
% (n, got_secs / 60, want_secs / 60))
out.unlink(missing_ok=True)
skipped += 1
continue
made += 1
for f in work.glob("*"):
f.unlink()
@@ -303,6 +440,8 @@ def main():
v = sub.add_parser("verify", help="проверить целостность синтеза")
v.add_argument("translations", type=Path, help="каталог, поданный в озвучку")
v.add_argument("audiobook", type=Path, help="каталог audiobook/")
v.add_argument("--group-size", type=int, default=GROUP_SIZE,
help="сколько абзацев в одном фрагменте (--paragraphs-per-group)")
v.set_defaults(func=cmd_verify)
s = sub.add_parser("split", help="нарезать по главам вместо одного файла")
@@ -315,8 +454,21 @@ def main():
help="пауза между абзацами, секунд (0 — без пауз)")
s.add_argument("--force", action="store_true",
help="резать даже главы с недостающими фрагментами")
s.add_argument("--group-size", type=int, default=GROUP_SIZE,
help="сколько абзацев в одном фрагменте (--paragraphs-per-group)")
s.set_defaults(func=cmd_split)
r = sub.add_parser("repair", help="досинтезировать потерянные фрагменты")
r.add_argument("translations", type=Path, help="каталог, поданный в озвучку")
r.add_argument("audiobook", type=Path, help="каталог audiobook/ с temp_audio/")
r.add_argument("--voice", default="ru-RU-DmitryNeural")
r.add_argument("--rate", default="+0%")
r.add_argument("--volume", default="+0%")
r.add_argument("--repo", type=Path, default=None,
help="клон book_translator — чтобы фонетика совпала с соседями")
r.add_argument("--group-size", type=int, default=GROUP_SIZE)
r.set_defaults(func=cmd_repair)
args = ap.parse_args()
result = args.func(args)
if result is False: