TTS工具开发笔记
这次本来只是想解决一个很小的问题:给托福听力弱词生成一批质量还可以的音频材料。
一开始我并没有想单独做一个工具。最早的想法很简单,把 800 多个弱词整理出来,生成短句和主题文章,然后用本机的 say 或者一个临时脚本转成音频就行。但实际试听之后发现,系统自带 TTS 的效果太差,尤其是做听力训练时,音质差这件事不是小问题。
如果音频本身不自然,后面的训练就会变形。你以为自己是在练单词识别,实际上可能是在适应一个奇怪的机器声音。
所以后面才把这个事情拆成了一个独立的命令行工具:tts-gen。

背景
这次需求来自一个具体的英语训练场景。
我有一份弱词列表,目标不是背单词,而是训练听到声音之后的瞬间反应。前面已经生成了几类材料:
- 每个词的短句;
- 按主题组织的听力文章;
- 用来批量转音频的 manifest;
- 后续复习用的文章和短句集合。
这里有一个判断很重要:Anki 裸听只能解决第一层问题,真正要迁移到听力里,还需要短句和文章。
所以音频生成不是一次性的“小脚本”问题。后面大概率还会换模型、换 voice、换文章、换项目目录,甚至会把别的学习材料也丢进来转音频。如果每次都写一个 render-audio.sh,后面肯定会乱。
因此这个工具一开始就定位成命令行工具,而不是某个项目目录里的临时脚本。
一开始的判断
我先试了一版本机 TTS。
技术上没什么难度,用 macOS 的 say 就能生成音频,脚本也很快能跑通。但试听以后基本可以放弃。不是说完全不能用,而是不适合听力训练。这个场景对音质和发音自然度的要求比普通“读一段文字”高很多。
后面又比较了 OpenAI 和 Google 的方案。大致判断是:
- OpenAI TTS 接入简单,质量也不错;
- Google Chirp 3 HD 更适合这次主要用途;
- 成本在这个材料规模下并不高,几小时音频大概就是几美元级别;
- Gemini TTS 和 Chirp 3 HD 都在 Google 这边,但 API payload 不完全一样,不能假设完全兼容。
最终先选 Google Cloud Text-to-Speech 的 Chirp 3 HD。
这里有一个容易混淆的点:en-US-Chirp3-HD-Alnilam 不是 model,它是 voice name。工具内部默认的 model 仍然是 chirp3-hd。
工具怎么设计
这个工具放在 ~/git/tts-gen,技术栈选得比较普通:
- Node.js + TypeScript;
commander做 CLI;zod做配置校验;csv-parse读 manifest;google-auth-library处理 Google ADC;gaxios处理 Google REST 请求和代理。
命令分成几类:
ttsgen speak [text]
ttsgen render <input.txt>
ttsgen batch <manifest.csv|manifest.jsonl>
ttsgen estimate <input-or-manifest>
ttsgen voices
ttsgen doctor
这几个命令对应了不同使用场景:
speak:临时输入一句话验证 voice;render:把一个 txt 文件转成音频;batch:根据 manifest 批量生成短句或文章;estimate:先估算字符数和成本;voices:列出可用 voice;doctor:检查本地配置和认证。
后面证明,doctor 和 voices 这两个命令很有必要。没有它们的话,很多问题会直接暴露在 render 阶段,排查起来更乱。
第一个坑:不是 API 错,是代理没走
最早真实调用时,命令直接报:
fetch failed
这个错误很烦,因为它没有告诉你到底是认证失败、API 没开、voice 不存在,还是网络不通。
一开始我下意识会怀疑 Google 认证。后来把底层错误打出来,才发现是:
UND_ERR_CONNECT_TIMEOUT
再对比 curl 之后,问题就清楚了:系统代理开着,地址是 127.0.0.1:7890,但是 Node 进程不会自动使用 macOS 系统代理。普通 curl 直连超时,curl --proxy http://127.0.0.1:7890 可以通。
所以这里不是 Google API 的问题,也不是 credential 的问题,而是命令行进程没有走代理。
解决方案是把代理做成工具的一等配置:
node dist/cli/index.js voices --language en-US --proxy http://127.0.0.1:7890
export TTSGEN_PROXY=http://127.0.0.1:7890
项目里也加了默认配置:
{
"network": {
"proxy": "http://127.0.0.1:7890"
}
}
这个配置现在放在 ttsgen.config.json 里。同时我也在 ~/.zshrc 里加了:
export TTSGEN_PROXY="http://127.0.0.1:7890"
这一步的经验是:本地工具如果要访问外部 API,代理不是“临时参数”,而是运行环境的一部分。
第二个坑:认证成功不代表 API 调用成功
代理问题解决后,doctor --online 已经可以拿到 Google auth token,但 voices 还是报 403。
错误大概是:
The texttospeech.googleapis.com API requires a quota project
一开始我以为是 ADC 没有设置 quota project,于是按 Google 的方式执行:
gcloud auth application-default set-quota-project YOUR_PROJECT_ID
但是问题还在。
后来检查 ADC 文件,发现里面其实已经有 quota_project_id。Node 里的 GoogleAuth client 也能读到这个值。也就是说,credential 没问题。
真正的问题在我们自己的实现。
因为工具是手写 REST 请求,只带了:
Authorization: Bearer ...
但对于本地 ADC user credentials,Google 还需要:
x-goog-user-project: YOUR_PROJECT_ID
这个 header 不带,Google 还是会认为没有 quota project。
修完之后,voices 就能正常返回 Chirp 3 HD 的 voice 列表:
en-US-Chirp3-HD-Achernar
en-US-Chirp3-HD-Achird
en-US-Chirp3-HD-Alnilam
en-US-Chirp3-HD-Aoede
...
这个问题比较典型:auth ok 只能说明 token 能拿到,不代表具体 API 请求就完整了。以后接 Google API,如果不用官方封装客户端,而是自己打 REST,要特别注意 quota project header。
最终状态
现在工具的默认配置是:
{
"provider": "google-cloud-tts",
"model": "chirp3-hd",
"voice": "en-US-Chirp3-HD-Alnilam",
"language": "en-US",
"format": "mp3",
"network": {
"proxy": "http://127.0.0.1:7890"
}
}
为了减少重复输入,还加了几个 npm script:
npm run doctor:online
npm run voices
npm run smoke
smoke 会生成一句很短的测试音频:
node dist/cli/index.js speak "The professor gave a short lecture about ocean tides." \
-o audio/smoke-test-alnilam.mp3 \
--skip-existing
这一步看起来很小,但我觉得是必要的。因为这个工具最怕的不是代码写不出来,而是某天换了机器、换了网络、换了 Google 项目后,突然不知道是哪一层坏了。
现在至少有三层自测:
doctor:online:认证和代理是否正常;voices:Google TTS API 是否能访问;smoke:真实合成和文件写入是否正常。
文档也算开发的一部分
这次还补了一批文档:
- README:基本使用方式;
- PRD:工具目标和边界;
- Technical Design:模块设计;
- Provider Integration:Google / OpenAI / Gemini 的差异;
- Auth and Secrets:认证和密钥管理;
- Local Runbook:本机代理、ADC、quota project、自测命令;
- Manifest Schema:批量生成输入格式。
以前我容易把文档当成“最后有时间再写”的东西。但这种工具不一样,它依赖外部服务、本机环境、认证文件、代理、配置优先级。只靠代码不够。
尤其是这次的两个问题:
- Node 不走系统代理;
- Google REST 请求要带
x-goog-user-project。
这两个都不是看代码结构就能想起来的东西。写进 runbook,下次才不会重新踩一遍。
经验教训
这次最有用的经验大概有几个。
第一,先做 smoke test。
不要等批量生成几千条音频时才发现认证、代理、voice 或输出路径有问题。真实 API 工具一定要有最小验证命令。
第二,错误信息要打完整。
fetch failed 这种错误基本没有排查价值。至少要把底层 cause、HTTP status、Google 返回体打印出来。没有这些信息,很容易沿着错误方向走。
第三,认证要分层看。
能拿 token,只说明认证第一步成功。真正调用 API 时还可能缺 quota project、缺权限、API 没启用、billing 没开、voice 不存在。不要把这些问题都混成“认证失败”。
第四,voice 和 model 要分清楚。
这次默认的是:
model: chirp3-hd
voice: en-US-Chirp3-HD-Alnilam
如果以后换 Gemini TTS,payload 可能就不是同一种结构。CLI 可以做统一入口,但 adapter 内部必须尊重 provider 的真实差异。
第五,一次性脚本和可复用工具的边界要早点判断。
如果只是生成一篇文章音频,脚本就够了。但这次有 txt、stdin、manifest、批量任务、成本估算、voice 切换、provider 切换、skip-existing、metadata,这已经超过脚本的舒适区。
后续再做类似工具,我会按这个顺序来:
- 先确定最小真实调用;
- 再设计配置和输入格式;
- 补
doctor、voices、smoke; - 最后再做批量和文档。
这次绕了一点路,但这个路值得记录。因为真正浪费时间的不是写 CLI,而是把“环境问题、认证问题、API 问题、工具设计问题”混在一起排查。