流水线开跑前先问用户用哪个 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 节点
336 lines
15 KiB
Python
336 lines
15 KiB
Python
#!/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())
|