用 Cloudflare 构建多人配音应用
作者用 Cloudflare Workers、D1 和 R2 构建剧本转 MP3 应用,调用 Fish Audio Drama 3 一次生成多角色对话。工程重点是把自由格式的台词、角色和表演指令转换成严格的 API 请求。
AI 深度解读
把一段多人剧本变成一个 MP3,难点未必在语音合成。作者用 Fish Audio 的 Drama 3 做了一个小应用:用户粘贴剧本、给角色选声音,一次请求生成整场对白。真正花心思的地方,是把随手写的文本变成模型接受的严格输入,同时管住角色切换、费用和失败状态。
这篇值得读的,是一组小型 AI 应用的工程取舍:哪些错误可以交给用户提前发现,哪些边界必须由服务端守住,以及为了少写一套任务系统,需要接受什么限制。
剧本解析不必全懂,但要让错误提前暴露
作者的应用只用一个 Cloudflare Worker,以原生 JavaScript 提供静态页面和 JSON API;D1 保存用户与生成记录,R2 保存 MP3,没有引入框架。
输入格式也很克制:每行一句,用 Name: line 标记角色。[whispering] 这样的方括号内容表示演绎指令,模型执行它,而不是念出来;<emphasis>…</emphasis> 用来强调词语。没有角色名的行,默认归给 Narrator。
这个默认值很实用。用户从文档里复制剧本,往往夹着环境描写和普通段落。全部报错会让编辑器难用,归给旁白则能保留内容。
解析器接受 Mara: hi,也接受 Mara [whispering]: hi,后者会把冒号前的指令移入正文。角色名最多 40 个字符,不能包含方括号、冒号、<、> 或 |,还要满足不超过 5 个按空白分隔的词。
这些规则是在降低误判概率。普通句子里的冒号也可能被识别成角色分隔符,例如 Note: the ferry leaves at six 会产生角色 Note,At 3:15 the lights went out 会产生角色 At 3。
作者没有继续堆语义判断,而是在生成前展示角色列表。
解析可以有误判,但误判应该在付费调用之前被看见。
另外,空行会跳过;去掉指令和标记后没有内容的行会丢弃,但单独的 [long silence] 会保留,因为停顿也是有效表演。解析结果包括按首次出现排序的角色,以及 { speaker, text } 对话列表,最多支持 16 个角色、6,000 个剧本字符。
用户能写表演指令,不能写模型控制标签
Drama 3 的多人输入用 <|speaker:0|>、<|speaker:1|> 等标签切换角色,reference_id 数组则按位置提供对应的声音 ID。
如果直接拼接用户文本,用户就能在对白里插入 <|speaker:3|>,切换声音,甚至引用没有分配声音的索引。因此,作者先清理控制标签:
function cleanText(s) {
// 移除疑似控制标签的内容,防止用户注入角色切换。
return s.replace(/<\|[^>]*\|>/g, '').trim();
}
关键是 replace 那一行:清理对象是供应商的控制标签,方括号指令和 <emphasis> 则保留。这是在明确划分权限:用户负责对白和演绎要求,程序负责角色索引。
随后,程序按角色首次出现的顺序生成标签,并用相同顺序构造声音数组。角色标签不依赖最终选了哪个声音,因此选角之前就能得到最终文本的长度,提前校验请求大小。
应用提供固定的 16 个精选声音,不支持声音克隆。自动选角先保留用户明确指定的声音,再尝试给 Narrator 分配专用旁白声音,剩余角色轮询声音池、跳过已用声音,耗尽后才复用。它避免的是自动分配时的重复,用户主动选择的重复声音仍会保留。
服务端还会拒绝名单之外的声音 ID。前端下拉框只能约束正常操作,挡不住手工构造的 API 请求。
调用模型时,作者特别强调要固定请求头:
const res = await fetch('https://api.fish.audio/v1/tts', {
method: 'POST',
headers: {
authorization: `Bearer ${env.FISH_AUDIO_API_KEY}`,
'content-type': 'application/json',
model: 'drama-3', // 缺失此请求头会静默回退到其他模型
},
body: JSON.stringify({
text, reference_id: referenceIds,
format: 'mp3', mp3_bitrate: 128, latency: 'normal', normalize: true,
}),
signal: AbortSignal.timeout(170_000),
});
要盯住的是 model: 'drama-3'。作者援引供应商文档称,遗漏或拼错模型请求头不会直接失败,而会回退到其他模型。能返回音频,并不能证明实际调用了目标模型。
应用还会把不足 1 KB 的响应视为失败,即使状态码是 200;错误日志只记录 fish_status_<code>,不记录剧本文本和密钥。
费用、请求寿命和音频播放,要一起设计
作者按实际发送文本的 UTF-8 字节数估算输入规模,计算方式是 new TextEncoder().encode(fishText).length。角色标签、表演指令也在其中,不能只数编辑器里可见的对白。
这里需要区分:供应商通常按输入字符计费,而 UTF-8 字节数是这个应用选择的估算口径。非拉丁文字每个字符可能占 2 到 4 字节,因此这种估算会更保守,不能直接当作供应商账单公式。
用户余额以音频秒数计量,最终取「实际音频时长」与「剧本规模折算值」中较大者。生成前预留额度,完成后退回剩余部分,失败则全额退款。原文没有交代剧本规模如何折算成秒数,但明确要求客户端展示和服务端扣费使用同一个函数,避免两个地方算出不同数字。
音频时长则借助固定码率估算。请求指定 128 kbps,即每秒 16,000 字节,于是使用 Math.max(1, Math.ceil(audio.byteLength / 16000)),向上取整且至少计 1 秒。这个办法依赖恒定码率(CBR);换成可变码率,就需要解析音频帧。
整个生成链路都在一次同步 HTTP 请求里完成:校验、预留、调用模型、写入 R2、结算。没有队列,也没有轮询。代价是长剧本可能碰到请求时限,客户端断开时 Worker 也可能被取消。应用会阻止同一用户在已有待处理任务时再次生成,超过 5 分钟的待处理记录不再阻塞。
最后还有一个前端容易踩的坑:作者的示例 <audio> 在桌面能播,在 iOS Safari 却不行,原因是静态资源处理器对 Range 请求返回了完整的 200。
作者给 /samples/*.mp3 增加处理器,支持单段 bytes=start-end 和后缀形式 bytes=-N;有效范围返回 206,并设置 Content-Range、Content-Length,无法满足的范围返回 416。不理解的请求形式退回完整 200,原文指出 RFC 9110 允许这种处理。
音频在 iPhone 上播不了,先看网络响应状态,比直接怀疑编码更有方向。
我的判断
我赞成把解析规则做得可预测,再通过角色预览弥补误判。对于输入格式松散、单次调用要花钱的工具,这是合理的产品设计。但原文没有误判率、用户完成率或对照实验,不能据此断言它优于更严格的编辑器或语义解析方案。
尤其是国内用户场景,「不超过 5 个词」按空白拆分,对没有空格的中文句子帮助有限。短句里的冒号仍可能制造假角色。角色列表能让错误可见,却不保证用户一定发现;原文也没有讲清误判后的修正流程。
控制标签清理和声音 ID 白名单,是文中更扎实的边界设计。不过作者把前者类比为 SQL 转义,只适合帮助理解数据与控制语法的区别,不能据此推导出这条正则覆盖了供应商的全部输入风险。
同步生成是否合适,则取决于实际耗时和失败恢复能力。6,000 字符上限限制了输入规模,却不能保证请求总能及时完成。过期记录在 5 分钟后解除阻塞,也不等于已经完成退款或清理孤立音频。原文没有说明这些中断后的结算细节。
128 kbps 换算时长同样是工程近似。作者认为足够用于计费,但没有给出误差测试。用于小工具可以评估接受;如果余额争议会直接影响业务,就需要验证误差和供应商输出是否持续满足 CBR 前提。
能拿来做什么
- 做剧本或长文本 AI 工具时,可以在提交前展示解析出的角色和最终输入规模,让用户先确认结构,再触发付费调用。
- 在 Worker 或 Node 服务中封装 TTS 请求时,把模型请求头、声音白名单、控制标签清理和响应大小检查集中处理,避免页面之间出现不同规则。
- 实现余额预扣时,让前后端共用估算函数,并单独检查客户端断开、模型超时和存储失败后的结算路径,不能只处理正常返回。
- 给网页添加 MP3 示例播放器时,把 iOS Safari 和 Range 响应纳入验收;遇到桌面正常、手机失败,先检查 206、
Content-Range与 416 的处理。