# 火山引擎语音合成 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. 三步配置 ```bash # ① 选方案(会问用户:edge 还是火山) python /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 /scripts/tts_setup.py --project . --check # 测连接 python /scripts/tts_setup.py --project . --list-voices # 列候选音色 python /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 格式 ```ini 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 ```jsonc {"code":0, "message":"OK", "data":"", "sentence":{"text":"你好","words":[ {"word":"你","startTime":0.195,"endTime":0.335,"confidence":0.9}]}} {"code":0, "message":"OK", "data":""} {"code":20000000, "message":"OK", "data":null, "usage":{"text_words":7}} // ★ 结束标记 ``` **四个必须知道的点**: 1. **结束标记的 `code` 是 `20000000`,不是 `0`。** 只判 `code===0` 收音频会丢最后一段 (症状:合并出来的音频末尾「嘎然而止」)。本包按 `code` 显式区分。 2. **★ 字级时间戳必须显式开 `audio_params.enable_subtitle: true`。** 官方文档的响应示例里 `words[]` 是有值的,**很容易误以为默认就给** —— 实测不传该参数 `words` **恒为空数组**(同文本对照:`false` → `words=0`,`true` → `words=10`)。 它**不报错**:音频、时长、`sentence.text` 全正常,只有时间戳是空的; 下游会静默退回按字数插值,字幕开始飘。本包默认开启。 开了之后拿到的是字级 `word` / `startTime` / `endTime` / `confidence`(单位**秒**), 即 edge-tts `WordBoundary` 的等价物,**不需要「强制对齐」那一步**。 → 映射成 `{"text","offset_sec","duration_sec"}`。 仅豆包 2.0 的**中英文**音色支持;拿到音频却没有时间戳时本包会在结果里塞 `warning`。 3. **`usage` 挂在结束标记那一行。** 解析循环若「先判结束标记 → `continue`」, 就会**永远读不到计费字数(恒为 0)**,容易被误判成「免费额度」。 另外**不传请求头 `X-Control-Require-Usage-Tokens-Return: *`,服务端根本不返回 `usage`**。 4. **单请求文本上限 ~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` | 女声 · 知性 | 完整列表(上百个)见官方音色文档: **注意**:切了引擎要重跑 `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:` | | 音频末尾「嘎然而止」 | 结束标记 `code=20000000` 的处理问题(本包已处理;自研代码要留意) | | 字幕与音频错位 | 检查 `sentence.words[]` 是否被正确映射成 `offset_sec`(单位是**秒**) | | 想确认测的到底是哪个引擎 | `--check` 会先打印 `测试方案:` |