Files
MohZhuang 0d74a1097d feat(v1.3.0): 新增火山引擎语音合成 2.0 作为可选配音引擎
流水线开跑前先问用户用哪个 TTS。两引擎产出的 audio-manifest.json
结构完全一致,故 timeline_build / subs / 渲染器零改动。

新增
- scripts/tts_volcano.py  接口包,唯一读 tts.env 的地方。零第三方依赖
                          (只用标准库 urllib),HTTP Chunked 单向流式,
                          与 edge-tts「发一次收一串」形态同构
- scripts/tts_setup.py    向导:选方案 → 配密钥 → 测连接 → 选音色,
                          输出 NEXT_ACTION=… 供智能体识别
- tts.env.example         密钥模板(真实 tts.env 已 gitignore)
- references/volcano-tts.md  密钥纪律 / 配置步骤 / 接口规格 / 音色表 / 排查表

安全
- Key 只存 tts.env,只有 tts_volcano.py 读它;异常过脱敏,只抹本次实际
  加载的密钥值与 AKLT… 形态,刻意不用「长字符串即密钥」的宽规则
- check_integrity.py 新增密钥防线检查(含「疑似把模板改名」提示)
- package_skill.py 排除 .env / .key / .pem,修掉一个真实外泄口

修复(真机联调暴露,均只在实跑时复现)
- enable_subtitle 必须显式开启,否则 words 恒为空数组且完全不报错,
  下游会静默退回按字数插值、字幕开始飘
- usage 挂在结束标记那一行,提前 continue 导致计费恒为 0
- 缓存未存首裁量,命中缓存时 lead_cut_sec=0,重跑字幕比首跑晚一帧
- CLI 把中文音色名原样当 speaker 发出,服务端报 resource ID mismatched

文档
- README 中英双语新增「配音:两个引擎」章节,按用户要求不写命令
  (那是智能体的职责),只讲「会问你什么 / 你唯一要动手的地方」
- 疑难排查补 6 条火山症状;lessons 34 → 56 条;流水线图加 tts_setup 节点
2026-09-23 10:04:13 +08:00

134 lines
5.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
timeline_build.py —— 全局时间轴:layout.json + 拼接音轨。
做两件事:
① 按 project.json 的 order 把各段容器时长 + 段间 gap 排成全局时间轴
→ layout.json(每段 start_sec / duration_sec,渲染器与字幕都读它)
② 把逐段 MP3 用 ffmpeg concat + anullsrc 间隙拼成一条 narration-full.mp3
(总长 = Σ容器时长 + gap×(n-1),与视频帧数同口径 → 音画天然对齐)
用法:
python timeline_build.py --project . [--gap 0.35]
产出:
layout.json + audio/narration-full.mp3
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
def ffmpeg_exe() -> str:
from shutil import which
p = which("ffmpeg")
if p:
return p
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
def probe_duration(path: str, ff: str) -> float:
r = subprocess.run([ff, "-hide_banner", "-i", path],
capture_output=True, text=True, errors="replace")
for line in r.stderr.splitlines():
if "Duration:" in line:
t = line.split("Duration:")[1].split(",")[0].strip()
h, m, s = t.split(":")
return int(h) * 3600 + int(m) * 60 + float(s)
return 0.0
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--project", default=".")
ap.add_argument("--gap", type=float, default=-1, help="段间静音秒(默认取 project.json 的 gap)")
args = ap.parse_args()
proj = os.path.abspath(args.project)
pj = json.load(open(os.path.join(proj, "project.json"), encoding="utf-8"))
order = pj.get("order") or []
gap = args.gap if args.gap >= 0 else float(pj.get("gap", 0.35))
if not order:
# 不拦这一下的话,下游 ffmpeg 会报 `Failed to set value ';concat=n=0:...'`
# —— 看不出根因在哪。直接说清楚该改哪个文件的哪个字段。
print("✗ project.json 的 order 是空的 —— 它是场景顺序表,先填上再跑。\n"
" 形如:\"order\": [\"intro\", \"point1\", \"ending\"](= narration.json 里 id 的顺序)",
file=sys.stderr)
return 1
man_path = os.path.join(proj, "audio-manifest.json")
if not os.path.exists(man_path):
print("✗ 先跑 tts_build.py(缺 audio-manifest.json)", file=sys.stderr)
return 1
man = json.load(open(man_path, encoding="utf-8"))
by_id = {}
for it in man.get("items", []):
by_id[it.get("id") or os.path.splitext(os.path.basename(it["path"]))[0]] = it
ff = ffmpeg_exe()
layout, cursor, parts = {}, 0.0, []
for nid in order:
it = by_id.get(nid)
if not it:
print(f"✗ manifest 里没有 {nid}", file=sys.stderr)
return 1
dur = round(float(it.get("container_duration_sec") or it["duration_sec"]), 3)
layout[nid] = {"start_sec": round(cursor, 3), "duration_sec": dur}
parts.append(os.path.join(proj, "audio", f"{nid}.mp3"))
cursor += dur + gap
total = cursor - (gap if order else 0.0)
# ---- 拼音轨:concat filter + anullsrc 间隙 ----
inputs = []
for p in parts:
inputs += ["-i", p]
n = len(parts)
filters, labels = [], []
for i in range(n):
filters.append(f"[{i}:a]aresample=44100[a{i}]")
labels.append(f"[a{i}]")
if i < n - 1:
inputs += ["-f", "lavfi", "-t", str(gap), "-i",
"anullsrc=channel_layout=mono:sample_rate=44100"]
filters.append(f"[{n + i}:a]aresample=44100[g{i}]")
labels.append(f"[g{i}]")
fc = ";".join(filters) + ";" + "".join(labels) + f"concat=n={len(labels)}:v=0:a=1[out]"
out = os.path.join(proj, "audio", "narration-full.mp3")
os.makedirs(os.path.dirname(out), exist_ok=True)
cmd = [ff, "-hide_banner", "-loglevel", "error", "-y",
*inputs, "-filter_complex", fc, "-map", "[out]",
"-c:a", "libmp3lame", "-b:a", "128k", out]
r = subprocess.run(cmd, capture_output=True, text=True, errors="replace")
if r.returncode != 0:
print("✗ ffmpeg 拼接失败:\n" + (r.stderr or "")[-800:], file=sys.stderr)
return 1
audio_total = probe_duration(out, ff)
layout["_total"] = {"audio_duration_sec": round(audio_total, 3),
"video_duration_sec": round(total, 3),
"gap_sec": gap, "fps": pj.get("fps", 30),
"total_frames": int(round(total * pj.get("fps", 30)))}
lp = os.path.join(proj, "layout.json")
json.dump(layout, open(lp, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print(f"[timeline] {len(order)} 段 · gap {gap}s · 视频 {total:.2f}s / "
f"{int(round(total * pj.get('fps', 30)))} 帧 · 音轨 {audio_total:.2f}s → {lp}")
drift = abs(audio_total - total)
if drift > 0.5:
print(f" ⚠ 音轨与视频时长差 {drift:.2f}s(>0.5s,检查 gap 或音频)", file=sys.stderr)
for nid in order:
print(f" {nid:<12} start {layout[nid]['start_sec']:>7.2f}s dur {layout[nid]['duration_sec']:>6.3f}s")
return 0
if __name__ == "__main__":
raise SystemExit(main())