#!/usr/bin/env python3 # -*- coding: utf-8 -*- """根据角色、独立台词和 Edge TTS 实际音轨生成准确分镜、字幕和配音时序。 兼容旧格式:scenes[].speech + scenes[].captions + 顶层 voice。 新版格式:scenes[].lines = [{role, text, captions, voice?}, ...],顶层 voices 按角色配置。 所有台词先进行严格预检。生成后的 timing.json 是音视频制作的唯一时间轴来源。 """ from __future__ import annotations import argparse import asyncio import hashlib import json import math import re import subprocess import wave from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] SAFE_SCENE_ID = re.compile(r"^[a-zA-Z0-9_-]+$") SAFE_NAME = re.compile(r"^[a-zA-Z0-9_-]+$") def clean(text: str) -> str: return re.sub(r"[^\u4e00-\u9fffA-Za-z0-9]", "", text) def derive_exact_phrases(speech: str, caption_hints: list[str] | None = None, max_chars: int = 16) -> list[str]: """Take every subtitle character from the actual input of Edge TTS. Caption hints may specify *boundaries*, but can never replace or paraphrase speech. The function also works without hints by splitting at punctuation/safe lengths. """ if caption_hints: if clean("".join(caption_hints)) != clean(speech): raise ValueError("字幕与实际 TTS 旁白不完全一致:不能跳词、改写或缩写") lengths = [len(clean(x)) for x in caption_hints] else: clauses = [x for x in re.findall(r"[^,。!?;:,.!?;:]+[,。!?;:,.!?;:]*|[,。!?;:,.!?;:]+", speech) if clean(x)] if clean("".join(clauses)) != clean(speech): raise ValueError("字幕自动切句导致文字丢失") lengths = [] for clause in clauses: n = len(clean(clause)) while n > max_chars: lengths.append(max_chars) n -= max_chars if n: lengths.append(n) original = speech parts = [] for num in lengths: hit = 0 cut = None for i, ch in enumerate(original): if clean(ch): hit += 1 if hit == num: cut = i + 1 while cut < len(original) and not clean(original[cut]): cut += 1 break if cut is None: raise ValueError("字幕分组超出了实际朗读文字范围") parts.append(original[:cut].strip()) original = original[cut:] if clean("".join(parts)) != clean(speech): raise ValueError("字幕未完整覆盖实际旁白") return parts def strict_character_spans(take: dict) -> list[tuple[float, float]]: """Force exact Edge word-level matching. No guessed character timing is allowed.""" expected = clean(take["speech"]) actual = [] spans = [] previous = -1.0 for word in take["words"]: if word.get("type") not in (None, "WordBoundary"): continue token = clean(str(word.get("text", ""))) if not token: continue start = float(word.get("offset", 0)) / 1e7 dur = float(word.get("duration", 0)) / 1e7 if dur <= 0 or start < previous - .08: raise ValueError(f"{take['id']} Edge TTS 返回了无效或非单调词边界") previous = start for i, ch in enumerate(token): actual.append(ch) spans.append((start + dur*i/len(token), start + dur*(i+1)/len(token))) joined = "".join(actual) if joined != expected or not spans: raise ValueError(f"{take['id']} Edge TTS 词边界与输入原稿不一致。拒绝按字数估算时间:期待 {expected!r},实际 {joined!r}") return spans def duration(path: Path) -> float: val = subprocess.check_output( ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", str(path)], text=True ).strip() return float(val) def as_voice(value: Any, label: str) -> dict: if isinstance(value, str): return {"speaker": value} if isinstance(value, dict): return dict(value) raise ValueError(f"{label} 必须是 Edge TTS ShortName 字符串或 voice 配置对象") def resolve_voice(story: dict, scene: dict, line: dict) -> dict: """优先级:line.voice > scene.voice > voices[role] > voice(全局回退)。""" role = line.get("role", "narrator") if not isinstance(role, str) or not role.strip(): raise ValueError("台词 role 必须是非空字符串") configured = story.get("voices", {}) if not isinstance(configured, dict): raise ValueError("story.voices 必须是按 role 名称索引的对象") explicit = ("voice" in line) or ("voice" in scene) or (role in configured) if role != "narrator" and not explicit: raise ValueError(f"角色 {role!r} 没有配置声音:在 voices 中指定,避免错误地使用旁白音色") voice = {"speaker": "zh-CN-YunxiaNeural", "rate": "+5%", "pitch": "+0Hz", "volume": "+0%"} voice.update(as_voice(story.get("voice", {}), "voice")) if role in configured: voice.update(as_voice(configured[role], f"voices.{role}")) if "voice" in scene: voice.update(as_voice(scene["voice"], f"scenes[{scene['id']}].voice")) if "voice" in line: voice.update(as_voice(line["voice"], f"lines[{role}].voice")) if not isinstance(voice.get("speaker"), str) or not voice["speaker"].strip(): raise ValueError(f"角色 {role!r} 缺少 speaker") for key in ("rate", "pitch", "volume"): if not isinstance(voice.get(key), str): raise ValueError(f"角色 {role!r} 的 {key} 必须是 Edge TTS 的字符串参数") return {k: voice[k] for k in ("speaker", "rate", "pitch", "volume")} def normalize_lines(story: dict, scene: dict) -> list[dict]: """每句独立角色、独立音频、独立字幕;在语音合成之前检查所有文字。""" if "lines" in scene: raw = scene["lines"] if not isinstance(raw, list) or not raw: raise ValueError(f"{scene['id']}: lines 必须是非空数组") else: raw = [{"role": scene.get("role", "narrator"), "text": scene.get("speech", ""), "captions": scene.get("captions", None), **({"voice": scene["voice"]} if "voice" in scene else {})}] lines = [] for index, item in enumerate(raw): if not isinstance(item, dict): raise ValueError(f"{scene['id']}: line[{index}] 必须是对象") role = item.get("role", "narrator") txt = item.get("text", item.get("speech", "")) phrases = item.get("captions", None) if not isinstance(txt, str) or not clean(txt): raise ValueError(f"{scene['id']}: line[{index}] 的 text 不能为空") if phrases is not None and (not isinstance(phrases, list) or not phrases or any(not isinstance(p, str) or not clean(p) for p in phrases)): raise ValueError(f"{scene['id']}: line[{index}] captions 必须是非空字符串数组,或省略以自动切句") phrases = derive_exact_phrases(txt, phrases) voice = resolve_voice(story, scene, item) take_id = scene["id"] if len(raw) == 1 else f"{scene['id']}-{index:02d}" lines.append({"id": take_id, "scene": scene["id"], "index": index, "role": role, "speech": txt, "captions": phrases, "voice": voice}) if "speech" in scene and "lines" in scene: if clean(scene["speech"]) != clean("".join(line["speech"] for line in lines)): raise ValueError(f"{scene['id']}: 同时提供 speech 与 lines 时,二者内容必须一致") return lines def validate_story(config: dict) -> list[list[dict]]: if not isinstance(config.get("scenes"), list) or not config["scenes"]: raise ValueError("scenes 必须是非空场景列表") if not isinstance(config.get("slug"), str) or not SAFE_NAME.fullmatch(config["slug"]): raise ValueError("slug 只能包含英文字母、数字、连字符和下划线") ids = [scene["id"] for scene in config["scenes"]] if any(not isinstance(v, str) or not SAFE_SCENE_ID.fullmatch(v) for v in ids): raise ValueError("scene.id 只能包含英文字母、数字、连字符和下划线") if len(set(ids)) != len(ids): raise ValueError("scene.id 必须唯一") lines = [normalize_lines(config, scene) for scene in config["scenes"]] video = config.get("video", {}) if not isinstance(video.get("fps", 30), int) or video.get("fps", 30) < 1: raise ValueError("fps 应为正整数") for sc in config["scenes"]: if float(sc.get("line_gap_seconds", .18)) < 0: raise ValueError("line_gap_seconds 不能为负") return lines def write_mock_wav(path: Path, seconds: float) -> None: """仅用于离线结构测试;不可视作正式成片的语音。""" sr = 16000 with wave.open(str(path), "wb") as out: out.setnchannels(1) out.setsampwidth(2) out.setframerate(sr) out.writeframes(b"\x00\x00" * round(seconds * sr)) def mock_words(text: str, seconds: float) -> list[dict]: chars = clean(text) step = seconds / max(len(chars), 1) return [{"type": "WordBoundary", "text": ch, "offset": int(i * step * 1e7), "duration": int(step * 1e7)} for i, ch in enumerate(chars)] async def synthesize(line: dict, sem: asyncio.Semaphore, mock: bool) -> dict: async with sem: name = line["id"] speech = line["speech"] voice = line["voice"] target = ROOT / "assets/voice" target.mkdir(parents=True, exist_ok=True) audio = target / f"{name}.{'wav' if mock else 'mp3'}" words_file = target / f"{name}.words.json" meta_file = target / f"{name}.cache.json" fingerprint = hashlib.sha256(json.dumps({"text": speech, "voice": voice, "mock": mock}, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest() metadata_ok = False if meta_file.is_file(): try: metadata_ok = json.loads(meta_file.read_text(encoding="utf-8")).get("fingerprint") == fingerprint except (ValueError, OSError): pass if mock: seconds = max(1.5, .52 + len(clean(speech)) * .105) if not metadata_ok or not audio.is_file(): write_mock_wav(audio, seconds) words_file.write_text(json.dumps(mock_words(speech, seconds), ensure_ascii=False, indent=2), encoding="utf-8") else: try: import edge_tts except ImportError as exc: raise RuntimeError("请安装 edge-tts:python -m pip install -r requirements.txt") from exc if not metadata_ok or not audio.is_file() or audio.stat().st_size < 1000 or not words_file.is_file(): for retry in range(5): try: c = edge_tts.Communicate(text=speech, voice=voice["speaker"], rate=voice["rate"], pitch=voice["pitch"], volume=voice["volume"], boundary="WordBoundary") raw_file = target / f"{name}.raw.jsonl" await c.save(str(audio), str(raw_file)) if not audio.is_file() or audio.stat().st_size < 1000: raise RuntimeError("Edge TTS 未返回有效音频") words = [json.loads(row) for row in raw_file.read_text(encoding="utf-8").splitlines() if row.strip()] if not words: raise RuntimeError("Edge TTS 未返回 WordBoundary 数据") words_file.write_text(json.dumps(words, ensure_ascii=False, indent=2), encoding="utf-8") raw_file.unlink(missing_ok=True) break except Exception as exc: print(f"重试 {name}: {retry+1}/5 ({type(exc).__name__})", flush=True) if retry == 4: raise RuntimeError(f"真实配音失败 {name}: {exc}") from exc await asyncio.sleep(2 + retry * 2) words = json.loads(words_file.read_text(encoding="utf-8")) meta_file.write_text(json.dumps({"fingerprint": fingerprint, "mock": mock, "role": line["role"], "voice": voice}, ensure_ascii=False, indent=2), encoding="utf-8") sec = duration(audio) return {**line, "audio_duration": round(sec, 6), "audio": audio.relative_to(ROOT).as_posix(), "words": words} def character_times(take: dict) -> tuple[list[tuple[float, float]], bool]: """Backward-compatible wrapper: precise Edge boundaries or exception, never approximation.""" return strict_character_spans(take), True def stamp(seconds: float) -> str: ms = max(0, round(seconds * 1000)) h, ms = divmod(ms, 3_600_000) m, ms = divmod(ms, 60_000) s, ms = divmod(ms, 1_000) return f"{h:02}:{m:02}:{s:02},{ms:03}" async def run(mock: bool) -> dict: config = json.loads((ROOT / "story.json").read_text(encoding="utf-8-sig")) lines_by_scene = validate_story(config) flattened = [line for group in lines_by_scene for line in group] # 并发限制为2:减少 Edge TTS 的临时超限风险。 sem = asyncio.Semaphore(2) generated = await asyncio.gather(*(synthesize(line, sem, mock) for line in flattened)) index = {item["id"]: item for item in generated} fps = int(config.get("video", {}).get("fps", 30)) cursor = 0.0 scenes = [] captions = [] warnings = [] used_voices: dict[str, dict] = {} for scene_def, group in zip(config["scenes"], lines_by_scene): start = cursor line_start = start + .48 gap = float(scene_def.get("line_gap_seconds", .18)) takes = [] for line in group: take = index[line["id"]] local = {"id": line["id"], "index": line["index"], "role": line["role"], "speaker": line["voice"]["speaker"], "rate": line["voice"]["rate"], "voice": line["voice"], "speech": line["speech"], "voice_start": round(line_start, 5), "voice_duration": take["audio_duration"], "voice_file": take["audio"]} takes.append(local) used_voices[line["role"]] = line["voice"] spans, exact = character_times(take) offset = 0 for phr in line["captions"]: nchars = len(clean(phr)) if nchars <= 0 or offset + nchars > len(spans): raise ValueError(f"{line['id']} 字幕未与实际 TTS 逐字对应") local_begin = spans[offset][0] local_end = spans[offset+nchars-1][1] begin = line_start + max(0.0, local_begin-.02) end = line_start + min(take["audio_duration"], local_end+.03) if end-begin <= .12: raise ValueError(f"{line['id']} 字幕过短,无法可靠显示:{phr}") captions.append({"scene": scene_def["id"], "take_id": line["id"], "role": line["role"], "speaker": line["voice"]["speaker"], "start": round(begin, 4), "end": round(end, 4), "text": phr, "precise_tts_boundaries": True, "source_sha256": hashlib.sha256(line["speech"].encode("utf-8")).hexdigest()}) offset += nchars if offset != len(spans): raise ValueError(f"{line['id']} 没有被字幕覆盖的配音文字,拒绝发布") line_start += take["audio_duration"] + gap last_voice_end = line_start - gap scene_duration = max(last_voice_end - start + 1.0, float(scene_def.get("min_seconds", 5.0))) item = {"id": scene_def["id"], "start": round(start, 5), "duration": round(scene_duration, 5), "lines": takes, "cast": sorted({take["role"] for take in takes})} # 保持与旧代码/第三方自定义脚本兼容:仅单音色单段的场景保留原字段。 if len(takes) == 1: item.update({"voice_start": takes[0]["voice_start"], "voice_duration": takes[0]["voice_duration"], "voice_file": takes[0]["voice_file"]}) scenes.append(item) cursor += scene_duration for index in range(len(captions)-1): captions[index]["end"] = round(min(captions[index]["end"], captions[index+1]["start"]-.045),4) if captions[index]["end"] - captions[index]["start"] <= .10: raise ValueError(f"第 {index+1} 条字幕无法满足最小间隔,必须修改配音分句") spoken_script = clean("".join(line["speech"] for line in flattened)) subtitle_script = clean("".join(c["text"] for c in captions)) if spoken_script != subtitle_script: raise ValueError("实际 TTS 原稿与完整字幕不一致,禁止渲染") total = math.ceil((cursor + float(config.get("video", {}).get("tail_seconds", 1.6))) * fps) / fps timing = {"title": config["title"], "slug": config["slug"], "fps": fps, "duration": round(total, 6), "mock": mock, "tts_engine": "Edge TTS WordBoundary", "scenes": scenes, "voice_cast": used_voices, "warnings": warnings, "verbatim_captions": True, "spoken_script_sha256": hashlib.sha256(spoken_script.encode("utf-8")).hexdigest()} (ROOT / "timing.json").write_text(json.dumps(timing, ensure_ascii=False, indent=2), encoding="utf-8") (ROOT / "captions.json").write_text(json.dumps(captions, ensure_ascii=False, indent=2), encoding="utf-8") out = ROOT / "output" out.mkdir(exist_ok=True) (out / f"{config['slug']}.srt").write_text("\n\n".join( f"{i+1}\n{stamp(c['start'])} --> {stamp(c['end'])}\n{c['text']}" for i,c in enumerate(captions)) + "\n", encoding="utf-8") print(json.dumps({"duration": total, "scenes": len(scenes), "voice_lines": len(generated), "voice_cast": used_voices, "subtitles": len(captions), "mock": mock, "warnings": warnings}, ensure_ascii=False, indent=2)) return timing if __name__ == "__main__": parser = argparse.ArgumentParser(description="角色分配 Edge 神经音色 + 字词级字幕 + 可重复的音轨时间轴") parser.add_argument("--mock", action="store_true", help="仅离线测试:生成静音模拟语音,禁止发布") opts = parser.parse_args() if not (ROOT / "story.json").is_file(): raise SystemExit("请先运行 create_science_project.py,并在含 story.json 的工程中执行 narrate.py") asyncio.run(run(opts.mock))