修复 - tts_build.py 走火山引擎时,project.json 里的中文音色名没被解析成音色 ID, 合成恒失败并返回 code=55000000(报错完全看不出是「名字没解析」)。 双层兜底:tts_volcano.synthesize() 入口解析 + tts_build.py 读配置后解析。 新增 - render_video.mjs:`--png-fast`(CDP optimizeForSpeed,逐像素无损,4.4×)、 `--jpeg-quality N`、`--crf N`、`--preset <名>`。最后一对把「帧容器」与 「成片码率」解耦(旧 --jpeg 会连带降 crf23/veryfast,那是草稿语义)。 默认行为完全不变。 - qc_check.py:新增实测视频流帧数校验。原先只比容器时长,而容器时长会被音轨撑起 来,帧序列缺口会导致视频流被截断却仍显示完整长度(假通过,见 lessons #57)。 文档 - lessons.md 新增 #62–#69:信息层色值契约与绘制层排查、语速标定绑定引擎与音色、 以及「PNG 编码才是瓶颈、且对并发完全免疫」的完整实测与判定路径。 - SKILL.md / references/workflow-guide.md:补截图模式选用判据(帧里有满幅照片就换模式)。 - CHANGELOG 1.3.1 段补齐 + 版本徽章同步。
11 KiB
火山引擎语音合成 2.0(豆包)接入说明
html-explainer 支持两个配音引擎。跑 tts_build.py 之前必须先问用户用哪个(SKILL.md 确认点 3)。
| 引擎 | 密钥 | 音色 | 词级时间戳 | 什么时候用 |
|---|---|---|---|---|
edge(默认) |
不需要 | edge-tts 内置音色 | 有(WordBoundary) |
默认。免费、开箱可用、够用 |
volcano |
需要 API Key | 豆包语音合成模型 2.0 | 有(sentence.words[]) |
想要更好的音质/更自然的语气 |
两个引擎产出的 audio-manifest.json 结构完全一致(boundaries 都是「秒 + 原始坐标系」),
所以 timeline_build.py / subs.py / 渲染器零改动。
1. 密钥纪律(读这一节就够)
用户裁定:agent 不许读取 tts.env,仓库不许记录它。
这不是靠自觉,是靠结构保证的:
你(手工填一次)→ tts.env(被 .gitignore 忽略)
↓ 只有 scripts/tts_volcano.py 读它
tts_volcano.synthesize() ← agent 只调这个,永远拿不到密钥值
↓
openspeech.bytedance.com
- agent 侧:只调用
scripts/tts_volcano.py(或tts_build.py --provider volcano)。 不需要cat/grep/ 读tts.env—— 接口包会自己读。 想确认密钥状态时跑tts_setup.py --status,它输出的是脱敏摘要。 - 密钥不进输出:接口包保证不打印、不记录、不回显密钥;异常信息过
_redact()脱敏 (只抹「本次实际加载的密钥值」和AKLT…形态 token,reqid / logid 保留 —— 排查要线索)。 - 密钥不进仓库:
.gitignore忽略tts.env/*.env/*.key/*.pem/secrets/;check_integrity.py有专门的密钥防线检查(见第 5 节)。 - 密钥不进分发:
package_skill.py排除tts.env/*.env/*.key/*.pem(模板tts.env.example保留,用户要照抄它)。 - 兜底层(真正的安全边界):万一还是泄了,损失要可控 —— 建议在火山控制台用 子账号只授 TTS 权限、设用量告警与限额,并且密钥可随时轮换。
说句实话:合成代码是 agent 在本机执行的,运行时进程里密钥对脚本可见, 「agent 绝对读不到」做不到。上面保证的是不进对话、不进仓库、不进日志、不进分发, 把泄露半径收敛成一次可撤销的事故。
2. 三步配置
# ① 选方案(会问用户:edge 还是火山)
python <skill>/scripts/tts_setup.py --project .
# 选火山且缺 tts.env → 生成带注释的空模板并退出(退出码 2,NEXT_ACTION=fill_env)
# ② 用户手工填 tts.env(这一步 agent 不参与)
# 火山引擎控制台 → 语音技术 → API Key 管理
# https://console.volcengine.com/speech/new/setting/apikeys
# ③ 回来测连接 + 选音色
python <skill>/scripts/tts_setup.py --project . --check # 测连接
python <skill>/scripts/tts_setup.py --project . --list-voices # 列候选音色
python <skill>/scripts/tts_setup.py --project . --provider volcano --voice "解说小明 2.0"
配置写进 project.json 的 provider / voice / rate 三个字段。
方案一旦选定立刻落盘 —— 因为填密钥会中断流程,若不落盘,用户回来跑 --check 时
project.json 还写着 edge,会静默去测 edge(这个坑真踩过)。
非交互场景(agent 代跑)看这三个标记:NEXT_ACTION=ask_user_provider /
NEXT_ACTION=fill_env / NEXT_ACTION=ask_user_voice / NEXT_ACTION=done。
3. tts.env 格式
VOLC_API_KEY= # 必填。也可以用旧版鉴权(见下)
VOLC_RESOURCE_ID=seed-tts-2.0
VOLC_APP_ID= # 可选:旧版鉴权(APP ID + Access Token)
VOLC_ACCESS_KEY= # 可选:填了则与 VOLC_APP_ID 配对使用
探测顺序(tts_volcano.env_candidates(),与向导保持一致):
$TTS_ENV_FILE → <项目>/tts.env → <技能目录>/tts.env → ~/.workbuddy/tts.env
资源 ID:预置音色用 seed-tts-2.0;你自己克隆的音色用 seed-icl-2.0。
4. 接口规格(2026-09 核对官方文档)
POST https://openspeech.bytedance.com/api/v3/tts/unidirectional
Header: X-Api-Key / X-Api-Resource-Id / X-Api-Request-Id(uuid) / Content-Type: application/json
Body: {user:{uid}, req_params:{text, speaker, audio_params:{format,sample_rate,bit_rate,speech_rate}}}
为什么选这条路:这是 HTTP Chunked 单向流式——一次性发文本、流式收音频。
与 edge-tts 的形态同构(发一次、收一串),所以 tts_build.py 的缓存 / 硬超时 /
退避重试 / 裁静音 / 两级时钟逻辑全部原样复用。
不用双向流式(wss://.../api/v3/tts/bidirection):那是给 LLM 实时对话设计的
(文本流式进、可打断、多轮 session 状态机)。离线出片用不上,纯开销。
响应:NDJSON,逐行 JSON
{"code":0, "message":"OK", "data":"<base64 音频块>", "sentence":{"text":"你好","words":[
{"word":"你","startTime":0.195,"endTime":0.335,"confidence":0.9}]}}
{"code":0, "message":"OK", "data":"<base64 音频块>"}
{"code":20000000, "message":"OK", "data":null, "usage":{"text_words":7}} // ★ 结束标记
四个必须知道的点:
- 结束标记的
code是20000000,不是0。 只判code===0收音频会丢最后一段 (症状:合并出来的音频末尾「嘎然而止」)。本包按code显式区分。 - ★ 字级时间戳必须显式开
audio_params.enable_subtitle: true。 官方文档的响应示例里words[]是有值的,很容易误以为默认就给 —— 实测不传该参数words恒为空数组(同文本对照:false→words=0,true→words=10)。 它不报错:音频、时长、sentence.text全正常,只有时间戳是空的; 下游会静默退回按字数插值,字幕开始飘。本包默认开启。 开了之后拿到的是字级word/startTime/endTime/confidence(单位秒), 即 edge-ttsWordBoundary的等价物,不需要「强制对齐」那一步。 → 映射成{"text","offset_sec","duration_sec"}。 仅豆包 2.0 的中英文音色支持;拿到音频却没有时间戳时本包会在结果里塞warning。 usage挂在结束标记那一行。 解析循环若「先判结束标记 →continue」, 就会永远读不到计费字数(恒为 0),容易被误判成「免费额度」。 另外不传请求头X-Control-Require-Usage-Tokens-Return: *,服务端根本不返回usage。- 单请求文本上限 ~200 字。 超了本包按标点切分(不在词中间劈开), 后续片段的词边界按前面片段用 ffmpeg 量出的实际时长平移。
参数换算:语速 speech_rate ∈ [-50,100],映射 倍率 = 1 + speech_rate/100
(100 = 2.0 倍、-50 = 0.5 倍,线性)。parse_rate_percent() 统一接受
"+8%" / "8" / 0.08 / 8 四种写法,tts_setup.py 与 tts_build.py 共用这一份
(不要各写一份:tts_volcano.py CLI 曾把中文音色名直接当 speaker 发出去,
被服务端以 55000000 resource ID is mismatched with speaker… 拒绝,看不出是没解析)。
音色名解析同理走 resolve_voice_token()。
默认 sample_rate=24000 / format=mp3 / bit_rate=160000。
代理:openspeech.bytedance.com 是境内端点,默认绕过系统代理直连。
本机常驻代理会让境内域名绕远甚至失败(见 lessons)。要强制走代理就设 VOLC_PROXY。
5. 密钥防线的自动化检查
python scripts/check_integrity.py 多跑两项:
✓ 密钥防线:.gitignore 已忽略 tts.env / *.env / *.key
✓ 密钥防线:仓库内未发现真实密钥(模板里的空赋值不算)
第二项会遍历仓库(跳过 .git/node_modules/二进制),抓两类形态:
非空密钥赋值(VOLC_API_KEY=…)与 AKLT… token。空模板不算违规,所以
tts.env.example 可以入库。CI 也跑这个 —— 它在无密钥环境下能通过。
6. 音色
tts_setup.py --list-voices 会列内置常用音色(可自定义 ID)。默认 云舟 2.0。
2.0 音色的 voice_type 都以 _uranus_bigtts 结尾。
| 音色 | voice_type | 定位 |
|---|---|---|
| 云舟 2.0 | zh_male_m191_uranus_bigtts |
男声 · 沉稳通用(默认) |
| 温暖阿虎 2.0 | zh_male_wennuanahu_uranus_bigtts |
男声 · 温暖 |
| 解说小明 2.0 | zh_male_jieshuoxiaoming_uranus_bigtts |
男声 · 解说 |
| 磁性解说男声 2.0 | zh_male_cixingjieshuonan_uranus_bigtts |
男声 · 磁性解说 |
| 悬疑解说 2.0 | zh_male_xuanyijieshuo_uranus_bigtts |
男声 · 悬疑叙事 |
| 小何 2.0 | zh_female_xiaohe_uranus_bigtts |
女声 · 通用 |
| Vivi 2.0 | zh_female_vv_uranus_bigtts |
女声 · 活泼通用 |
| 知性灿灿 2.0 | zh_female_cancan_uranus_bigtts |
女声 · 知性 |
完整列表(上百个)见官方音色文档:https://docs.volcengine.com/docs/DoubaoVoice/Tonelist-1
注意:切了引擎要重跑 tts_build.py(缓存 sig 含 provider,不会串音);
语速标定结论不能跨引擎沿用 —— edge 的 RATE 与火山的 speech_rate 是两套刻度,
换引擎后如需精密控速要重新标定。另外 edge 独有的「相邻数字补逗号」处理对火山不启用:
它会改动送进去的文本、影响词边界与解说词的逐字对齐,而火山不熔读相邻数字。
7. 排查
| 现象 | 原因 / 处理 |
|---|---|
没有找到 tts.env |
跑 tts_setup.py --project . 生成模板后填写 |
HTTP 401 / Invalid X-Api-Key |
密钥错、未开通服务,或该用旧版鉴权(填 VOLC_APP_ID + VOLC_ACCESS_KEY) |
55000000 resource ID is mismatched with speaker related resource |
① 音色名没被解析成 ID(speaker 只认 ID;1.3.1 前 tts_build.py 走了未解析的路径,已修)② 音色与资源 ID 不匹配(预置音色要配 seed-tts-2.0)③ 该音色未在控制台开通。先确认日志里打印的是不是 xxx_uranus_bigtts 形态的 ID,再去查 ②③ —— 否则会白测一圈音色 |
HTTP 404 |
音色与资源 ID 不匹配 —— 预置音色要配 seed-tts-2.0 |
网络不可达 |
本包默认直连;若确实需要代理,设 VOLC_PROXY=http://127.0.0.1:<port> |
| 音频末尾「嘎然而止」 | 结束标记 code=20000000 的处理问题(本包已处理;自研代码要留意) |
| 字幕与音频错位 | 检查 sentence.words[] 是否被正确映射成 offset_sec(单位是秒) |
| 想确认测的到底是哪个引擎 | --check 会先打印 测试方案:<provider> |