diff --git a/.gitignore b/.gitignore index ff0f499..f58f240 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,17 @@ node_modules/ # ---------- Distribution bundles ---------- dist/ +# ---------- Secrets ---------- +# 火山引擎(豆包)TTS 的 API Key 只存在 tts.env 里,永不入库。 +# ★ 这个文件是密钥的唯一入口;agent 不需要、也不应该读取它 —— +# scripts/tts_volcano.py 会自己读。模板见 tts.env.example(那个是入库的)。 +tts.env +*.env +!*.env.example +secrets/ +*.key +*.pem + # ---------- Per-project runtime artifacts ---------- # html-explainer generates these inside a *video project* directory. # Ignored here so a demo project created inside the repo never gets committed. diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b4c18..bd76bd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,87 @@ --- +## [1.3.0] — 2026-09-23 + +### 新增 + +- **★ 火山引擎(豆包)语音合成 2.0 作为可选配音引擎。** 原先只有 edge-tts; + 现在 `tts_build.py --provider edge|volcano`,`project.json` 记 `provider` 字段。 + 两个引擎产出的 `audio-manifest.json` **结构完全一致**(`boundaries` 均为「秒 + 原始坐标系」), + 因此 `timeline_build.py` / `subs.py` / 渲染器**零改动**。 +- **`scripts/tts_volcano.py`** —— 火山 TTS 接口包(SDK 层)。走 **HTTP Chunked 单向流式** + `POST /api/v3/tts/unidirectional`:与 edge-tts 形态同构(发一次、收一串), + 故缓存 / 硬超时 / 退避重试 / 裁静音 / 两级时钟全部原样复用。 + **零第三方依赖**(只用标准库 `urllib`)。`--check` / `--voices` / `--synth` 三个子命令。 +- **`scripts/tts_setup.py`** —— 配音方案向导:选方案 → 缺密钥则生成 `tts.env` 模板并停下 → + 测连接 → 选音色 → 写回 `project.json`。输出 `NEXT_ACTION=…` 供 agent 判断下一步。 +- **`tts.env.example`** —— 密钥模板(入库;真实 `tts.env` 被忽略)。 + +### 安全 + +- **★ 密钥纪律:agent 不读、仓库不记、日志不吐、分发不含。** 四条都由结构保证: + ① 密钥只从 `tts.env` 读取,而只有 `tts_volcano.py` 读它 —— agent 只调接口包; + ② 异常信息过 `_redact()` 脱敏(只抹本次实际加载的密钥值与 `AKLT…` 形态 token, + 保留 reqid/logid 以便排查); + ③ `.gitignore` 新增 `tts.env` / `*.env` / `*.key` / `*.pem` / `secrets/`; + ④ **`check_integrity.py` 新增「密钥防线」检查**(gitignore 覆盖 + 遍历仓库抓非空密钥赋值与 + `AKLT…` token,空模板不算违规),CI 在无密钥环境下跑通。 +- **★ 修掉一个真实的密钥外泄口:`package_skill.py` 原先不排除 `tts.env`** —— + 技能目录里若放了这个文件,打包会把用户的 API Key 一起塞进可分发的 zip。 + 已加入 `EXCLUDE_NAMES` / `EXCLUDE_SUFFIX`(`tts.env`、`*.env`、`*.key`、`*.pem`), + 模板 `tts.env.example` 保留。已实测:打包后 zip 内无密钥文件、内容级扫描无密钥串。 + +### 修复 + +- **`tts_setup.py` 的方案选定后未落盘**:用户选火山 → 生成模板 → 填密钥 → 回来跑 `--check`, + 此时 `project.json` 还写着 `edge`,会**静默去测 edge 并报「就绪」**。现在方案一旦确定立刻写盘。 +- `--check` 现在先打印 `测试方案:`,避免「测的其实不是你以为的那个」。 +- `ensure_env_file()` 的查找顺序改成与 `tts_volcano.env_candidates()` 一致 + (项目目录 → 技能目录 → 新建于项目目录),消除「向导认技能目录的、接口包认项目目录的」错位。 + +### 修复(首次真机联调后补齐) + +> v1.3.0 在合并前用**真实 API Key** 跑了完整链路(向导 → 真合成 → timeline → subs → 打包), +> 又抓出 4 个只看代码发现不了的问题。以下都已修复并实测通过;v1.3.0 此前从未发布,故不另起版本号。 + +- **★ 字级时间戳必须显式开 `audio_params.enable_subtitle: true`。** 官方文档的响应示例里 + `sentence.words[]` 是有值的,容易误以为「默认就给」——**实测不传这个参数 `words` 恒为空数组** + (同文本对照:`false` → `words=0`,`true` → `words=10`)。而且它**完全不报错**:音频正常、 + 时长正常、`sentence.text` 也正常。下游 `subs.py` 会静默退回**按字数插值**,成片照样出、 + 字幕开始飘。现已在接口包默认开启,并在拿到音频却没有时间戳时返回 `warning` + 让 `tts_build.py` 打到台面上。该能力仅豆包 2.0 的中英文音色支持。 +- **计费字数永远是 0**:`usage.text_words` 挂在**结束标记那一行**(`code=20000000`), + 而解析循环先判结束标记就 `continue` 了,那一行从没被读完。同时补上 + `X-Control-Require-Usage-Tokens-Return: *` 请求头(不传该头服务端根本不返回 `usage`)。 + 修后实测:`计费 20 字` 与文本逐字对上。 +- **★ 命中缓存时丢失首裁量 → 首跑与重跑产出不一致。** 词边界是原始坐标系,下游靠 + `lead_cut_sec` 平移;缓存只存了边界数组,于是**重跑 `lead_cut_sec=0`、首跑是 0.037** + ——字幕整体晚一帧,且只在第二次跑时出现。缓存格式改为 + `{"boundaries": [...], "lead_cut_sec": x}`,并对旧版裸列表格式做兼容读取。 +- **CLI 把中文音色名原样当 `speaker` 发出去**:`--voice "云舟 2.0"` 被服务端拒绝, + 报的是 `55000000 resource ID is mismatched with speaker related resource` + ——完全看不出是「名字没解析」。现把 `resolve_voice_token()`(名称/序号/ID)与 + `parse_rate_percent()`(`+8%`/`8`/`0.08`)都收进接口包,`tts_setup.py` 改为委托, + 消除两份实现跑偏的可能。 +- 火山项目的 `rate` 会沿用 `new_project.py` 写的 edge 格式 `+8%`,现按引擎清洗 + (火山存 int 百分比);`--status` 在 `rate=0` 时不再误报「未设置」(`0` 是 falsy)。 +- `check_integrity.py` 缺模板时新增提示:若同目录存在 `tts.env`,直接说明 + **「疑似把模板改名成了 tts.env」**(实测踩到的真实用法)。 + +### 备注 + +- 火山接口的坑已处理并写进文档:**结束标记 `code=20000000`(不是 0,误判会丢最后一段音频)**、 + 单请求文本上限 ~200 字(超了按标点切分并按实际时长平移词边界)、 + **字级时间戳需显式开 `audio_params.enable_subtitle`**(见上)。 +- `openspeech.bytedance.com` 是境内端点,接口包**默认绕过系统代理直连**;需要时设 `VOLC_PROXY`。 +- edge 独有的「相邻数字补逗号」对火山**不启用** —— 它会改动送进去的文本, + 影响词边界与解说词的逐字对齐,而火山不熔读相邻数字。 +- 真机联调的验收口径:3 段共 20 块字幕,**每块起点与其首字词边界的误差均为 0.000s**; + 首跑与缓存重跑的 manifest 深度 diff(982 个字段)完全一致; + 打包 zip 38 个成员,密钥文件名与内容级扫描**双无命中**。 + +--- + ## [1.2.3] — 2026-09-22 ### 新增 @@ -151,6 +232,7 @@ - 23 种画面风格目录,8 个类别,按改编成本分类。 - `setup_env.sh` 做首次环境自检,`--install` 装缺失依赖;`package_skill.py` 打可移植 zip。 +[1.3.0]: https://github.com/OneMoh/html-explainer/releases/tag/v1.3.0 [1.2.3]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.3 [1.2.2]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.2 [1.2.1]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.1 diff --git a/README.en.md b/README.en.md index 8611350..daa548f 100644 --- a/README.en.md +++ b/README.en.md @@ -8,7 +8,7 @@ Write scenes in HTML → deterministic frame-by-frame rendering → a real MP4. locally; the core pipeline needs no API key and charges no per-render fee. [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-1.2.3-blue.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.3.0-blue.svg)](CHANGELOG.md) [![Agent Skill](https://img.shields.io/badge/Agent%20Skill-SKILL.md-8A2BE2.svg)](SKILL.md) [![Claude Code](https://img.shields.io/badge/Claude%20Code-%E2%9C%93-D97757.svg)](#install) [![Codex](https://img.shields.io/badge/Codex-%E2%9C%93-000000.svg)](#install) @@ -107,7 +107,7 @@ exception and only reads `.claude/skills/`.) | Item | Minimum | Notes | |---|---|---| -| Python | 3.9+ | `edge-tts==7.2.8` (pinned deliberately — v7 changed the boundary API), `numpy`, `pillow`, `imageio-ffmpeg` | +| Python | 3.9+ | `edge-tts==7.2.8` (pinned deliberately — v7 changed the boundary API), `numpy`, `pillow`, `imageio-ffmpeg`. **The Volcano engine needs nothing extra** (stdlib `urllib` only, no SDK) | | Node.js | 18+ | Used by the renderer and the cover builder | | Browser | Chrome or Edge | Auto-detected; playwright chromium (~115MB, one time) only if neither exists | | ffmpeg | any version | Looked up on `PATH`; falls back to the static binary bundled with `imageio-ffmpeg` | @@ -117,6 +117,81 @@ exception and only reads `.claude/skills/`.) --- +## Voice-over: two engines + +Before the pipeline starts the agent **asks which TTS you want**, then lists candidate voices for +you to pick from (or you can just hand it a voice ID): + +| | `edge-tts` | Volcano Engine TTS 2.0 | +|---|---|---| +| What you do | nothing | paste an API Key once | +| Cost | free | billed per character | +| Voices | a set of built-in zh/en voices | Doubao 2.0 voice library, incl. voice cloning | +| Word timestamps | `WordBoundary` | `sentence.words[]` | +| Extra deps | `edge-tts==7.2.8`, `imageio-ffmpeg` | **none** — stdlib `urllib` only | +| When to pick it | default; works out of the box | when you want more natural prosody / better quality | + +Both engines emit an **identical** `audio-manifest.json`, so the timeline, the subtitles and the +renderer **all stay untouched** — switching engines is a one-field change. + +### With Volcano, the only thing you touch + +The first time it runs Volcano, the skill **creates a `tts.env` and stops**, then tells you to paste +your API Key into it (console → Speech → API Key management). Say "done" and the agent tests the +connection, confirms the voice, and carries on. + +**That step deliberately bypasses the chat window** — the key is never sent to the agent, and the +agent never needs to know it. + +Built-in voices (full list in the +[official voice docs](https://docs.volcengine.com/docs/6561/1257544)): + +| Male | Female | +|---|---| +| Yunzhou 2.0 (default) · Wennuan Ahu 2.0 · Jieshuo Xiaoming 2.0 · Cixing Jieshuo Nan 2.0
Xuanyi Jieshuo 2.0 · Guanggao Jieshuo 2.0 · Ruya Qingnian 2.0 · Shaonian Zixin 2.0 · Shenye Boke 2.0 | Xiaohe 2.0 · Vivi 2.0 · Zhixing Cancan 2.0
Tianmei Taozi 2.0 · Linjia Nvhai 2.0 · Wenrou Shunv 2.0 | + +If the built-in list is not enough, a voice ID from voice cloning works too. + +### Key discipline + +The API Key lives only in `tts.env`, and **only one interface package inside the skill reads it**. +The agent calls that package, so it neither gets nor needs the key value: + +``` +you (fill it in once) → tts.env (already gitignored) + ↓ only the interface package reads it + synthesis call ← the agent calls this; never sees the key + ↓ + Volcano Engine +``` + +- **Never in the chat**: ask the agent for key status; what comes back is a masked summary + (`ef90******86c8`). +- **Never in the repo**: `.gitignore` covers `tts.env` / `*.env` / `*.key` / `*.pem` / `secrets/`; + the repo self-check has a dedicated **key-defence** rule, itself proven by a negative test — + a deliberately planted fake key gets caught. +- **Never in logs**: error messages are redacted — only the key value actually used and `AKLT…` + tokens are scrubbed. Deliberately **not** a broad "any long string is a secret" rule, which would + also erase the request IDs you need for support. +- **Never in a distribution**: packaging the skill excludes key files. +- **Backstop** (the real security boundary): if it leaks anyway, keep the blast radius small — use + a **sub-account with speech-synthesis-only permission**, set **usage alerts and quotas**, and + **rotate** the key. + +> Bluntly: the synthesis code runs on your machine, so the key is visible to that process at +> runtime — **"the agent can never read it" is not achievable**. What is guaranteed is *not in the +> chat, not in the repo, not in the logs, not in the distribution*, which shrinks a leak into one +> revocable incident. +> +> Also **don't store the key in an environment variable** — many hosts dump the process environment +> straight into the session transcript. + +**Don't rename `tts.env.example` to `tts.env` and fill that in.** It is the template meant to stay +in the repo; renaming it leaves everyone else without one (the repo self-check reports it). +Copy it, then fill the copy. + +--- + ## Video project layout Say one sentence and the agent walks the whole pipeline inside a **video project directory**; @@ -161,7 +236,9 @@ The trade-off: **every animation must be seekable.** Wall-clock animation (`setI | | | |---|---| | **Beats anchored to narration** | Scenes position animation by **matching subtitle text** (`B('block text')`), never by hardcoded frame numbers. Edit the script and the whole video re-times with zero scene-code changes. | -| **Word-boundary subtitles** | Timing comes from edge-tts `WordBoundary` events, never interpolated by character count — in Chinese two phrases of equal length can differ 3× in duration. | +| **Word-boundary subtitles** | Timing comes from the TTS engine's **character-level timestamps**, never interpolated by character count — in Chinese two phrases of equal length can differ 3× in duration. `WordBoundary` for edge-tts, `sentence.words[]` for Volcano Engine. | +| **Two voiceover engines** | `edge-tts` (free, no key, default) or **Volcano Engine TTS 2.0** (better quality, needs an API key). Both emit an identical manifest, so nothing downstream changes — switch with `--provider`. | +| **The API key never reaches the agent or the repo** | The Volcano key lives only in `tts.env` (gitignored) and **only the interface package reads it** — the agent calls `tts_volcano.py` and never needs the key. Errors are redacted, `check_integrity.py` enforces a secret guard, and packaging excludes key files. | | **Two-level clock** | Frame length uses MP3 **container duration** (keeps A/V in sync); the final subtitle block ends on **actual speech end**. | | **23 visual styles** | 8 categories, each with its canvas, type scale, timeline and colour discipline recorded. No designing from a blank page. | | **Dual covers** | Every video ships a 16:9 cover plus an **independently re-laid-out** 3:4 cover — not a crop. Cropping loses 57.8% of the width. | @@ -176,7 +253,8 @@ The trade-off: **every animation must be seekable.** Wall-clock animation (`setI flowchart LR A["Topic"] --> B["Research
every figure sourced"] B --> C["narration.json
pipes split subtitle blocks"] - C --> D["tts_build.py
edge-tts to MP3 + word boundaries"] + C --> S["tts_setup.py
ask engine · key · voice"] + S --> D["tts_build.py
edge-tts / Volcano to MP3 + word boundaries"] D --> E["timeline_build.py
global axis + stitched track"] E --> F["subs.py
subs.json, srt/vtt, beats.js"] F --> G["frames/*.html
one scene per line, styled from the library"] @@ -266,8 +344,13 @@ rules and the pre-upload checklist: [`references/cover-guide.md`](references/cov | Cover renders at 1× | Same trap, `deviceScaleFactor` twin | Pass to `newPage()` + `screenshot({ scale: 'device' })` | | Numbers render but never move | The seek suppressed `onUpdate` callbacks | Renderer fixed (`pause(t, false)`); use a transform-based number reel in scenes | | `No such file or directory` on Windows | Non-ASCII path — Windows ffmpeg reads UTF-8 as ANSI | Keep paths ASCII | +| Subtitles drift / land in the wrong place | TTS returned no word timestamps, so `subs.py` fell back to **character-count interpolation** (a single stderr warning) | Check whether `tts_build` printed `⚠ no word timestamps`; switch to a supported voice (zh/en Doubao 2.0) | +| Volcano error `resource ID is mismatched with speaker related resource` | `speaker` got a display name instead of a voice ID; or a cloned voice paired with the preset resource ID | Use a built-in voice name/ID (the package resolves it); for cloned voices set `VOLC_RESOURCE_ID=seed-icl-2.0` | +| Volcano `HTTP 401/403` | Wrong key in `tts.env`, or the service is not enabled | Verify `VOLC_API_KEY`; check the masked status via `tts_setup.py --status` | +| Volcano `network unreachable` | The package **bypasses the system proxy by default** (China-mainland endpoint) | If you really need a proxy, set `VOLC_PROXY=http://127.0.0.1:` | +| Subtitles sit one frame late after a re-run | A cache hit dropped the head-trim amount (historical bug, fixed) | Upgrade to v1.3.0+; the cache format now carries `lead_cut_sec` | -**`references/lessons.md` is the most valuable file in this repository.** 34 numbered entries, each +**`references/lessons.md` is the most valuable file in this repository.** 56 numbered entries, each one a bug where "the video looked fine but was wrong" — including how it was misdiagnosed at first. Read it before debugging from scratch. diff --git a/README.md b/README.md index 2e71a6e..660f186 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ HTML 写画面 → 确定性逐帧渲染 → 真 MP4。全本地跑,核心链路零 API key、零按次计费。 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-1.2.3-blue.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.3.0-blue.svg)](CHANGELOG.md) [![Agent Skill](https://img.shields.io/badge/Agent%20Skill-SKILL.md-8A2BE2.svg)](SKILL.md) [![Claude Code](https://img.shields.io/badge/Claude%20Code-%E2%9C%93-D97757.svg)](#安装) [![Codex](https://img.shields.io/badge/Codex-%E2%9C%93-000000.svg)](#安装) @@ -102,7 +102,7 @@ Windows 上 `~` 就是 `C:\Users\<你的用户名>`。 | 项 | 最低 | 说明 | |---|---|---| -| Python | 3.9+ | `edge-tts==7.2.8`(刻意钉死 —— v7 改过边界 API)、`numpy`、`pillow`、`imageio-ffmpeg` | +| Python | 3.9+ | `edge-tts==7.2.8`(刻意钉死 —— v7 改过边界 API)、`numpy`、`pillow`、`imageio-ffmpeg`。**火山引擎引擎零额外依赖**(只用标准库 `urllib`,不需要装 SDK) | | Node.js | 18+ | 渲染器与封面器用 | | 浏览器 | Chrome 或 Edge | 自动探测;都没有才下载 playwright chromium(约 115MB,只需一次) | | ffmpeg | 任意版本 | 先在 `PATH` 找;没有则用 `imageio-ffmpeg` 自带的静态二进制 | @@ -112,6 +112,71 @@ Windows 上 `~` 就是 `C:\Users\<你的用户名>`。 --- +## 配音:两个引擎 + +流水线开始前,智能体会**先问你用哪个 TTS**,再列候选音色让你挑(也可以直接给它音色 ID): + +| | `edge-tts` | 火山引擎语音合成 2.0 | +|---|---|---| +| 你要做什么 | 什么都不用做 | 填一次 API Key | +| 费用 | 免费 | 按字符计费 | +| 音色 | 内置中英文若干 | 豆包 2.0 音色库,含声音复刻 | +| 字级时间戳 | `WordBoundary` | `sentence.words[]` | +| 额外依赖 | `edge-tts==7.2.8`、`imageio-ffmpeg` | **零** —— 只用标准库 `urllib` | +| 什么时候选它 | 默认。开箱可用、够用 | 想要更自然的语气与更好音质 | + +两者产出的 `audio-manifest.json` **结构完全一致**,所以时间轴、字幕、渲染**全都不用改** —— +换引擎只是换一个字段的事。 + +### 选火山时,你唯一要动手的地方 + +技能第一次跑火山时会**生成一个 `tts.env` 并停下来**,然后告诉你把 API Key 填进去 +(火山控制台 → 语音技术 → API Key 管理)。你填好回来说一声,智能体就去测连接、确认音色, +然后接着往下跑。 + +**这一步刻意不经过对话窗口** —— 密钥不发给智能体,智能体也不需要知道它。 + +内置常用音色(完整列表见[官方音色文档](https://docs.volcengine.com/docs/6561/1257544)): + +| 男声 | 女声 | +|---|---| +| 云舟 2.0(默认)· 温暖阿虎 2.0 · 解说小明 2.0 · 磁性解说男声 2.0
悬疑解说 2.0 · 广告解说 2.0 · 儒雅青年 2.0 · 少年梓辛 2.0 · 深夜播客 2.0 | 小何 2.0 · Vivi 2.0 · 知性灿灿 2.0
甜美桃子 2.0 · 邻家女孩 2.0 · 温柔淑女 2.0 | + +内置列表不够用时,也可以直接用声音复刻出来的音色 ID。 + +### 密钥纪律 + +API Key 只存在 `tts.env` 里,而且**只有技能里的一个接口包读它**。 +智能体调的是那个接口包,拿不到、也不需要读密钥的值: + +``` +你(手工填一次)→ tts.env(已被 .gitignore 忽略) + ↓ 只有接口包读 + 合成调用 ← 智能体只调这个,永远拿不到密钥值 + ↓ + 火山引擎 +``` + +- **不进对话**:问智能体密钥状态,它给你的是脱敏摘要(`ef90******86c8`)。 +- **不进仓库**:`.gitignore` 覆盖 `tts.env` / `*.env` / `*.key` / `*.pem` / `secrets/`; + 仓库自检里有专门的**密钥防线**,并且做过反向自证 —— 故意植入一个假密钥会被当场抓到。 +- **不进日志**:出错信息过脱敏,只抹「这次实际用到的密钥值」与 `AKLT…` 形态的 token —— + 刻意**不**用「长字符串就算密钥」这种宽规则,否则连排查要用的请求 ID 一起抹掉。 +- **不进分发**:技能打包时排除密钥文件。 +- **兜底层**(真正的安全边界):万一还是泄了,损失要可控 —— 建议在火山控制台用 + **子账号只授语音合成权限**、设**用量告警与限额**,密钥**可随时轮换**。 + +> 说句实话:合成代码是在你本机执行的,运行时进程里密钥对那一段脚本可见, +> **「智能体绝对读不到」做不到**。上面保证的是**不进对话、不进仓库、不进日志、不进分发**, +> 把泄露半径收敛成一次可撤销的事故。 +> +> 另外**别用环境变量存密钥** —— 很多宿主会把进程环境原样打进会话记录。 + +**别把 `tts.env.example` 改名成 `tts.env` 去填**:那是要留在仓库里的模板, +改名会让后来的人没模板可抄(仓库自检会报错并提示)。复制一份再填。 + +--- + ## 视频项目结构 说一句需求,智能体会在一个**视频项目目录**里走完整个流水线;产物落在 `out/`: @@ -152,7 +217,9 @@ CSS `transition` 入场)不可能工作,会被 `lint_frames.py` 直接拒绝 | | | |---|---| | **节拍锚定解说词** | 画面按**匹配字幕文本**定位动画(`B('块文本')`),绝不硬编码帧号。改解说词,全片自动重排时间,画面代码零改动。 | -| **词边界字幕** | 时序来自 edge-tts 的 `WordBoundary` 事件,不按字数插值 —— 中文里两个同字数的短语时长可以差 3 倍。 | +| **词边界字幕** | 时序来自 TTS 的**字级时间戳**,不按字数插值 —— 中文里两个同字数的短语时长可以差 3 倍。edge-tts 取 `WordBoundary`,火山引擎取 `sentence.words[]`。 | +| **两个配音引擎** | `edge-tts`(免费、免密钥、默认)或**火山引擎语音合成 2.0**(音质更好,需 API Key)。两者产出的 manifest 结构一致,下游零改动。切换用 `--provider`。 | +| **密钥不进 agent、不进仓库** | 火山 API Key 只存 `tts.env`(已 gitignore),**只有接口包读它** —— agent 只调 `tts_volcano.py`,拿不到也不需读密钥。异常信息脱敏;`check_integrity.py` 有专门的密钥防线检查;打包排除密钥文件。 | | **两级时钟** | 帧长用 MP3 **容器时长**(保音画同步);末块字幕按**语音真实结束**收尾。 | | **23 种画面风格** | 8 个类别,每种都记录了画布、字阶、时间轴与配色纪律。不用对着空白页从零设计。 | | **封面双方案** | 每条成片附带 16:9 与**独立重排**的 3:4 封面 —— 不是裁切,裁切会丢掉 57.8% 的画面宽度。 | @@ -166,8 +233,9 @@ CSS `transition` 入场)不可能工作,会被 `lint_frames.py` 直接拒绝 ```mermaid flowchart LR A["主题"] --> B["调研
每个数字带出处"] - B --> C["narration.json
竖线切字幕块"] - C --> D["tts_build.py
edge-tts → MP3 + 词边界"] + B --> C["narration.json
竖线切字幕块"] + C --> S["tts_setup.py
问 TTS 方案 · 配密钥 · 选音色"] + S --> D["tts_build.py
edge-tts / 火山引擎 → MP3 + 词边界"] D --> E["timeline_build.py
全局轴 + 拼接音轨"] E --> F["subs.py
subs.json · srt/vtt · beats.js"] F --> G["frames/*.html
一句一场景,风格取自风格库"] @@ -251,8 +319,13 @@ tl.fromTo('.verdict', { scale: 0.8 }, { scale: 1, duration: 0.6 }, | 封面出成 1 倍图 | 同上,`deviceScaleFactor` 的孪生坑 | 传 `newPage()` + `screenshot({ scale: 'device' })` | | 数字能渲染但永远不动 | seek 抑制了 `onUpdate` 回调 | 渲染器已修(`pause(t, false)`);帧里改用 transform 数字卷轴 | | Windows 上 `No such file or directory` | 非 ASCII 路径 —— Windows 的 ffmpeg 把 UTF-8 当 ANSI 读 | 路径保持 ASCII | +| 字幕开始飘 / 位置对不上 | TTS 没返回字级时间戳,`subs.py` 退回**按字数插值**(只有一行 stderr 警告) | 看 `tts_build` 有没有打 `⚠ 未返回字级时间戳`;换个受支持的音色(中英文 2.0) | +| 火山报 `resource ID is mismatched with speaker related resource` | `speaker` 收到了中文显示名而不是音色 ID;或复刻音色配了预置资源 ID | 音色用内置名/ID(接口包会解析);复刻音色配 `VOLC_RESOURCE_ID=seed-icl-2.0` | +| 火山报 `HTTP 401/403` | `tts.env` 里的 key 不对,或该服务未开通 | 核对 `VOLC_API_KEY`;`tts_setup.py --status` 看脱敏状态 | +| 火山报 `网络不可达` | 本包默认**绕过系统代理直连**(境内端点) | 确实需要代理时设 `VOLC_PROXY=http://127.0.0.1:` | +| 重跑后字幕整体晚一帧 | 命中缓存时丢掉了首裁量(历史 bug,已修) | 升级到 v1.3.0+;缓存格式已带 `lead_cut_sec` | -**`references/lessons.md` 是这个仓库里最值钱的文件。** 34 条编号记录,每一条都是一个 +**`references/lessons.md` 是这个仓库里最值钱的文件。** 56 条编号记录,每一条都是一个 「成片看着挺正常、其实是错的」的 bug —— 包括它一开始是怎么被误判的。从零开始 debug 之前, 先读它。 diff --git a/SKILL.md b/SKILL.md index 288d4d3..858133a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: html-explainer -version: 1.2.3 -description: 把任意主题做成「讲解/科普视频」并渲染成 MP4:调研→审查→解说词→字幕→edge-tts 配音→并行构建 HTML 场景→确定性逐帧渲染→成片后出双方案封面。**画面语言内置 23 个模板风格 / 8 个类别**(大胆信号卡、奢华极简、NYT 数据图表、瑞士网格、故障艺术、胶片漏光、流体 Hero、Logo 收尾、东方柔和有机、VFX 文字光标…共 23 种风格,含每种的画布/配色/字体/时间轴规范,见 references/style-catalog.md),流程规范与音画同步体系承自 anything2explainer(词边界字幕、两级时钟、语速标定、多 agent 分工与 QC 判据),渲染层为自研 seek 式渲染器。**封面双方案**:抖音主封面 1920×1080 + 兼容 3:4 的 1440×1080(独立重排,防主页栅格切字)。独立可移植:GSAP 内置、playwright-core 随包、ffmpeg 走 imageio-ffmpeg 回退、浏览器自动探测 Chrome/Edge;**不依赖 html-video / anything2explainer 任何代码或目录**。触发场景:要做科普/讲解/教学/知识/产品类视频、"讲一下 X 做成视频"、要用 html-video 那种模板化画面但更稳的音画同步、要挑某种视觉风格(极简/数据/赛博/电影感/品牌)出片、要出抖音封面/竖版封面、anything2explainer 换 HTML 渲染、或提到 html-explainer / HTML 讲解视频 / explainer video / MG 视频。 +version: 1.3.0 +description: 把任意主题做成「讲解/科普视频」并渲染成 MP4:调研→审查→解说词→字幕→配音(edge-tts,或火山引擎语音合成 2.0)→并行构建 HTML 场景→确定性逐帧渲染→成片后出双方案封面。**画面语言内置 23 个模板风格 / 8 个类别**(大胆信号卡、奢华极简、NYT 数据图表、瑞士网格、故障艺术、胶片漏光、流体 Hero、Logo 收尾、东方柔和有机、VFX 文字光标…共 23 种风格,含每种的画布/配色/字体/时间轴规范,见 references/style-catalog.md),流程规范与音画同步体系承自 anything2explainer(词边界字幕、两级时钟、语速标定、多 agent 分工与 QC 判据),渲染层为自研 seek 式渲染器。**封面双方案**:抖音主封面 1920×1080 + 兼容 3:4 的 1440×1080(独立重排,防主页栅格切字)。独立可移植:GSAP 内置、playwright-core 随包、ffmpeg 走 imageio-ffmpeg 回退、浏览器自动探测 Chrome/Edge;**不依赖 html-video / anything2explainer 任何代码或目录**。触发场景:要做科普/讲解/教学/知识/产品类视频、"讲一下 X 做成视频"、要用 html-video 那种模板化画面但更稳的音画同步、要挑某种视觉风格(极简/数据/赛博/电影感/品牌)出片、要出抖音封面/竖版封面、anything2explainer 换 HTML 渲染、或提到 html-explainer / HTML 讲解视频 / explainer video / MG 视频。 agent_created: true --- @@ -59,6 +59,7 @@ bash /setup_env.sh # 自检;--install 联网补装 ``` 依赖:Python≥3.9(edge-tts==7.2.8 钉死 / numpy / pillow / imageio-ffmpeg)、Node≥18、 +**火山引擎零额外依赖**(`tts_volcano.py` 只用标准库 `urllib`,不需要装任何 SDK)。 Chrome 或 Edge(几乎必有;都没有才下载 playwright chromium ~115MB)、ffmpeg(PATH 或 imageio-ffmpeg 静态二进制自动回退)。Windows 注意:项目路径全 ASCII;给 Node/Python 传 `C:/...` 正斜杠路径;别用 heredoc 给 Python 传正则。 @@ -68,6 +69,7 @@ imageio-ffmpeg 静态二进制自动回退)。Windows 注意:项目路径全 ```bash PY= # 派子 agent 时必须展开成绝对路径写进 prompt "$PY" /scripts/new_project.py --topic "主题" # 阶段 0 建项目 +"$PY" /scripts/tts_setup.py --project . # ★ 先定配音方案 + 音色(问用户:edge / 火山) "$PY" /scripts/tts_build.py --project . # 配音:audio/*.mp3 + manifest "$PY" /scripts/timeline_build.py --project . # 全局时间轴:layout.json + narration-full.mp3 "$PY" /scripts/subs.py --project . # 字幕:subs.json + beats.js + srt/vtt @@ -77,6 +79,12 @@ node /scripts/render_video.mjs . [--preview 30] [--keep-frames] [--only node /scripts/cover_build.mjs . # 封面双方案:out/cover_169.png + cover_34.png ``` +配音引擎(**跑之前必须先问用户**,见确认点 3):`edge`(默认,免费免密钥)或 +`volcano`(火山引擎语音合成 2.0,音质更好,需 API Key)。两者产出的 manifest 结构一致, +下游零改动。切换:`--provider edge|volcano` 或 `TTS_PROVIDER` 环境变量。 +**火山密钥只存 `tts.env`(已 gitignore)—— agent 只调 `tts_volcano.py`,不读该文件。** +详见 `references/volcano-tts.md`。 + 辅助工具(随时可用,不进主流水线): ```bash @@ -129,7 +137,7 @@ node /scripts/peek_frame.mjs . <帧id> --at 40,80 # 单帧速览:秒 |---|---| | 帧时长 | MP3 **容器时长**(tts_build 裁首尾静音后回填)。词边界时长每段少 ~0.86s,用它必错位 | | 末块字幕收尾 | 语音**真实结束**(speech_end_sec)—— 两级时钟,混用则「字幕过了语音还没过」 | -| 字幕节拍 | edge-tts **WordBoundary** 首字对帧号,绝不按字数插值(中文同字数时长差 3 倍) | +| 字幕节拍 | 词级时间戳首字对帧号,绝不按字数插值(中文同字数时长差 3 倍)。edge 取 `WordBoundary`,火山取 `sentence.words[]`(**需显式开 `audio_params.enable_subtitle`,本包默认开**;不开会静默退回插值) | | 画面节拍 | 场景 HTML 里 `B('块文本')` 取该词起播秒排 GSAP —— 画面与吐字同源 | | 渲染 | **确定性 seek**:`tl.pause(t, false)` + CSS 动画 `currentTime=t×1000` → 截图 → ffmpeg 合成。无实时录制,html-video 的引导期/起播/字体坑整类不存在。**第二参必须传 `false`**,少了它 `onUpdate` 类回调被静默抑制(数字滚动恒为初值,见 lessons #27) | | 字幕层/进度条 | 渲染器注入并逐帧驱动(`#mg-subs` 44px 白字黑边 bottom 96px;`#mg-progress` accent 填充),帧作者零负担 | @@ -140,7 +148,17 @@ node /scripts/peek_frame.mjs . <帧id> --at 40,80 # 单帧速览:秒 0. **建项目**(5 分钟):`new_project.py` + 定主题(`make_theme.py --topic/--preset … --use`,颜色全在 theme.css 的 CSS 变量里,画面代码禁止色值字面量) 1. **调研**(20 分钟,1 agent):research/调研.md,每个数字带 URL;**确认点 1**(时长/语言)并行问 -2. **解说词**(30 分钟):narration.json(`|` 切字幕块,中文 ≤16 字/块)→ 填 order → **确认点 2**(文案定稿)→ **确认点 3**(TTS 偏好)→ tts/timeline/subs 三连 → 核对时长区间(差 >15% 改句子,别改语速硬凑)→ **定稿后不改词** +2. **解说词**(30 分钟):narration.json(`|` 切字幕块,中文 ≤16 字/块)→ 填 order → **确认点 2**(文案定稿)→ **确认点 3**(配音方案 + 音色)→ tts/timeline/subs 三连 → 核对时长区间(差 >15% 改句子,别改语速硬凑)→ **定稿后不改词** + + **确认点 3 必须问两件事**(用 `tts_setup.py` 落实): + ① **方案** —— 「配音用 edge-tts(免费、免密钥、开箱可用)还是火山引擎语音合成 2.0 + (音质更好,需要 API Key,约 1 分钟配置)」; + ② **音色** —— 选定方案后列候选让用户挑,也可自定义 ID。 + 选火山时:缺 `tts.env` → `tts_setup.py` 生成空模板并**停下来**,让用户手工填密钥 + (**agent 不读该文件、不参与填值**);用户说填好了 → `--check` 测连接 → 再选音色。 + ★ 提醒用户用 `cp tts.env.example tts.env` **复制**,别把 `tts.env.example` **改名**成 + `tts.env` —— 那是要留在仓库里的模板(改名会让 `check_integrity.py` 报错)。 + 详见 `references/volcano-tts.md`。 3. **分镜(含选风格,20 分钟)**:script/storyboard.md,每场景一行 —— **先从风格库挑风格**(`references/style-catalog.md`,按「挑风格的实用建议」表匹配内容类型, 并核对时长档),再写画面/主角·尺寸/光/B() 锚点;末尾全局约束 @@ -185,7 +203,9 @@ GSAP 用 `../assets/gsap.min.js`(本地内置)→ 主体动画压在 speech_ | 路径 | 作用 | |---|---| | `scripts/new_project.py` | 阶段 0 脚手架:建目录树 + `project.json` + `narration.json` 占位 + `theme.css` + 两份封面 HTML | -| `scripts/tts_build.py` | edge-tts:缓存/超时/重试/裁静音,manifest 写两级时长 | +| `scripts/tts_setup.py` | **配音方案向导**:选 edge/火山 → 缺密钥则生成 `tts.env` 模板并停下 → 测连接 → 选音色 → 写回 project.json。输出 `NEXT_ACTION=…` 供 agent 判断下一步 | +| `scripts/tts_volcano.py` | **火山引擎语音合成 2.0 接口包**:唯一读 `tts.env` 的地方;`--check` / `--voices` / `--synth`。密钥不回显、异常脱敏(`_redact`) | +| `scripts/tts_build.py` | 配音合成:edge-tts **或** 火山引擎(`--provider`);缓存/硬超时/退避重试/裁静音,manifest 写两级时长。两引擎 manifest 结构一致 | | `scripts/timeline_build.py` | layout.json 全局轴 + narration-full.mp3(gap 显式插入) | | `scripts/subs.py` | 字幕三出口(subs.json / srt+vtt / 画面内层由渲染器注入)+ beats.js 节拍器 | | `scripts/lint_frames.py` | **渲染前静态体检**:八条契约违规逐条报(外链字体/色值字面量/墙钟/`B()\|\|N`/字幕带压内容/缺中文字体族…) | diff --git a/package_skill.py b/package_skill.py index f4d8d62..31125fb 100644 --- a/package_skill.py +++ b/package_skill.py @@ -36,10 +36,14 @@ EXCLUDE_DIRS = { "render", "research", "script", # 项目级产物(防在技能目录内建过 demo) } # 文件名 / 后缀排除 -EXCLUDE_SUFFIX = (".pyc", ".pyo", ".beats.js", ".mp4", ".mp3", ".wav", ".log") +EXCLUDE_SUFFIX = (".pyc", ".pyo", ".beats.js", ".mp4", ".mp3", ".wav", ".log", + ".env", ".key", ".pem") # 密钥类:绝不进可分发包 EXCLUDE_NAMES = { "package-lock.json", ".DS_Store", "Thumbs.db", "qc_sheet.jpg", "qc_report.md", ".gitignore", ".gitattributes", + # ★ 密钥文件。tts.env 是火山 TTS 的 API Key 唯一落点,打包进 zip 等于把 + # 用户的密钥随技能一起发出去。模板 tts.env.example 是入库的,不在此列。 + "tts.env", "secrets", } diff --git a/references/lessons.md b/references/lessons.md index 0fbd7ee..44cf50a 100644 --- a/references/lessons.md +++ b/references/lessons.md @@ -389,3 +389,108 @@ 验收口径(别只看渲染日志的「✓ 成片」):**帧号连续无缺口 + 成片时长 == layout 总长**。 本片收尾实测 6194 帧、0 缺帧、`Duration 00:03:26.47` 与 layout 的 206.47s 逐位一致。 + +46. **★(2026-09-23)「用户需要中断流程去手工做一件事」时,那个选择必须先落盘。** + 加火山 TTS 时踩的:向导让用户选方案 → 选火山 → 生成 `tts.env` 模板让用户去填密钥 → + **此时 `project.json` 还没写 `provider`**(原本要等全部步骤走完才写)→ 用户填完回来跑 + `--check`,脚本从 `project.json` 读到默认的 `edge`,于是**静默去测 edge 并报「✓ 就绪」**。 + 用户会以为密钥配好了。 + 规则:**只要流程中间存在「需要用户离开去手工完成」的断点,断点之前已经确定的状态就要立刻持久化**。 + 顺带把「我测的是哪个」显式打印出来(`测试方案:volcano`)—— 加了这一行,同类错位就不可能再静默。 + 更一般的判据:**任何带默认值的回退,都要问一句「这个默认值在什么情况下会冒充用户的真实选择」**。 + +47. **密钥只能用「单点接触」的接口包封装,且默认值不能替用户做安全决定。** + 本技能的密钥纪律是「agent 不读、仓库不记、日志不吐、分发不含」。让它成立的**不是自觉, + 是结构**:密钥只存在 `tts.env`,**只有 `tts_volcano.py` 读它**,agent 只调这个包。 + 四条各有对应机制: + - 不读 → 接口包自己读;agent 想知道状态就跑 `--status`(输出脱敏摘要) + - 不记 → `.gitignore` + `check_integrity.py` 的密钥防线检查(遍历仓库抓非空赋值与 `AKLT…`) + - 不吐 → 异常过 `_redact()`。**脱敏规则别写成「长字符串就当密钥」**:那会把 reqid/logid + 一起抹掉,排查时没有定位线索。只抹「本次实际加载的密钥值」+ 明确形态(`AKLT…`)即可, + 因为密钥只可能从这两个来源进入输出。 + - 不含 → `package_skill.py` 排除密钥文件(见 #48) + **必须对用户说清的实话**:合成代码是 agent 在本机执行的,运行时进程里密钥对脚本可见, + 「agent 绝对读不到」做不到。能保证的是**不进对话、不进仓库、不进日志、不进分发**, + 再加**最小权限子账号 + 用量告警 + 可随时轮换**把泄露半径收敛成一次可撤销的事故。 + ★ 附实证:**环境变量比凭据文件更不安全** —— 本机 `env` 的输出会被工具结果带进会话记录 + (早前 `env | grep proxy` 连 MCP 的 bearer token 都打出来过)。要存密钥就用 + 系统凭据管理器或仓库外的文件,**别用环境变量**。 + +48. **加了新的敏感文件类型,要同时检查「打包/分发」与「gitignore」两条路 —— 它们不共享规则。** + `package_skill.py` 的排除表原先只有仓库卫生文件,**没有 `.env`**。 + 也就是说:技能目录里一旦放了 `tts.env`,`package_skill.py` 会把它打进可分发的 zip, + 用户把包转给别人 = 把 API Key 一起发出去。gitignore 挡不住这条(打包不看 gitignore)。 + 修完必须**实测**而不是只看代码:往技能目录放一个假 `tts.env`,打包, + 再开 zip 列表 + 对每个成员做**内容级**扫描(`b'AKLT...' in z.read(n)`)。 + 验收三条:密钥文件不在包里、模板 `tts.env.example` 仍在包里、内容扫描无命中。 + +49. **「非 0 的状态码 = 错误」是错的 —— 先查清楚协议里的成功/结束码各是什么。** + 火山 V3 单向流式的响应是 NDJSON,音频块 `code=0`,而**结束标记是 `code=20000000` 且 `data=null`**。 + 社区记录里有人只判 `code===0` 收音频,症状是「合并出来的音频末尾嘎然而止」——**最后一段丢了**, + 而 HTTP 200、日志一切正常。本包按 `code` 显式区分三种:`0` 收音频 / `20000000` 结束 / + 其他抛错,并且**离线断言过**(本地假服务端按官方响应结构回包,断言音频字节数、 + 时间戳换算、请求头与请求体字段)。这类「协议细节只听文档会错」的地方, + 花十分钟搭个假服务端做断言,比拿真 key 反复试便宜得多。 + +50. **对外接口的「同构」是省事的关键 —— 适配新引擎时先看能不能让下游零改动。** + 火山 TTS 有五种接入方式(WS 双向流式 / WS 单向流式 / HTTP Chunked / SSE / 已弃用的 v1), + 选的是 **HTTP Chunked 单向流式**,唯一理由是它和 edge-tts **形态同构**:发一次文本、收一串音频。 + 于是缓存 / 硬超时 / 退避重试 / 裁静音 / 两级时钟**一行没改**, + 两引擎产出的 manifest 结构完全一致,`timeline_build.py` / `subs.py` / 渲染器零改动。 + 双向流式是为 LLM 实时对话设计的(可打断、多轮 session 状态机),离线出片要它纯属负担。 + ★ 判断依据要落在**数据契约**上而不只是传输方式:这里真正的关键不是「怎么发」, + 而是「词级时间戳拿不拿得到」——火山 `sentence.words[]` 给字级 `startTime`/`endTime`(秒), + 正好等价于 edge 的 `WordBoundary`,**不需要「强制对齐」那一步** + (注意:**前提是显式开 `audio_params.enable_subtitle`**,见第 51 条)。 + 接新引擎前先确认这一点;拿不到词级时间戳就**别接**——按字数插值是这个技能明确禁止的退化。 + +51. **★(2026-09-23)官方文档示例里「有值」的字段,不等于你不传参数它就会返回。** + 火山文档的响应示例里 `sentence.words[]` 带着完整的字级时间戳,很容易让人以为「默认就给」。 + 实测:**不传 `audio_params.enable_subtitle: true`,`words` 恒为空数组**(同文本对比 + false→`words=0`,true→`words=10`)。这个坑的可怕之处在于**它完全不报错**: + 音频正常、时长正常、`sentence.text` 也正常,只有时间戳是空的。 + 下游会静默退化——`subs.py` 发现没有词边界就**按字数插值**(只打一行 stderr 警告), + 成片照样能出,字幕却开始飘。**判据:凡是「文档示例里看着有值」的字段, + 都要用一个显式关掉参数的对照组实测一次**,别接受单边证据。 + 顺带:该开关仅豆包 2.0 的中英文音色支持,所以本包在拿到音频却没有时间戳时 + 会往结果里塞 `warning`,让 `tts_build.py` 能把它打到台面上,而不是安静地飘字幕。 + +52. **计费/统计字段常挂在「流结束」那一行上 —— 别在结束标记处提前 `continue`。** + 同一个坑的第二层:`usage.text_words` 就挂在 `code=20000000`(结束标记)那一行。 + 解析循环写成「先判结束标记 → `continue` → 再读其他字段」, + 结果**计费字数永远是 0**,而请求本身完全正常。加上 `X-Control-Require-Usage-Tokens-Return: *` + 这个头之后依然读到 0 —— 因为问题不在服务端给不给,而在**我们把那一行跳过了**。 + 症状极易被误判成「是不是免费额度?」。**顺序原则:一行的所有字段先全部读完, + 再决定这行要不要跳过它的「主语义」**。修完实测:`计费 20 字` 与文本长度逐字对上。 + +53. **缓存里必须连「裁剪量」一起存 —— 否则首跑与重跑产出的 manifest 不一致。** + 词边界是**原始坐标系**(合成完那一刻的时间),下游 `subs.py` 靠 `lead_cut_sec` 平移。 + 缓存只存了边界数组、命中缓存时把首裁量当 0,于是**重跑的 `lead_cut_sec=0`、首跑是 0.037** + ——字幕整体晚一帧。这类 bug 只在「第二次跑」出现,而第二次跑恰恰是最常发生的场景。 + 修法:缓存改成 `{"boundaries": [...], "lead_cut_sec": x}`,并对旧版**裸列表**格式做兼容读取。 + 验收必须是**深度 diff**(见 55)。 + +54. **★ 别把仓库里的 `*.example` 模板「改名」成真文件 —— 模板会消失,而其他用户再也配不起来。** + 实证:用户把 `tts.env.example` 改名成 `tts.env` 去填 key。功能上没问题(gitignore 也确实 + 挡住了它入库),但**仓库里少了模板**,`check_integrity.py` 的密钥防线当场报错。 + 这类错之所以常见,是因为「复制」和「改名」在资源管理器里只差一个字。 + **两道改进**:① 模板开头直接写清「请复制,不要改名」; + ② 校验脚本在「缺模板」时**再看一眼同目录有没有真文件**,有就直说「疑似改名」—— + 错误信息能指向原因,比只报「缺文件」有用得多。 + +55. **★ 验证脚本本身要先 sanity check ——「没发现差异」可能是比较器根本没比到。** + 验证「首跑 vs 重跑一致」时,第一版 diff 把 `items` 这个**列表**当成不透明值 + (只记 ``),于是 982 个字段里一个都没深入比较,第二轮打印出 + 「✓ 两次 manifest 完全一致」。而同一时刻终端上明明显示首裁 `0.037s` vs `0.000s`。 + **两个互相矛盾的证据同时出现时,先怀疑那个「更省事」的结论**。 + 改成递归进入列表后,立刻暴露 3 处不一致。 + ★ 通用规则:**验证器要能被证伪** —— 先拿一个已知有差异的输入试一次, + 看它会不会报差异;会报,才有资格说「一致」。 + +56. **「名称 / 序号 / 原始 ID」的解析必须收在接口包里,不能让调用方各写一份。** + 火山 CLI 的 `--voice "云舟 2.0"` 把中文显示名**原样当成 speaker 发出去**, + 服务端拒了,报的是 `55000000 resource ID is mismatched with speaker related resource` + ——完全看不出「其实是名字没解析」。修法是把 `resolve_voice_token()` 放进接口包, + `tts_setup.py` 改为委托它,语速换算(`parse_rate_percent`)同理。 + **判据:凡是「用户会手输、而协议只认机器格式」的字段(音色名、语速、路径), + 解析逻辑有且只能有一份,且放在离协议最近的那一层。** 两份实现必然慢慢跑偏。 diff --git a/references/volcano-tts.md b/references/volcano-tts.md new file mode 100644 index 0000000..313a8a8 --- /dev/null +++ b/references/volcano-tts.md @@ -0,0 +1,195 @@ +# 火山引擎语音合成 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`) | +| `HTTP 404` | 音色与资源 ID 不匹配 —— 预置音色要配 `seed-tts-2.0` | +| `网络不可达` | 本包默认直连;若确实需要代理,设 `VOLC_PROXY=http://127.0.0.1:` | +| 音频末尾「嘎然而止」 | 结束标记 `code=20000000` 的处理问题(本包已处理;自研代码要留意) | +| 字幕与音频错位 | 检查 `sentence.words[]` 是否被正确映射成 `offset_sec`(单位是**秒**) | +| 想确认测的到底是哪个引擎 | `--check` 会先打印 `测试方案:` | diff --git a/references/workflow-guide.md b/references/workflow-guide.md index 6df2f7a..8d65547 100644 --- a/references/workflow-guide.md +++ b/references/workflow-guide.md @@ -44,15 +44,24 @@ bash /setup_env.sh # 首台机器先跑一次 text 内用 `|` 切字幕块(中文每块 ≤16 字,切点落在语义边界) 2. 填 `project.json` 的 `order`(= id 顺序,可选 `chapters: [{title, startSegment}]`) 3. **确认点 2**:全文 + 章节划分 + 字数/预估时长贴给用户 -4. **确认点 3**:问一句「配音有偏好的 TTS 吗?」默认 edge-tts 云希 +8% -5. 跑流水线(顺序不能乱): +4. **确认点 3 —— 配音方案与音色,必须问两件事**: + ① 方案:「edge-tts(免费、免密钥、开箱可用)还是火山引擎语音合成 2.0(音质更好,需 API Key)」 + ② 音色:用 `--list-voices` 列候选让用户挑,也接受自定义音色 ID + +5. 跑设置向导 + 流水线(顺序不能乱): ```bash +"$PY" /scripts/tts_setup.py --project . # 定方案 + 音色;缺密钥会生成 tts.env 模板并停下 "$PY" /scripts/tts_build.py --project . "$PY" /scripts/timeline_build.py --project . "$PY" /scripts/subs.py --project . ``` + **选火山时的正确停顿姿势**:向导生成 `tts.env` 后退出码 2、打印 `NEXT_ACTION=fill_env` + → 告诉用户「请打开这个文件填 API Key,填好告诉我」→ **agent 不要读该文件、不要代填** + → 用户说填好了 → `tts_setup.py --project . --check` 测连接 → 再选音色。 + 密钥纪律与排查见 `references/volcano-tts.md`。 + 6. 核对成片时长落在用户要的区间(差 >15% 加/删句子重跑,别改语速硬凑) 7. **定稿后不再改词**(帧时长与节拍全部随配音重排) diff --git a/scripts/check_integrity.py b/scripts/check_integrity.py index e4387f3..f5aeee3 100644 --- a/scripts/check_integrity.py +++ b/scripts/check_integrity.py @@ -14,6 +14,9 @@ check_integrity.py —— 仓库自洽性检查(CI 与本地都能跑,零第 —— 授权信息缺失会让 THIRD_PARTY_NOTICES 失真。 5. 帧模板 / 封面模板不得出现外链字体或 CDN 资源 —— 离线渲染硬要求,见 frame-contract.md 契约 2。 + 6. 密钥防线:.gitignore 必须忽略 tts.env / *.env / *.key,且仓库内不得出现 + 真实的火山引擎密钥(非空赋值 / AKLT 形态 token) + —— 火山 TTS 是本技能唯一需要密钥的功能,泄露即永久失守。 用法: python scripts/check_integrity.py # 以脚本所在技能的根目录为准 @@ -159,6 +162,75 @@ def check_no_remote_assets(root: str, fails: list[str]) -> None: print(f"{OK} 模板无外链资源:{len(targets)} 个模板检查通过") +def check_no_secrets(root: str, fails: list[str]) -> None: + """密钥防线:tts.env 必须被忽略,且仓库里不得出现真实密钥。 + + 对应 2026-09-23 加入的火山引擎 TTS 支持 —— 那是本技能唯一需要密钥的功能。 + 密钥纪律是「agent 不读、仓库不记」,所以这里做两道自动化检查: + a) .gitignore 覆盖 tts.env / *.env / *.key,且 tts.env.example 模板仍被跟踪; + b) 草签文件里不得出现非空的密钥赋值或 AKLT 形态的 token。 + """ + gi = os.path.join(root, ".gitignore") + if os.path.isfile(gi): + text = read(gi) + need = ["tts.env", "*.env", "*.key"] + missing = [n for n in need if n not in text] + if missing: + fails.append(f"secret: .gitignore 未忽略 {', '.join(missing)}" + f" —— 密钥可能被提交进公开仓库") + else: + print(f"{OK} 密钥防线:.gitignore 已忽略 tts.env / *.env / *.key") + else: + fails.append("secret: 没有 .gitignore —— 密钥无保护") + + example = os.path.join(root, "tts.env.example") + if not os.path.isfile(example): + if os.path.isfile(os.path.join(root, "tts.env")): + # 实证过这种踩法:用户把仓库里的 tts.env.example **改名**成 tts.env 去填 key, + # 于是模板没了、仓库里多了一份(被忽略的)密钥文件。给个能指向原因的说法。 + fails.append("secret: 缺 tts.env.example 模板,但同目录有 tts.env —— " + "疑似把模板文件改名成了 tts.env。请恢复 tts.env.example" + "(模板要留在仓库),tts.env 另行复制") + else: + fails.append("secret: 缺 tts.env.example 模板(用户没有可照抄的密钥模板)") + + # 真实密钥形态:非空赋值 / AKLT 前缀 + SECRET_ASSIGN = re.compile( + r"^\s*(VOLC_API_KEY|VOLC_ACCESS_KEY|X_Api_Key|X-Api-Key)\s*[=:]\s*[\"']?([^\s\"'#]+)", + re.M) + AKLT = re.compile(r"AKLT[A-Za-z0-9_\-]{8,}") + SKIP_DIRS = {".git", "node_modules", "__pycache__", "dist", ".venv", "venv"} + SKIP_EXT = {".mp4", ".mp3", ".wav", ".png", ".jpg", ".jpeg", ".ttf", ".otf", + ".woff", ".woff2", ".zip", ".ico"} + hits: list[str] = [] + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + for fn in filenames: + if os.path.splitext(fn)[1].lower() in SKIP_EXT: + continue + p = os.path.join(dirpath, fn) + rel = os.path.relpath(p, root) + # tts.env 本体跳过:它是用户本地文件,本检查只保证它不入库 + if fn in ("tts.env",) or rel in ("tts.env",): + continue + try: + body = read(p) + except (UnicodeDecodeError, OSError): + continue + for m in AKLT.finditer(body): + hits.append(f"{rel}: AKLT 形态 token") + if fn.endswith(".example"): + continue + for m in SECRET_ASSIGN.finditer(body): + if m.group(2).strip(): # 有值才算泄露,空模板是合规的 + hits.append(f"{rel}: {m.group(1)} 有非空值") + if hits: + for h in sorted(set(hits)): + fails.append(f"secret: 疑似真实密钥 —— {h}") + else: + print(f"{OK} 密钥防线:仓库内未发现真实密钥(模板里的空赋值不算)") + + def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--root", default=None, help="仓库根目录(默认 = 本脚本所在技能的根)") @@ -173,6 +245,7 @@ def main() -> int: count = check_catalog(root, fails) check_catalog_count(root, count, fails) check_no_remote_assets(root, fails) + check_no_secrets(root, fails) print("-" * 40) if fails: diff --git a/scripts/new_project.py b/scripts/new_project.py index 4527ca0..fa75ace 100644 --- a/scripts/new_project.py +++ b/scripts/new_project.py @@ -59,6 +59,9 @@ def main() -> int: "order": [], # 写完 narration.json 后把 id 按顺序填进来 "theme": "violet", "progress": True, + # 配音引擎:edge = 免费免密钥;volcano = 火山引擎语音合成 2.0(需 tts.env)。 + # ★ 建项目后先问用户用哪个 —— python /scripts/tts_setup.py --project . + "provider": "edge", "voice": "zh-CN-YunxiNeural" if args.lang == "zh" else "en-US-GuyNeural", "rate": "+8%", } diff --git a/scripts/timeline_build.py b/scripts/timeline_build.py index b2df9fc..aed5630 100644 --- a/scripts/timeline_build.py +++ b/scripts/timeline_build.py @@ -54,6 +54,14 @@ def main() -> int: 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) diff --git a/scripts/tts_build.py b/scripts/tts_build.py index ed47182..98c221e 100644 --- a/scripts/tts_build.py +++ b/scripts/tts_build.py @@ -1,7 +1,19 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -tts_build.py —— html-explainer 的配音与时间轴数据源(edge-tts)。 +tts_build.py —— html-explainer 的配音与时间轴数据源。 + +【两个 TTS 引擎】`--provider` / 环境变量 TTS_PROVIDER / project.json 的 provider 字段 +(优先级从左到右)。**跑之前先问用户用哪个**(SKILL.md 确认点 3): + + edge 默认。edge-tts 云端合成,免费、免密钥、有词级边界。 + VOICE 默认 zh-CN-YunxiNeural(云希)。 + volcano 火山引擎(豆包)语音合成 2.0。音质更好,**需要 API Key**。 + 密钥由 scripts/tts_volcano.py 从 tts.env 读取 —— 本文件从不接触密钥值。 + 音色默认 zh_male_m191_uranus_bigtts(云舟 2.0)。 + 设置向导:python /scripts/tts_setup.py --project . + ★ 两个引擎产出的 manifest 结构完全一致(boundaries 都是「秒 + 原始坐标系」), + timeline_build.py / subs.py / 渲染器零改动。 【血统与口径】工程化外壳移植自 anything2explainer 的 tts_build.py(经 html-video-workbuddy-driver 的 tts_edges.py 二次实战),核心五件事: @@ -10,7 +22,7 @@ html-video-workbuddy-driver 的 tts_edges.py 二次实战),核心五件事 ② 每次合成 asyncio.wait_for 硬超时 45s —— edge-tts 的 websocket 会 偶发**永久挂起**(不抛错不返回零日志),必须把挂死变成可重试失败 ③ 指数退避重试 4 次(微软端点连续请求会限流抛 NoAudioReceived) - ④ 句间 0.8s 间隔 + 磁盘缓存(sig 含 boundary 与 trim —— 参数变了旧缓存自动失效) + ④ 句间 0.8s 间隔 + 磁盘缓存(sig 含 engine/boundary 与 trim —— 参数变了旧缓存自动失效) ⑤ 默认裁首尾静音(首回退 0.03s 保爆破音、尾保留 0.12s 保收音), 裁完段间停顿才由 GAP 显式控制 @@ -19,6 +31,8 @@ html-video-workbuddy-driver 的 tts_edges.py 二次实战),核心五件事 按字数插值。必须显式传 boundary='WordBoundary'。 2) 相邻数字熔成大数(「沪深300」「4479.55点」→「三百万四千…」), 数字与数字之间补中文逗号。 + ※ 数字补逗号只在 edge 引擎做:它会改动送进去的文本,从而影响词边界与解说词的 + 逐字对齐。火山引擎不熔读,原样送原文(对齐最稳)。 时长两级口径(manifest 同时写两个,下游各取所需): container_duration_sec 裁完后 MP3 容器时长 → **帧时长用这个**(音画同步) @@ -26,7 +40,8 @@ html-video-workbuddy-driver 的 tts_edges.py 二次实战),核心五件事 duration_sec 词边界末尾(原始坐标系)→ 仅排查用 用法(项目目录内): - python tts_build.py --project . [--rate +8%] [--voice zh-CN-YunxiNeural] [--no-trim] + python tts_build.py --project . [--provider edge|volcano] [--rate +8%] + [--voice zh-CN-YunxiNeural] [--no-trim] 产出: audio/.mp3 + audio-manifest.json """ @@ -44,6 +59,9 @@ import sys import edge_tts +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import tts_volcano # noqa: E402 + DEFAULT_VOICE_ZH = "zh-CN-YunxiNeural" # 云希,男声 DEFAULT_VOICE_EN = "en-US-GuyNeural" DEFAULT_RATE = "+8%" @@ -52,6 +70,8 @@ DEFAULT_PITCH = "+0Hz" DEFAULT_TIMEOUT = 45.0 DEFAULT_RETRIES = 4 RETRY_SLEEP = 0.8 +VOLCANO_TIMEOUT = 60.0 +VOLCANO_RETRIES = 3 TRIM_HEAD_BACKOFF = 0.03 TRIM_TAIL_KEEP = 0.12 @@ -152,8 +172,18 @@ def trim_edges(path: str, sr: int = 48000) -> float: return round(a / sr, 3) -def cache_paths(text, voice, rate, volume, pitch, cache_dir, trim): - sig = f"{voice}|{rate}|{volume}|{pitch}|{_boundary_kwarg()}|{int(trim)}|{text}" +def _rate_to_int(rate) -> int: + """'+8%' / '8' / 0.08 / 8 → 8。火山引擎的 speech_rate 是整数百分比([-50,100])。 + + 实现在 tts_volcano.parse_rate_percent —— 语速换算只保留一份, + tts_setup 做格式清洗时用的是同一份逻辑。 + """ + return tts_volcano.parse_rate_percent(rate) + + +def cache_paths(text, voice, rate, volume, pitch, cache_dir, trim, provider="edge"): + sig = (f"{provider}|{voice}|{rate}|{volume}|{pitch}|" + f"{_boundary_kwarg() if provider == 'edge' else 'chars'}|{int(trim)}|{text}") h = hashlib.sha1(sig.encode("utf-8")).hexdigest()[:16] d = os.path.join(cache_dir, "trim") if trim else cache_dir os.makedirs(d, exist_ok=True) @@ -161,21 +191,41 @@ def cache_paths(text, voice, rate, volume, pitch, cache_dir, trim): async def synth(text, out_path, voice, rate, volume=DEFAULT_VOLUME, pitch=DEFAULT_PITCH, - cache_dir="", timeout=DEFAULT_TIMEOUT, retries=DEFAULT_RETRIES, trim=True) -> dict: + cache_dir="", timeout=None, retries=None, trim=True, + provider="edge", project_dir="") -> dict: + """合成一段。provider='edge' 走 edge-tts,'volcano' 走火山引擎(密钥由接口包自理)。""" os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True) - spoken = sanitize_for_tts(text) + if provider == "volcano": + # 火山不熔读相邻数字 → 原样送原文,词边界与解说词逐字对齐最稳 + spoken = text + timeout = VOLCANO_TIMEOUT if timeout is None else timeout + retries = VOLCANO_RETRIES if retries is None else retries + else: + spoken = sanitize_for_tts(text) + timeout = DEFAULT_TIMEOUT if timeout is None else timeout + retries = DEFAULT_RETRIES if retries is None else retries cache_mp3 = cache_js = "" if cache_dir: - cache_mp3, cache_js = cache_paths(text, voice, rate, volume, pitch, cache_dir, trim) + cache_mp3, cache_js = cache_paths(text, voice, rate, volume, pitch, cache_dir, + trim, provider=provider) if os.path.exists(cache_mp3) and os.path.exists(cache_js): - bounds = json.load(open(cache_js, encoding="utf-8")) + cached = json.load(open(cache_js, encoding="utf-8")) + # 缓存里必须连「首裁了多少」一起存。词边界是原始坐标系,下游 subs.py 靠 + # lead_cut_sec 平移;命中缓存时若把它当 0,重跑出来的字幕会比首跑晚约一帧 + # (实测 0.037s)。见 lessons.md #51。 + if isinstance(cached, dict): + bounds = cached.get("boundaries") or [] + head_cut = float(cached.get("lead_cut_sec") or 0.0) + else: # 旧版缓存是裸列表,没有首裁记录 + bounds = cached + head_cut = 0.0 with open(cache_mp3, "rb") as f: open(out_path, "wb").write(f.read()) dur = bounds[-1]["offset_sec"] + bounds[-1]["duration_sec"] if bounds else 0.0 - return _result(text, spoken, out_path, voice, rate, bounds, dur, 0.0, trim) + return _result(text, spoken, out_path, voice, rate, bounds, dur, head_cut, trim) - async def _once(): + async def _once_edge(): kw = {} b = _boundary_kwarg() if b != "n/a": @@ -196,10 +246,28 @@ async def synth(text, out_path, voice, rate, volume=DEFAULT_VOLUME, pitch=DEFAUL raise RuntimeError("empty audio stream") return bytes(audio), bounds + # 接口包在「要了字幕却没拿到时间戳」时会返回 warning;必须往上抛, + # 否则下游只能看到「没有词边界」,排查时不知道是音色不支持还是接口出问题。 + warns: list[str] = [] + + async def _once_volcano(): + # 同步 HTTP 调用丢到线程里;接口包负责读 tts.env、脱敏、切片与重试 + r = await asyncio.to_thread( + tts_volcano.synthesize, spoken, voice, _rate_to_int(rate), + "mp3", 24000, 160000, timeout, 1, None, project_dir) + if r.get("warning"): + warns.append(str(r["warning"])) + return r["audio"], r["boundaries"] + + _once = _once_volcano if provider == "volcano" else _once_edge + last: Exception | None = None for attempt in range(retries): try: - audio, bounds = await asyncio.wait_for(_once(), timeout=timeout) + audio, bounds = await asyncio.wait_for(_once(), timeout=timeout + 15) + for w in warns: + print(f" ⚠ {w}", file=sys.stderr) + warns.clear() with open(out_path, "wb") as f: f.write(audio) await asyncio.sleep(RETRY_SLEEP) @@ -207,16 +275,27 @@ async def synth(text, out_path, voice, rate, volume=DEFAULT_VOLUME, pitch=DEFAUL if cache_dir: with open(cache_mp3, "wb") as f: f.write(open(out_path, "rb").read()) - json.dump(bounds, open(cache_js, "w", encoding="utf-8"), ensure_ascii=False) + json.dump({"boundaries": bounds, "lead_cut_sec": round(head_cut, 4)}, + open(cache_js, "w", encoding="utf-8"), ensure_ascii=False) dur = bounds[-1]["offset_sec"] + bounds[-1]["duration_sec"] if bounds else 0.0 return _result(text, spoken, out_path, voice, rate, bounds, dur, head_cut, trim) except Exception as e: # noqa: BLE001 last = e print(f" [retry {attempt + 1}/{retries}] {type(e).__name__}: {e}", file=sys.stderr, flush=True) + if getattr(e, "retryable", True) is False: + break # 永久性错误(密钥/音色/参数),重试是白等 await asyncio.sleep(1.5 * (attempt + 1)) - raise SystemExit( - f"edge-tts 连续 {retries} 次失败:{type(last).__name__}: {last}\n" - f"已合成段落走缓存不会重复耗时,稍后重跑即可。") + label = "火山引擎" if provider == "volcano" else "edge-tts" + if getattr(last, "retryable", True) is False: + # 永久性错误(密钥/音色/参数):只试了一次就别谎报「连续 N 次失败」 + detail = f"{label}:{last}" + else: + detail = f"{label} 连续 {attempt + 1} 次失败:{type(last).__name__}: {last}" + if provider == "volcano": + # 抛普通异常(不是 SystemExit):SystemExit 不是 Exception 子类, + # 会被 run() 里给火山准备的友好提示漏掉,用户就只看到一串原始报错。 + raise tts_volcano.VolcanoTTSError(detail) + raise SystemExit(detail + "\n已合成段落走缓存不会重复耗时,稍后重跑即可。") def _result(text, spoken, out_path, voice, rate, bounds, dur, head_cut, trimmed) -> dict: @@ -251,18 +330,39 @@ async def run(args) -> int: if isinstance(narration, dict): narration = narration.get("items") or [] - voice = args.voice or pj.get("voice") or DEFAULT_VOICE_ZH - rate = args.rate or pj.get("rate") or DEFAULT_RATE + # 引擎优先级:命令行 --provider > 环境变量 TTS_PROVIDER > project.json + provider = (args.provider or os.environ.get("TTS_PROVIDER") + or pj.get("provider") or "edge").strip().lower() + if provider not in ("edge", "volcano"): + print(f"✗ 未知 provider:{provider}(只支持 edge / volcano)", file=sys.stderr) + return 1 + + if provider == "volcano": + voice = args.voice or pj.get("voice") or tts_volcano.DEFAULT_VOICE + rate = args.rate or pj.get("rate") or 0 + else: + voice = args.voice or pj.get("voice") or DEFAULT_VOICE_ZH + rate = args.rate or pj.get("rate") or DEFAULT_RATE + out_dir = os.path.join(proj, "audio") cache = os.path.join(out_dir, "cache") results = [] + print(f"[tts] 引擎 {provider} · 音色 {voice} · 语速 {rate}", file=sys.stderr) for item in narration: key, text = item["id"], item["text"] - r = await synth(text, os.path.join(out_dir, f"{key}.mp3"), - voice=item.get("voice", voice), rate=item.get("rate", rate), - cache_dir="" if args.no_cache else cache, - trim=not args.no_trim) + try: + r = await synth(text, os.path.join(out_dir, f"{key}.mp3"), + voice=item.get("voice", voice), rate=item.get("rate", rate), + cache_dir="" if args.no_cache else cache, + trim=not args.no_trim, provider=provider, project_dir=proj) + except Exception as e: # noqa: BLE001 + if provider == "volcano": + print(f"✗ [{key}] 火山引擎合成失败:\n{e}\n" + f" 排查:python /scripts/tts_setup.py --project . --check", + file=sys.stderr) + return 1 + raise r["id"] = key results.append(r) print(f" [{key}] {r['bytes']:>8}B 容器 {r['container_duration_sec']:>7.3f}s " @@ -270,10 +370,11 @@ async def run(args) -> int: f"bounds={r['boundary_count']}", file=sys.stderr) manifest = { - "voice": voice, "rate": rate, + "provider": provider, "voice": voice, "rate": rate, "trimmed": not args.no_trim, - "engine": f"edge-tts {edge_tts.__version__}", - "boundary_mode": _boundary_kwarg(), + "engine": ("volcano doubao-tts-2.0 (seed-tts-2.0)" if provider == "volcano" + else f"edge-tts {edge_tts.__version__}"), + "boundary_mode": _boundary_kwarg() if provider == "edge" else "char-timestamps", "items": results, } mp = os.path.join(proj, "audio-manifest.json") @@ -285,8 +386,10 @@ async def run(args) -> int: def main() -> int: - p = argparse.ArgumentParser(description="html-explainer 配音合成(edge-tts)") + p = argparse.ArgumentParser(description="html-explainer 配音合成(edge-tts / 火山引擎)") p.add_argument("--project", default=".") + p.add_argument("--provider", default="", choices=["", "edge", "volcano"], + help="edge=免费免密钥(默认);volcano=火山引擎语音合成 2.0(需 tts.env)") p.add_argument("--voice", default="") p.add_argument("--rate", default="") p.add_argument("--no-trim", action="store_true", help="不裁首尾静音(不建议)") diff --git a/scripts/tts_setup.py b/scripts/tts_setup.py new file mode 100644 index 0000000..a1f1c7f --- /dev/null +++ b/scripts/tts_setup.py @@ -0,0 +1,335 @@ +#!/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 /scripts/tts_build.py --project .") + else: + print(" 下一步:python /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()) diff --git a/scripts/tts_volcano.py b/scripts/tts_volcano.py new file mode 100644 index 0000000..d6f7e59 --- /dev/null +++ b/scripts/tts_volcano.py @@ -0,0 +1,598 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +tts_volcano.py —— 火山引擎(豆包)语音合成 2.0 接口包。 + +【为什么单独成一个包】密钥只在这一个文件里被读取,调用方(含 AI agent) +只传「文本 + 音色 + 语速」,永远拿不到也不需要密钥值。本包保证: + + ★ 不打印、不记录、不回显密钥;异常信息一律脱敏(见 _redact)。 + ★ 不把密钥写进任何文件;只读 tts.env,从不写 tts.env。 + ★ 上游返回体里也没有密钥,缓存与 manifest 里也不会出现密钥。 + → agent 只需要会调用本包,不需要(也不应该)读 tts.env。 + +【接口】与 tts_build.py 的 edge 引擎同构,下游 timing 逻辑零改动: + synthesize(text, voice, rate=0, ...) -> { + "audio": bytes, # mp3 字节 + "boundaries": [{"text","offset_sec","duration_sec"}], # 原始坐标系 + "usage": {"text_words": int} | {}, + "requests": int, # 实际请求数(长文本会切分) + } + ★ boundaries 的时间单位是**秒**,坐标系 = 返回音频的起点, + 与 edge-tts 的 WordBoundary 语义一致(下游 subs.py 按 offset_sec - lead_cut 平移)。 + +【接口规格】(2026-09 核对官方文档) + POST https://openspeech.bytedance.com/api/v3/tts/unidirectional + Header: X-Api-Key(必选)/ X-Api-Resource-Id(必选,TTS2.0 = seed-tts-2.0) + / 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}}} + 响应: NDJSON,逐行 JSON;音频块 code=0 + data(base64); + **sentence.words[] 直接给字级时间戳**(word / startTime / endTime / confidence,单位秒); + 结束标记 code=20000000 且 data=null(★ 不是 code=0,误判会丢最后一段音频)。 + +【本机注意】openspeech.bytedance.com 是境内端点,默认**绕过系统代理**直连 +(本机常驻代理会让境内域名绕远甚至失败)。要强制走代理时设 VOLC_PROXY。 + +用法: + python tts_volcano.py --check # 连通性自检(用一句话试合成) + python tts_volcano.py --voices # 列出内置常用音色 + python tts_volcano.py --synth "测试文本" out.mp3 # 手动合成一段 + python tts_volcano.py --env-file <路径> ... # 指定 tts.env(默认自动探测) +被 tts_build.py 作为库调用(provider=volcano 时)。 +""" +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request +import uuid + +ENDPOINT = "https://openspeech.bytedance.com/api/v3/tts/unidirectional" +DEFAULT_RESOURCE_ID = "seed-tts-2.0" # TTS 2.0 预置音色 +CLONE_RESOURCE_ID = "seed-icl-2.0" # 声音复刻音色 +END_CODE = 20000000 # ★ 结束标记;不是 0 +MAX_TEXT_LEN = 200 # 单请求文本上限(超了按标点切分) +DEFAULT_TIMEOUT = 60.0 +DEFAULT_RETRIES = 3 +RETRY_SLEEP = 1.0 + +# 音色 ID 末尾都是 _uranus_bigtts = 豆包语音合成模型 2.0 +DEFAULT_VOICE = "zh_male_m191_uranus_bigtts" # 云舟 2.0 +ENV_FILENAME = "tts.env" + +# 内置常用音色(名称, voice_type, 说明)。完整列表见官方音色文档。 +VOICES: list[tuple[str, str, str]] = [ + # —— 男声:解说 / 通用 —— + ("云舟 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_male_guanggaojieshuo_uranus_bigtts", "男声 · 广告调"), + ("儒雅青年 2.0", "zh_male_ruyaqingnian_uranus_bigtts", "男声 · 青年儒雅"), + ("少年梓辛 2.0", "zh_male_shaonianzixin_uranus_bigtts", "男声 · 少年"), + # —— 女声 —— + ("小何 2.0", "zh_female_xiaohe_uranus_bigtts", "女声 · 通用(默认女声)"), + ("Vivi 2.0", "zh_female_vv_uranus_bigtts", "女声 · 活泼通用"), + ("知性灿灿 2.0", "zh_female_cancan_uranus_bigtts", "女声 · 知性"), + ("甜美桃子 2.0", "zh_female_tianmeitaozi_uranus_bigtts", "女声 · 甜美"), + ("邻家女孩 2.0", "zh_female_linjianvhai_uranus_bigtts", "女声 · 邻家"), + ("温柔淑女 2.0", "zh_female_wenroushunv_uranus_bigtts", "女声 · 温柔"), + ("深夜播客 2.0", "zh_male_shenyeboke_uranus_bigtts", "男声 · 深夜播客/低语"), + # —— 英文 —— + ("Tim", "en_male_tim_uranus_bigtts", "美式英语 · 男声"), + ("Dacey", "en_female_dacey_uranus_bigtts", "美式英语 · 女声"), +] + +VOICE_BY_NAME = {name: vid for name, vid, _ in VOICES} +VOICE_IDS = {vid for _, vid, _ in VOICES} + + +def resolve_voice_token(token: str, default: str = "") -> str: + """把「名称 / 序号 / 原始 ID」统一成音色 ID。空 token → default。 + + 必需:speaker 字段只认 ID。直接传中文显示名会被服务端拒掉, + 报的还是 `55000000 resource ID is mismatched with speaker related resource` + 这种看不出是「名字没解析」的错误。 + """ + token = (token or "").strip() + if not token: + return default or DEFAULT_VOICE + if token.isdigit() and 1 <= int(token) <= len(VOICES): + return VOICES[int(token) - 1][1] + if token in VOICE_BY_NAME: + return VOICE_BY_NAME[token] + if token in VOICE_IDS: + return token + for name, vid, _ in VOICES: # 允许带括注的名字,如「云舟 2.0(默认)」 + if name.startswith(token) or token.startswith(name): + return vid + return token # 自定义 ID(如复刻音色)原样返回 + + +def parse_rate_percent(value) -> int: + """把各种语速写法统一成整数百分比:'+8%' / '8' / 0.08 / 8 → 8。 + + 火山 `speech_rate` 与 edge `rate` 语义相同(都表示「快百分之几」), + 只是表示法不同:火山要 int,edge 要 '+N%' 字符串。 + 两个下游脚本共用这一个换算,避免各写一份慢慢跑偏。 + """ + if isinstance(value, bool) or value is None or value == "": + return 0 + if isinstance(value, (int, float)): + v = float(value) + return int(round(v * 100 if abs(v) <= 1.5 else v)) + m = re.search(r"([-+]?\d+(?:\.\d+)?)", str(value)) + if not m: + return 0 + v = float(m.group(1)) + if "%" in str(value): + return int(round(v)) + return int(round(v * 100 if abs(v) <= 1.5 else v)) + +# 形如 AKLT... 的火山 AccessKey —— 只认这种明确形态,避免把普通 request-id 也抹掉 +_SECRET_PAT = re.compile(r"AKLT[A-Za-z0-9_\-]{6,}") + + +class VolcanoTTSConfigError(RuntimeError): + """缺 tts.env 或字段不全 —— 由 tts_setup.py 负责引导用户补齐。""" + + +class VolcanoTTSError(RuntimeError): + """调用失败(已脱敏,可安全打印)。 + + retryable=False 表示**重试没有意义**(密钥错、音色 ID 错、参数错这类 4xx)。 + 退避重试只对网络抖动、超时、5xx、429 有意义 —— 对 401 重试三次只是白等两轮。 + """ + + def __init__(self, message: str, retryable: bool = True): + super().__init__(message) + self.retryable = retryable + + +# ---------------------------------------------------------------- 密钥读取 +def _skill_root() -> str: + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def env_candidates(project_dir: str = "") -> list[str]: + """tts.env 探测顺序:显式指定 → 项目目录 → 技能目录 → 用户目录。""" + out: list[str] = [] + explicit = os.environ.get("TTS_ENV_FILE", "").strip() + if explicit: + out.append(explicit) + if project_dir: + out.append(os.path.join(os.path.abspath(project_dir), ENV_FILENAME)) + out.append(os.path.join(_skill_root(), ENV_FILENAME)) + out.append(os.path.join(os.path.expanduser("~"), ".workbuddy", ENV_FILENAME)) + seen, uniq = set(), [] + for p in out: + if p not in seen: + seen.add(p) + uniq.append(p) + return uniq + + +def find_env_file(project_dir: str = "") -> str: + for p in env_candidates(project_dir): + if os.path.isfile(p): + return p + return "" + + +def parse_env(path: str) -> dict[str, str]: + """极简 KEY=VALUE 解析(与 python-dotenv 兼容的最小子集),不执行任何代码。""" + vals: dict[str, str] = {} + with open(path, encoding="utf-8-sig") as f: + for raw in f: + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + k, v = k.strip(), v.strip() + if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'": + v = v[1:-1] + vals[k] = v + return vals + + +def load_credentials(project_dir: str = "") -> dict[str, str]: + """读取密钥。返回的 dict 里含密钥,**调用方不得打印或落盘**。""" + path = find_env_file(project_dir) + if not path: + raise VolcanoTTSConfigError( + "没有找到 tts.env。跑到项目根目录执行:python /scripts/tts_setup.py\n" + "(它会生成一份带注释的空模板,你手工填入 API Key 即可。)") + vals = parse_env(path) + api_key = vals.get("VOLC_API_KEY", "").strip() + app_id = vals.get("VOLC_APP_ID", "").strip() + access_key = vals.get("VOLC_ACCESS_KEY", "").strip() + if not api_key and not (app_id and access_key): + raise VolcanoTTSConfigError( + f"{path} 里还没有填密钥。请打开该文件填入 VOLC_API_KEY 后告诉我,我来测连接。\n" + " API Key 获取:火山引擎控制台 → 语音技术 → API Key 管理\n" + " https://console.volcengine.com/speech/new/setting/apikeys") + return { + "path": path, + "api_key": api_key, + "app_id": app_id, + "access_key": access_key, + "resource_id": vals.get("VOLC_RESOURCE_ID", "").strip() or DEFAULT_RESOURCE_ID, + } + + +def describe_env(project_dir: str = "") -> dict[str, str]: + """给 agent/用户看的**脱敏**状态摘要 —— 这个函数的输出可以安全打印。""" + try: + c = load_credentials(project_dir) + except VolcanoTTSConfigError as e: + return {"status": "missing", "detail": str(e).splitlines()[0]} + mode = "X-Api-Key" if c["api_key"] else "X-Api-App-Id + X-Api-Access-Key" + return { + "status": "ok", + "env_file": c["path"], + "auth": mode, + "key_masked": _mask(c["api_key"] or c["access_key"]), + "resource_id": c["resource_id"], + } + + +def _mask(secret: str) -> str: + if not secret: + return "(未设置)" + if len(secret) <= 8: + return "*" * len(secret) + return f"{secret[:4]}{'*' * 6}{secret[-4:]}(长度 {len(secret)})" + + +def _redact(text: str) -> str: + """把可能混进错误信息的密钥抹掉。异常与日志一律过这一层。 + + 只做两件事:抹掉**本次实际加载的**密钥值,以及 AKLT 形态的 token。 + 刻意不用「长字符串就当密钥」这种宽规则 —— 那会把 reqid、logid 一起抹掉, + 排查问题时就看不到定位线索了(而密钥只可能从下面这两个来源进入输出)。 + """ + try: + c = load_credentials("") + for k in ("api_key", "access_key"): + v = c.get(k) or "" + if len(v) >= 8 and v in text: + text = text.replace(v, _mask(v)) + except Exception: + pass + return _SECRET_PAT.sub(lambda m: _mask(m.group(0)), text) + + +# ---------------------------------------------------------------- 请求构造 +def _headers(cred: dict[str, str]) -> dict[str, str]: + h = { + "Content-Type": "application/json", + "Accept-Encoding": "identity", # 不要 gzip,NDJSON 逐行读 + "X-Api-Resource-Id": cred["resource_id"], + "X-Api-Request-Id": str(uuid.uuid4()), + # 不加这个头,响应里就没有 usage.text_words(计费字数恒为 0) + "X-Control-Require-Usage-Tokens-Return": "*", + } + if cred["api_key"]: + h["X-Api-Key"] = cred["api_key"] + else: # 旧版鉴权兜底 + h["X-Api-App-Id"] = cred["app_id"] + h["X-Api-Access-Key"] = cred["access_key"] + return h + + +def _opener() -> urllib.request.OpenerDirector: + """默认不走代理:境内端点直连更快更稳;需要时用 VOLC_PROXY 显式指定。""" + proxy = os.environ.get("VOLC_PROXY", "").strip() + if proxy: + return urllib.request.build_opener( + urllib.request.ProxyHandler({"http": proxy, "https": proxy})) + return urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def split_long_text(text: str, limit: int = MAX_TEXT_LEN) -> list[str]: + """按标点切分到 <=limit 字,保证不把一个词从中间劈开。""" + if len(text) <= limit: + return [text] + parts = re.split(r"(?<=[。!?;,、,.!?;::\n])", text) + out: list[str] = [] + for p in parts: + if not p: + continue + if len(p) <= limit and (not out or len(out[-1]) + len(p) > limit): + out.append(p) + elif len(p) <= limit: + out[-1] += p + else: # 单句仍超长 → 硬切 + for i in range(0, len(p), limit): + out.append(p[i:i + limit]) + return [s for s in out if s.strip()] or [text] + + +def _one_request(text: str, voice: str, cred: dict[str, str], rate: int, + fmt: str, sample_rate: int, bit_rate: int, + timeout: float, enable_subtitle: bool = True, + context_texts: list[str] | None = None, + uid: str = "") -> tuple[bytes, list[dict], dict]: + """单次请求 → (mp3 字节, boundaries, usage)。""" + audio_params: dict = {"format": fmt, "sample_rate": sample_rate} + if fmt == "mp3" and bit_rate: + audio_params["bit_rate"] = bit_rate + # ★ 不传这个开关,响应里的 sentence.words 恒为空数组 —— 字级时间戳拿不到。 + # 实测(2026-09-23):同文本 false→words=0,true→words=10。 + # 官方文档示例里默认带词时间戳,容易让人以为「不用开」,实际必须显式打开。 + # 仅豆包语音合成大模型 2.0 音色、仅中英文语种支持。 + if enable_subtitle: + audio_params["enable_subtitle"] = True + if rate: + audio_params["speech_rate"] = int(rate) # [-50,100] → 0.5x~2.0x + + req_params: dict = {"text": text, "speaker": voice, "audio_params": audio_params} + if context_texts: + req_params["context_texts"] = list(context_texts) + body = {"user": {"uid": uid or f"html-explainer-{uuid.uuid4().hex[:12]}"}, + "req_params": req_params} + + req = urllib.request.Request( + ENDPOINT, data=json.dumps(body, ensure_ascii=False).encode("utf-8"), + headers=_headers(cred), method="POST") + + chunks: list[bytes] = [] + boundaries: list[dict] = [] + usage: dict = {} + try: + with _opener().open(req, timeout=timeout) as resp: + for raw in resp: # NDJSON:逐行 + line = raw.strip() + if not line: + continue + try: + obj = json.loads(line.decode("utf-8")) + except Exception: + continue + code = obj.get("code") + data = obj.get("data") + # ★ usage 挂在**结束行**上(code=20000000、data=null 那一行)。 + # 先判结束标记再读 usage,会把计费字数永远读成 0(踩过)。 + if obj.get("usage"): + usage = obj["usage"] + if code == END_CODE: # ★ 结束标记,且不含音频 + continue + if code not in (0, None): + raise VolcanoTTSError( + f"接口返回 code={code} message={obj.get('message')!r}") + if data: + try: + chunks.append(base64.b64decode(data)) + except Exception: + raise VolcanoTTSError("音频块 base64 解码失败(流可能被截断)") + sent = obj.get("sentence") or {} + for w in (sent.get("words") or []): + txt = (w.get("word") or "").strip() + if not txt: + continue + st = float(w.get("startTime") or 0.0) + et = float(w.get("endTime") or st) + boundaries.append({ + "text": txt, + "offset_sec": round(st, 4), + "duration_sec": round(max(0.0, et - st), 4), + }) + except urllib.error.HTTPError as e: + detail = "" + try: + detail = e.read().decode("utf-8", "replace")[:400] + except Exception: + pass + hint = "" + # 4xx 基本都是永久性错误(密钥/音色/参数),重试无益;429 与 5xx 才值得重试 + retryable = e.code == 429 or e.code >= 500 + if e.code in (401, 403): + hint = "\n → 鉴权失败:核对 tts.env 里的 VOLC_API_KEY 是否正确、是否已开通该服务。" + elif e.code == 404: + hint = "\n → 音色或资源 ID 不对:预置音色要配 seed-tts-2.0。" + raise VolcanoTTSError( + f"HTTP {e.code} {e.reason}{hint}\n {_redact(detail)}", retryable=retryable) + except urllib.error.URLError as e: + raise VolcanoTTSError( + f"网络不可达:{_redact(str(e.reason))}\n" + " → 本包默认绕过系统代理直连。若你的网络确实需要代理,设 VOLC_PROXY=http://127.0.0.1:。") + except TimeoutError: + raise VolcanoTTSError(f"请求超时({timeout}s)") + + if not chunks: + raise VolcanoTTSError("没有收到任何音频块(检查音色 ID 与文本是否为空)") + return b"".join(chunks), boundaries, usage + + +def _looks_like_mp3(b: bytes) -> bool: + if len(b) < 4: + return False + head = b[:4].hex() + return head.startswith("494433") or head.startswith("fff3") or head.startswith("fffb") \ + or head.startswith("fffa") or head.startswith("fff2") + + +def synthesize(text: str, voice: str = DEFAULT_VOICE, rate: int = 0, + fmt: str = "mp3", sample_rate: int = 24000, bit_rate: int = 160000, + timeout: float = DEFAULT_TIMEOUT, retries: int = DEFAULT_RETRIES, + context_texts: list[str] | None = None, + project_dir: str = "", + enable_subtitle: bool = True) -> dict: + """合成一段文本 → {audio, boundaries, usage, requests}。失败抛 VolcanoTTSError。 + + enable_subtitle=True(默认)会要求服务端返回字级时间戳。关掉只在 + 「音色不支持字幕(非中英文)」时有意义 —— 那种情况下 words 必为空。 + """ + text = (text or "").strip() + if not text: + raise VolcanoTTSError("文本为空") + cred = load_credentials(project_dir) + + last: Exception | None = None + for attempt in range(max(1, retries)): + try: + pieces = split_long_text(text) + audio_all: list[bytes] = [] + bounds_all: list[dict] = [] + usage: dict = {} + # 长文本切分时,后续片段的词边界要按前面片段的实际时长平移 + for i, piece in enumerate(pieces): + audio, bounds, u = _one_request( + piece, voice, cred, rate, fmt, sample_rate, bit_rate, + timeout, enable_subtitle, context_texts) + if i: + offset = _bytes_duration(b"".join(audio_all), fmt) + bounds = [{**b, "offset_sec": round(b["offset_sec"] + offset, 4)} + for b in bounds] + audio_all.append(audio) + bounds_all.extend(bounds) + if u: + usage = u + audio = b"".join(audio_all) + if not _looks_like_mp3(audio) and fmt == "mp3": + raise VolcanoTTSError("返回的音频不是有效 MP3 头(可能被截断)") + result = {"audio": audio, "boundaries": bounds_all, + "usage": usage, "requests": len(pieces)} + # ★ 要了字幕却一个时间戳都没回来 —— 大概率踩到「音色不支持字幕」。 + # 静默放行会让下游字幕整段塌掉,所以这里显式记一条可打印的警告。 + if enable_subtitle and not bounds_all: + result["warning"] = ( + "未返回字级时间戳(sentence.words 为空)。" + "该能力仅豆包语音合成大模型 2.0 的中英文音色支持;" + "当前音色若不支持,字幕将退化为按整段均分——建议换成 zh_*_uranus_bigtts。") + return result + except VolcanoTTSConfigError: + raise + except VolcanoTTSError as e: + if not e.retryable: + raise # 密钥/参数错,重试是白等 + last = e + if attempt + 1 < max(1, retries): + time.sleep(RETRY_SLEEP * (attempt + 1)) + except Exception as e: # noqa: BLE001 + last = e + if attempt + 1 < max(1, retries): + time.sleep(RETRY_SLEEP * (attempt + 1)) + raise VolcanoTTSError(_redact(f"连续 {retries} 次失败:{type(last).__name__}: {last}")) + + +def _bytes_duration(data: bytes, fmt: str) -> float: + """用 ffmpeg 量已合成字节的时长(长文本切片拼接时算偏移用)。""" + import subprocess + import tempfile + from shutil import which + ff = which("ffmpeg") + if not ff: + try: + import imageio_ffmpeg + ff = imageio_ffmpeg.get_ffmpeg_exe() + except Exception: + return 0.0 + fd, tmp = tempfile.mkstemp(suffix=f".{fmt}") + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + r = subprocess.run([ff, "-hide_banner", "-i", tmp], + 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 round(int(h) * 3600 + int(m) * 60 + float(s), 3) + finally: + try: + os.remove(tmp) + except OSError: + pass + return 0.0 + + +def check(project_dir: str = "", voice: str = "", timeout: float = 30.0) -> dict: + """连通性自检:用一句短文本试合成。返回可安全打印的状态 dict。""" + try: + cred = load_credentials(project_dir) + except VolcanoTTSConfigError as e: + return {"ok": False, "stage": "env", "detail": str(e)} + v = resolve_voice_token(voice) + try: + r = synthesize("连接测试,这句话只用来验证接口是否可用。", + voice=v, timeout=timeout, retries=1, project_dir=project_dir) + return { + "ok": True, "stage": "api", "voice": v, + "env_file": cred["path"], + "key_masked": _mask(cred["api_key"] or cred["access_key"]), + "resource_id": cred["resource_id"], + "audio_bytes": len(r["audio"]), + "boundaries": len(r["boundaries"]), + "chars_billed": (r.get("usage") or {}).get("text_words", 0), + } + except VolcanoTTSError as e: + return {"ok": False, "stage": "api", "voice": v, "env_file": cred["path"], + "detail": str(e)} + + +# ---------------------------------------------------------------- CLI +def _print_voices() -> None: + print("内置常用音色(完整列表见官方音色文档):") + for i, (name, vid, note) in enumerate(VOICES, 1): + print(f" {i:>2}. {name:<18} {note}\n {vid}") + + +def main() -> int: + p = argparse.ArgumentParser(description="火山引擎语音合成 2.0 接口包") + p.add_argument("--project", default="", help="项目目录(用于探测 tts.env)") + p.add_argument("--env-file", default="", help="显式指定 tts.env 路径") + p.add_argument("--check", action="store_true", help="连通性自检") + p.add_argument("--voices", action="store_true", help="列出内置音色") + p.add_argument("--synth", default="", help="合成一段文本") + p.add_argument("--out", default="volcano_tts_test.mp3") + p.add_argument("--voice", default="") + p.add_argument("--rate", type=int, default=0) + args = p.parse_args() + + if args.env_file: + os.environ["TTS_ENV_FILE"] = args.env_file + + if args.voices: + _print_voices() + return 0 + + if args.check: + st = check(args.project, args.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']}") + print(f" 密钥: {st['key_masked']} · 资源 {st['resource_id']}") + return 0 + print(f"✗ 未通过({st['stage']})\n{st['detail']}", file=sys.stderr) + return 1 + + if args.synth: + try: + r = synthesize(args.synth, voice=resolve_voice_token(args.voice), + rate=args.rate, project_dir=args.project) + except (VolcanoTTSError, VolcanoTTSConfigError) as e: + print(f"✗ {e}", file=sys.stderr) + return 1 + with open(args.out, "wb") as f: + f.write(r["audio"]) + print(f"✓ {args.out} · {len(r['audio'])}B · 时间戳 {len(r['boundaries'])} 个 " + f"· 请求 {r['requests']} 次") + if r.get("warning"): + print(f"⚠ {r['warning']}", file=sys.stderr) + return 0 + + p.print_help() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tts.env.example b/tts.env.example new file mode 100644 index 0000000..4a7c02a --- /dev/null +++ b/tts.env.example @@ -0,0 +1,22 @@ +# --------------------------------------------------------------- +# 火山引擎(豆包)语音合成 2.0 —— 密钥配置【模板】 +# +# 用法:把本文件复制成 tts.env 再填,不要直接改本文件: +# cp tts.env.example tts.env # 放到本技能目录,或某个视频项目目录 +# 注意别把本文件「改名」成 tts.env —— 本文件是要留在仓库里的模板, +# 改名会让仓库少掉模板(check_integrity.py 会报错)。 +# +# 真实 tts.env 已被 .gitignore 忽略,不会进 git 仓库。 +# ★ agent 不需要、也不应该读取 tts.env;接口包 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=