Files
html-explainer/scripts/tts_setup.py
T
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

336 lines
15 KiB
Python
Raw 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 -*-
"""
tts_setup.py —— 配音方案设置向导(选 TTS 引擎 → 配密钥 → 测连接 → 选音色)。
【它在流程里的位置】SKILL.md 的「确认点 3」要求先问用户用哪个 TTS。
本脚本把那次对话落成可复现的三步,并保证**密钥永不经过对话窗口**:
① 选方案 edge-tts(免费、免密钥、开箱可用) / 火山引擎语音合成 2.0(音质更好,需 API Key)
② 配密钥 选火山但缺 tts.env → 生成带注释的空模板 → 打印指引 → 退出(NEXT_ACTION=fill_env)
用户填好回来说一声 → 重跑本脚本(或 --check)→ 测连接
③ 选音色 列出内置常用音色(可自定义 ID)→ 写进 project.json
【密钥纪律】本脚本**从不打印、不落盘、不回显**密钥值;状态摘要走 tts_volcano.describe_env()
(内部已脱敏)。也不会因为测连接失败而把密钥打进日志。
用法:
python tts_setup.py --project . --status # 当前配置 + 脱敏密钥状态
python tts_setup.py --project . --provider volcano # 选火山(缺 env 则生成模板后退出)
python tts_setup.py --project . --list-voices # 列出候选音色
python tts_setup.py --project . --check # 测连接(火山走真合成;edge 查依赖)
python tts_setup.py --project . --provider volcano --voice "解说小明 2.0" # 一步到位
python tts_setup.py --project . # 交互式(人在终端时用)
退出码:0 完成;2 = 需要用户手工填 tts.env 后再来;1 = 出错。
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import tts_volcano # noqa: E402
ENV_FILENAME = "tts.env"
ENV_TEMPLATE = """\
# ---------------------------------------------------------------
# 火山引擎(豆包)语音合成 2.0 —— 密钥配置
# 本文件由 tts_setup.py 生成,已被 .gitignore 忽略,不会进 git 仓库。
# ★ agent 不需要、也不应该读取本文件;接口包 scripts/tts_volcano.py 会自己读。
# ---------------------------------------------------------------
# 【必填】API Key —— 火山引擎控制台 → 语音技术 → API Key 管理
# https://console.volcengine.com/speech/new/setting/apikeys
VOLC_API_KEY=
# 资源 ID:seed-tts-2.0 = 预置音色;seed-icl-2.0 = 声音复刻音色
VOLC_RESOURCE_ID=seed-tts-2.0
# 【可选】旧版鉴权:若你拿到的是 APP ID + Access Token 而不是 API Key,填这两项
VOLC_APP_ID=
VOLC_ACCESS_KEY=
"""
# edge-tts 常用音色(名称, voice, 说明)。完整列表:edge-tts --list-voices
EDGE_VOICES: list[tuple[str, str, str]] = [
("云希(默认)", "zh-CN-YunxiNeural", "中文男声 · 年轻,解说常用"),
("云健", "zh-CN-YunjianNeural", "中文男声 · 浑厚,纪录片感"),
("晓晓", "zh-CN-XiaoxiaoNeural", "中文女声 · 通用"),
("晓伊", "zh-CN-XiaoyiNeural", "中文女声 · 甜"),
("云扬", "zh-CN-YunyangNeural", "中文男声 · 新闻播报"),
("云夏", "zh-CN-YunxiaNeural", "中文男声 · 少年"),
("辽宁小北", "zh-CN-liaoning-XiaobeiNeural", "中文女声 · 东北方言"),
("Guy(英文)", "en-US-GuyNeural", "英文男声"),
("Aria(英文)", "en-US-AriaNeural", "英文女声"),
]
EDGE_DEFAULT_ZH = "zh-CN-YunxiNeural"
EDGE_DEFAULT_EN = "en-US-GuyNeural"
def catalog(provider: str) -> list[tuple[str, str, str]]:
return tts_volcano.VOICES if provider == "volcano" else EDGE_VOICES
def resolve_voice(provider: str, token: str) -> str:
"""名称 / 序号 / 原始 ID 都能接受。返回 "" 表示「用户没指定」。"""
token = (token or "").strip()
if not token:
return ""
if provider == "volcano":
# 交给接口包解析,避免两处规则各写一份、慢慢跑偏
return tts_volcano.resolve_voice_token(token)
cat = catalog(provider)
if token.isdigit() and 1 <= int(token) <= len(cat):
return cat[int(token) - 1][1]
for name, vid, _ in cat:
if token == name or token == vid:
return vid
# 允许带括注的名字,如「云希(默认)」
for name, vid, _ in cat:
if name.startswith(token) or token.startswith(name):
return vid
return token # 自定义 ID 原样返回
def _normalize_rate(provider: str, value) -> object:
"""把语速写成该引擎的表示法:火山 = int 百分比,edge = '+N%'。
new_project.py 默认写 '+8%'(那是 edge 的格式)。用户后来切到火山时若不清洗,
火山项目的 project.json 里就一直留着 edge 字符串 —— 下游换算虽然能兜住,
但 --status 打出来会让人以为配错了引擎。
"""
if value is None or value == "":
return 0 if provider == "volcano" else "+8%"
pct = tts_volcano.parse_rate_percent(value)
if provider == "volcano":
return max(-50, min(100, pct))
return f"{pct:+d}%"
def print_voices(provider: str) -> None:
label = "火山引擎语音合成 2.0" if provider == "volcano" else "edge-tts"
print(f"{label} 候选音色:")
for i, (name, vid, note) in enumerate(catalog(provider), 1):
print(f" {i:>2}. {name:<22} {note}")
print(f" {vid}")
print(" (也可以直接给音色 ID 自定义)")
def load_project(project: str) -> tuple[str, dict]:
proj = os.path.abspath(project or ".")
pj_path = os.path.join(proj, "project.json")
if not os.path.isfile(pj_path):
print(f"✗ 找不到 {pj_path}(--project 要指向视频项目目录)", file=sys.stderr)
raise SystemExit(1)
return proj, json.load(open(pj_path, encoding="utf-8"))
def save_project(proj: str, pj: dict) -> None:
with open(os.path.join(proj, "project.json"), "w", encoding="utf-8") as f:
json.dump(pj, f, ensure_ascii=False, indent=2)
def ensure_env_file(proj: str) -> tuple[str, bool]:
"""返回 (路径, 是否本次新建)。已存在则原样返回,**不读内容**。
优先级与 tts_volcano.env_candidates() 保持一致:项目目录 → 技能目录 → 新建于项目目录。
不按同序查找会出现「向导认技能目录的、接口包认项目目录的」这种静默错位。
"""
project_env = os.path.join(proj, ENV_FILENAME)
if os.path.isfile(project_env):
return project_env, False
skill_env = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ENV_FILENAME)
if os.path.isfile(skill_env):
return skill_env, False
with open(project_env, "w", encoding="utf-8") as f:
f.write(ENV_TEMPLATE)
return project_env, True
def cmd_status(proj: str, pj: dict) -> int:
provider = pj.get("provider") or os.environ.get("TTS_PROVIDER") or "edge"
rate = pj.get("rate")
if rate is None or rate == "":
rate_shown = "(未设置,将用默认)"
elif provider == "volcano":
rate_shown = f"{rate}%(快慢百分比,0 = 原速)"
else:
rate_shown = str(rate)
print(f"当前方案:{provider}")
print(f"当前音色:{pj.get('voice') or '(未设置,将用默认)'}")
print(f"当前语速:{rate_shown}")
if provider == "volcano":
st = tts_volcano.describe_env(proj)
if st["status"] == "ok":
print(f"tts.env :已就绪 {st['env_file']}")
print(f"密钥 :{st['key_masked']}(脱敏显示)· 鉴权 {st['auth']} · 资源 {st['resource_id']}")
else:
print(f"tts.env :未就绪 —— {st['detail']}")
else:
try:
import edge_tts # noqa: F401
print("edge-tts:已安装(免费,无需密钥)")
except ImportError:
print("edge-tts:未安装 → pip install edge-tts")
return 0
def cmd_check(proj: str, provider: str, voice: str) -> int:
print(f"测试方案:{provider}", flush=True) # flush:否则与 stderr 的错误信息交错乱序
if provider == "volcano":
st = tts_volcano.check(proj, voice)
if st["ok"]:
print(f"✓ 火山接口连通 · 音色 {st['voice']} · 音频 {st['audio_bytes']}B · "
f"时间戳 {st['boundaries']} 个 · 计费 {st['chars_billed']} 字")
print(f" tts.env {st['env_file']} · 密钥 {st['key_masked']}")
return 0
print(f"✗ 火山接口未通过({st['stage']})\n{st['detail']}", file=sys.stderr)
return 1
try:
import edge_tts # noqa: F401
except ImportError:
print("✗ edge-tts 未安装:pip install edge-tts", file=sys.stderr)
return 1
if shutil.which("ffmpeg") is None:
try:
import imageio_ffmpeg # noqa: F401
except ImportError:
print("✗ 缺 ffmpeg(edge 引擎裁静音/量时长要用):pip install imageio-ffmpeg",
file=sys.stderr)
return 1
print("✓ edge-tts 与 ffmpeg 就绪(edge 不需要密钥)")
return 0
def interactive_ask(prompt: str, options: list[tuple[str, str]], default_idx: int = 0) -> str:
print(prompt)
for i, (label, note) in enumerate(options, 1):
mark = "(默认)" if i - 1 == default_idx else ""
print(f" {i}. {label}{mark} {note}")
while True:
raw = input(f"选择 [1-{len(options)}],直接回车用默认:").strip()
if not raw:
return options[default_idx][0]
if raw.isdigit() and 1 <= int(raw) <= len(options):
return options[int(raw) - 1][0]
print(" 输入不在范围内,再试一次。")
def run(args) -> int:
proj, pj = load_project(args.project)
provider = (args.provider or os.environ.get("TTS_PROVIDER")
or pj.get("provider") or "").lower()
if args.status:
return cmd_status(proj, pj)
# ---- ① 选方案 ----
if provider not in ("edge", "volcano"):
if not sys.stdin.isatty():
print("当前未设置配音方案。可选:", file=sys.stderr)
print(" --provider edge 免费、免密钥、开箱可用(默认)", file=sys.stderr)
print(" --provider volcano 火山引擎语音合成 2.0,音质更好,需 API Key", file=sys.stderr)
print("NEXT_ACTION=ask_user_provider")
return 1
choice = interactive_ask(
"配音用哪个 TTS?",
[("edge-tts", "免费、免密钥、开箱可用"),
("火山引擎语音合成 2.0", "音质更好、需 API Key(约 1 分钟配置)")])
provider = "edge" if choice.startswith("edge") else "volcano"
# ★ 方案一旦确定立刻落盘。用户填 key 会中断流程,若不落盘,
# 回来跑 --check 时 project.json 还写着 edge,会静默去测 edge(踩过这个坑)。
if pj.get("provider") != provider:
pj["provider"] = provider
save_project(proj, pj)
# ---- ② 密钥(仅火山) ----
if provider == "volcano":
env_path, created = ensure_env_file(proj)
if created:
print(f"✓ 已生成密钥模板:{env_path}")
print(" (该文件已被 .gitignore 忽略,不会进仓库;我也不会读它的内容)")
print()
print("请手动完成两步:")
print(" 1. 打开上面这个文件,把 VOLC_API_KEY= 后面填上你的 API Key")
print(" API Key 获取:https://console.volcengine.com/speech/new/setting/apikeys")
print(" 2. 存盘后回来告诉我「填好了」,我再测连接并继续")
print()
print("NEXT_ACTION=fill_env")
return 2
voice_for_check = resolve_voice("volcano", args.voice or pj.get("voice", ""))
st = tts_volcano.check(proj, voice_for_check)
if not st["ok"]:
print(f"✗ 密钥已存在但连接未通过({st['stage']}):\n{st['detail']}", file=sys.stderr)
print(f" tts.env:{env_path}", file=sys.stderr)
return 1
print(f"✓ 连接正常 · 音色 {st['voice']} · 密钥 {st['key_masked']}")
# ---- ③ 选音色 ----
name_or_id = args.voice or ""
if not name_or_id:
if not sys.stdin.isatty():
default = tts_volcano.DEFAULT_VOICE if provider == "volcano" else EDGE_DEFAULT_ZH
print(f"未指定音色,使用默认 {default}")
print("NEXT_ACTION=ask_user_voice")
name_or_id = default
else:
print_voices(provider)
name_or_id = input("音色(填序号 / 名称 / 直接给 ID,回车用默认):").strip() or ""
default_voice = tts_volcano.DEFAULT_VOICE if provider == "volcano" else EDGE_DEFAULT_ZH
voice = resolve_voice(provider, name_or_id) or default_voice
# ---- 写回 project.json ----
pj["provider"] = provider
pj["voice"] = voice
pj["rate"] = _normalize_rate(provider, args.rate or pj.get("rate"))
save_project(proj, pj)
print()
print(f"✓ 已写入 {os.path.join(proj, 'project.json')}")
rate_shown = f"{pj['rate']}%(快慢百分比)" if provider == "volcano" else pj["rate"]
print(f" 方案 {provider} · 音色 {voice} · 语速 {rate_shown}")
if provider == "volcano":
print(" 下一步:python <skill>/scripts/tts_build.py --project .")
else:
print(" 下一步:python <skill>/scripts/tts_build.py --project .")
print("NEXT_ACTION=done")
return 0
def main() -> int:
p = argparse.ArgumentParser(description="html-explainer 配音方案设置向导")
p.add_argument("--project", default=".")
p.add_argument("--provider", default="", choices=["", "edge", "volcano"])
p.add_argument("--voice", default="")
p.add_argument("--rate", default="")
p.add_argument("--status", action="store_true", help="显示当前配置与脱敏密钥状态")
p.add_argument("--check", action="store_true", help="只测连接")
p.add_argument("--list-voices", action="store_true", help="列出候选音色")
p.add_argument("--env-file", default="", help="显式指定 tts.env(仅本次)")
args = p.parse_args()
if args.env_file:
os.environ["TTS_ENV_FILE"] = args.env_file
proj, pj = load_project(args.project)
provider = (args.provider or os.environ.get("TTS_PROVIDER")
or pj.get("provider") or "edge").lower()
if args.list_voices:
print_voices(provider)
return 0
if args.check:
return cmd_check(proj, provider, resolve_voice(provider, args.voice))
return run(args)
if __name__ == "__main__":
raise SystemExit(main())