搜索文章

输入关键词开始搜索

TTS工具开发笔记

AI#AI#AI Coding#音视频#Node.js#程序设计

这次本来只是想解决一个很小的问题:给托福听力弱词生成一批质量还可以的音频材料。

一开始我并没有想单独做一个工具。最早的想法很简单,把 800 多个弱词整理出来,生成短句和主题文章,然后用本机的 say 或者一个临时脚本转成音频就行。但实际试听之后发现,系统自带 TTS 的效果太差,尤其是做听力训练时,音质差这件事不是小问题。

如果音频本身不自然,后面的训练就会变形。你以为自己是在练单词识别,实际上可能是在适应一个奇怪的机器声音。

所以后面才把这个事情拆成了一个独立的命令行工具:tts-gen

TTS 工具从文本到音频的处理流水线

背景

这次需求来自一个具体的英语训练场景。

我有一份弱词列表,目标不是背单词,而是训练听到声音之后的瞬间反应。前面已经生成了几类材料:

  • 每个词的短句;
  • 按主题组织的听力文章;
  • 用来批量转音频的 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:检查本地配置和认证。

后面证明,doctorvoices 这两个命令很有必要。没有它们的话,很多问题会直接暴露在 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,这已经超过脚本的舒适区。

后续再做类似工具,我会按这个顺序来:

  1. 先确定最小真实调用;
  2. 再设计配置和输入格式;
  3. doctorvoicessmoke
  4. 最后再做批量和文档。

这次绕了一点路,但这个路值得记录。因为真正浪费时间的不是写 CLI,而是把“环境问题、认证问题、API 问题、工具设计问题”混在一起排查。