Files

277 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 音频合成
把每个章节 `narrations.ts` 里的口播文字按 **step 颗粒度**合成 mp3,
落到 `presentation/public/audio/<chapter-id>/<step-N>.mp3`。运行时
Auto 模式会自动按 step 播放并自动推进——录屏可以一镜到底。
> **真相源**:每个章节的 `src/chapters/<NN>-<id>/narrations.ts` 是 step
> 数 + 口播文本的**唯一来源**。`outline.md` 不再参与音频合成,章节代码
> 也不再手写 `totalSteps`。这一改根除了"网页 step 和音频文件数对不上"
> 这个老问题。
合成器是 **provider-agnostic** 的:runner 本身不绑定任何 TTS 后端,每个
后端是 `scripts/tts-providers/<name>.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`
- 文件名 = `<step-N>.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=<voice-id> # 指定音色
```
启动时 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/<name>.sh` → 设好环境变量 → 切换 provider 跑:
```bash
PRESENTATION_TTS=elevenlabs npm run synthesize-audio
# 或
npm run synthesize-audio -- --provider=edge-tts
```
如果用户的 TTS 完全自研,**按三函数契约**写一个 `<name>.sh` 即可:
| 函数 | 必需 | 作用 |
|---|---|---|
| `tts_synthesize <text> <out_path> [<voice>]` | ✓ | 把一段文字写成 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/<name>.sh,
再 PRESENTATION_TTS=<name> 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>`;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` | 你的 `<X>.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=<id>` |
| 整段合成被截断 | 单段过长(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 之后:
| 现象 | 原因 / 修法 |
|---|---|
| `<X>_API_KEY not set` | 你的 provider 需要 API key,但 env 里没设。`export <X>_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 仓库:<https://github.com/MiniMax-AI/cli>
- mmx 官方文档:<https://platform.minimaxi.com/docs/token-plan/minimax-cli>
- mmx 参数 / 音色查询:`mmx speech --help`