# 音频合成 把每个章节 `narrations.ts` 里的口播文字按 **step 颗粒度**合成 mp3, 落到 `presentation/public/audio//.mp3`。运行时 Auto 模式会自动按 step 播放并自动推进——录屏可以一镜到底。 > **真相源**:每个章节的 `src/chapters/-/narrations.ts` 是 step > 数 + 口播文本的**唯一来源**。`outline.md` 不再参与音频合成,章节代码 > 也不再手写 `totalSteps`。这一改根除了"网页 step 和音频文件数对不上" > 这个老问题。 合成器是 **provider-agnostic** 的:runner 本身不绑定任何 TTS 后端,每个 后端是 `scripts/tts-providers/.sh` 一个文件。**内置 2 个 provider**: | Provider | 默认 | 何时用 | |---|---|---| | `minimax` | ✓ | 中文口播首选(用 `mmx-cli`,要 MiniMax API key) | | `openai` | —— | 多数 agent 已有 `OPENAI_API_KEY`;curl-based、响应快 | 换 / 加 provider 见 [`scripts/tts-providers/README.md`](../templates/scripts/tts-providers/README.md) (脚手架跑完后路径是 `presentation/scripts/tts-providers/README.md`)。 README 里还附了 5 套**可粘贴**的现成片段(ElevenLabs / edge-tts / macOS say / Azure / Google Cloud)和写自定义 provider 的三函数契约。 --- ## 文件命名约定 ``` presentation/public/audio/ ├── coldopen/ │ ├── 1.mp3 │ ├── 2.mp3 │ └── ... ├── hook/ │ └── ... └── ... ``` - 章节子目录名 = `chapters.ts` 里的 `id` - 文件名 = `.mp3`(**1-indexed**,对齐 narrations 数组的 index + 1) - 格式默认 mp3。如果你写的 provider 只能出 wav,在函数里加一步 `ffmpeg` 转 mp3(参见 `tts-providers/README.md` 的 `say.sh` 示例) --- ## 标准流程 ### 1. 抽取 segments ```bash cd presentation npm run extract-narrations ``` 这会扫所有章节的 `narrations.ts`,按 `chapters.ts` 注册顺序生成 `audio-segments.json`: ```json [ { "chapter": "coldopen", "step": 1, "text": "...", "audio": "coldopen/1.mp3" }, { "chapter": "coldopen", "step": 2, "text": "...", "audio": "coldopen/2.mp3" }, ... ] ``` 让用户**先扫一眼这个 json**,确认文本和切分都对,再开始烧 token 合成。 > 空字符串的 narration 会被自动跳过(不烧 TTS token)——运行时 Auto 模式 > 按字数估时撑过这种"无声过场"step。 ### 2. 选 provider ```bash ls scripts/tts-providers/ # 看本项目带了哪些 ``` - 用默认 `minimax` → 走 [2.A](#2a-用内置-minimax-合成) - 用内置 `openai` → 走 [2.B](#2b-用内置-openai-合成) - 想用别的 TTS / 自带 TTS → 走 [2.C](#2c-换-provider--加自定义-provider) - 一个都没装好 → 走 [2.D](#2d-退化路径) #### 2.A 用内置 minimax 合成 ```bash npm run synthesize-audio # 增量:跳过已存在的 mp3 npm run synthesize-audio -- --force # 全部重合成 npm run synthesize-audio -- --voice= # 指定音色 ``` 启动时 runner 会先调 provider 的 `tts_check`: - mmx 未安装 → 报 `mmx CLI not found in PATH`,并打印安装说明 - mmx 未登录 → 报 `mmx is not authenticated`,并提示登录命令 修完再跑。每条段打印进度: ``` [ 3/24] coldopen/3.mp3 ✓ 4s [ 4/24] coldopen/4.mp3 skip (exists) ``` 合成串行(避免 rate limit),**自动跳过已存在文件**(断点续合,不烧 重复 token)。 #### 2.B 用内置 openai 合成 ```bash export OPENAI_API_KEY=sk-... # 在 platform.openai.com 拿 PRESENTATION_TTS=openai npm run synthesize-audio # 换音色 + HD 模型 OPENAI_TTS_MODEL=tts-1-hd PRESENTATION_TTS=openai \ npm run synthesize-audio -- --voice=nova ``` 可选 env: | 变量 | 默认 | 作用 | |---|---|---| | `OPENAI_API_KEY` | —— **必须** | API key | | `OPENAI_BASE_URL` | `https://api.openai.com/v1` | 切代理 / Azure-OpenAI | | `OPENAI_TTS_MODEL` | `tts-1` | `tts-1` 快 / `tts-1-hd` 高质量约 2× 价 | | `--voice=` / `PRESENTATION_TTS_VOICE` | `alloy` | 可选 alloy / echo / fable / onyx / nova / shimmer | `tts_check` 会检查 curl / jq / `OPENAI_API_KEY` 三件套,缺哪个报哪个。 #### 2.C 换 provider / 加自定义 provider 内置之外的常见后端在 `scripts/tts-providers/README.md` 里有 5 段 **可粘贴**代码片段(ElevenLabs / edge-tts / macOS `say` / Azure / Google Cloud)。 挑一个 → 复制 README 里的代码块 → 保存为 `scripts/tts-providers/.sh` → 设好环境变量 → 切换 provider 跑: ```bash PRESENTATION_TTS=elevenlabs npm run synthesize-audio # 或 npm run synthesize-audio -- --provider=edge-tts ``` 如果用户的 TTS 完全自研,**按三函数契约**写一个 `.sh` 即可: | 函数 | 必需 | 作用 | |---|---|---| | `tts_synthesize []` | ✓ | 把一段文字写成 mp3 到指定路径 | | `tts_check` | 可选 | 启动时校验环境(CLI / key / auth),未就绪 return 非零 | | `tts_install_help` | 可选 | `tts_check` 失败时打印怎么修 | 抄 `openai.sh`(HTTP-based)或 `minimax.sh`(CLI-based)起手最快。 详细规范在 `scripts/tts-providers/README.md`。 #### 2.D 退化路径 如果两个内置 provider 都没就绪(没装 mmx 也没有 OpenAI key)告诉用户: ``` 我可以: 1. 用内置 openai provider(如果你已有 OpenAI key) export OPENAI_API_KEY=sk-... PRESENTATION_TTS=openai npm run synthesize-audio 2. 帮你装 MiniMax CLI(默认 provider,中文音色更稳) npm install -g mmx-cli && mmx auth login --api-key sk-xxxxx API key 在 https://platform.minimaxi.com 获取 3. 换其它 provider scripts/tts-providers/README.md 里有 5 种现成代码片段: • ElevenLabs (要 ELEVENLABS_API_KEY,英文音色最佳) • edge-tts (免费 / 无 key / pip install edge-tts) • macOS say (零依赖离线,质量一般,适合预览) • Azure (要 AZURE_SPEECH_KEY) • Google (要 gcloud auth) 复制一段保存成 tts-providers/.sh, 再 PRESENTATION_TTS= npm run synthesize-audio 4. 暂时跳过 稿子和 narrations 都在,你自己用任意 TTS 录制即可——文件 按 audio-segments.json 的 audio 字段命名就行。 ``` 不要假装合成成功。 --- ## 校验时长 合成完后跑: ```bash for f in public/audio/*/*.mp3; do d=$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$f") echo "$f ${d}s" done ``` 把每条的实际秒数汇总告诉用户。**重点关注 ≥ 15s 的条目**——口播太长意味 着该 step 的 narration 写得过密,或者 step 没拆够。让用户决定**改稿子 重合**还是**回章节代码拆 step**。 --- ## 运行时如何使用合成的音频 合成完成后,**不需要任何额外配置**——脚手架的 `App.tsx` 已经接好: | 模式 | 触发方式 | 行为 | |---|---|---| | **Manual**(默认) | 直接打开页面 | 不播音频,点击 / 方向键推进 | | **Audio**(半自动) | URL `?audio=1` 或按 `M` 键 | 进入 step 自动播音频,但你手动推进(点鼠标) | | **Auto**(全自动) | URL `?auto=1` 或按两次 `M` 键 | 进入 step 播音频 → 播完自动 next() → 进下个 step → ... | Auto 模式首次需要按一次 `Space` 启动(绕过浏览器自动播放限制),之后 全自动跑。**录屏时打开屏幕录制 → 按 Space → 整片自动跑完 → stop**。 > **Auto 模式的推进规则就一句话**:每段音频播完 + 200ms 缓冲 → 自动 next。 > **没有"等动画跑完"的兜底**——如果你写的视觉动画比口播长,会被当场切。 > 解决办法:写更长口播 / 拆 step / 调动画速度(详见 > [`CHAPTER-CRAFT.md`](CHAPTER-CRAFT.md) 「代码层最小约束」)。 > > 音频文件缺失(还没合成 / 404)或 narration 是空串 → 退化到字数估时 > (`max(1500ms, 字数 × 250ms)`),保证预览也能整片跑通。 --- ## 故障排查 通用: | 现象 | 原因 / 修法 | |---|---| | `chapter id "X" registered but no matching folder found` | 章节文件夹应命名为 `NN-`;id 必须等于 chapters.ts 里注册的 | | `narrations.ts in X must export an array named "narrations"` | 该章节的 narrations.ts 没 export 名为 narrations 的数组 | | `TTS provider 'X' not found` | `scripts/tts-providers/X.sh` 不存在;列出来看哪些可用,或抄 README 加一个 | | `provider 'X' does not define tts_synthesize` | 你的 `.sh` 没定义必需的函数。看 README 的契约部分 | | 中间断了几条没合成 | `npm run synthesize-audio` 重跑 —— 已存在文件会跳过 | | 浏览器没播音频 | Auto / Audio 模式下首次需要用户手势——确认你按了 SPACE 启动 Auto,或者点过页面 | | 音频 404 但 Auto 模式还能跑 | 找不到 mp3 时 useAudioPlayer 退化到字数估时(4 字/秒),保证预览不中断 | minimax 专属: | 现象 | 原因 / 修法 | |---|---| | `mmx: command not found` | `npm install -g mmx-cli`;npm 全局 bin 不在 PATH 时 `npm config get prefix` 看一下 | | `mmx is not authenticated` | `mmx auth login --api-key sk-xxxxx` 重新登录 | | 中文音色不自然 | mmx 默认音色未必最佳;查 `mmx speech --help` 看 `--voice` 可选项,传 `--voice=` | | 整段合成被截断 | 单段过长(mmx 默认上限约 5000 字符)。在 narrations.ts 里把这条拆成两条(也意味着该 step 应该拆成两个 step) | openai 专属: | 现象 | 原因 / 修法 | |---|---| | `OPENAI_API_KEY is not set` | `export OPENAI_API_KEY=sk-...`,或者把它加到 shell rc / `.env` | | 全部段 FAILED + key 是对的 | 多半 model / voice 名字错。`--voice=alloy` 试默认值;`OPENAI_TTS_MODEL=tts-1` 试默认模型;用 `bash -x scripts/synthesize-audio.sh` 看请求体 | | 走代理 / 走 Azure-OpenAI | `export OPENAI_BASE_URL=https://your-proxy/v1` | | HD 太慢 | 改成 `OPENAI_TTS_MODEL=tts-1`(默认);HD 大约慢 2 倍 | | 中文音色不像真人 | OpenAI 6 种音色都是英语偏向;中文角色用 `minimax` 更合适 | 换其它(自定义)provider 之后: | 现象 | 原因 / 修法 | |---|---| | `_API_KEY not set` | 你的 provider 需要 API key,但 env 里没设。`export _API_KEY=...` 或写到 `.env` 再 `set -a; source .env; set +a` | | 合成的 mp3 浏览器播不了 | 检查 provider 是否真的出了 mp3(不是 wav / opus / aac)。`file public/audio/*/*.mp3` 看 magic header | | 一切看起来都对,但全部 FAILED | `bash -x scripts/synthesize-audio.sh` 看每段实际调了什么 | --- ## 相关链接 - Provider 契约 + 现成片段:[`scripts/tts-providers/README.md`](../templates/scripts/tts-providers/README.md) - mmx-cli 仓库: - mmx 官方文档: - mmx 参数 / 音色查询:`mmx speech --help`