Skip to main content

04 - 说:文字怎么变成音频

机器人已经想好了要说什么:「好的,少冰珍珠奶茶一杯,十八块」。现在要把这 15 个字变成一段能播的声音。

最直接的想法是让模型直接吐波形。算一下就不行了。

这句话念出来 3 秒,24 kHz 采样就是 72000 个数字。模型每吐一个数要跑一遍完整前向 —— 72000 步,每步就算只要 5 毫秒,也得 6 分钟。

问题出在粒度:一个采样点几乎不携带信息,逐点决定等于让模型做七万次没信息量的决策。

出路是中间垫一层。

先让模型吐一种「粗一点」的东西,一秒几十个,几十步就够。再拿一个小网络把这几十个还原成七万个采样点 —— 这个小网络不用一个一个吐,一次就算完。

它叫声码器(vocoder)。

这就是两段式,现在所有能用的 TTS 都是这个结构。

一、中间那层到底是什么

先把「粗一点的东西」说清楚,因为后面所有取舍都从它来。

音频进模型有两条路,选哪条不看审美,看这段音频是要被听懂还是要被生成

分歧点:这段音频是输入,还是输出路线 A · 连续特征 —— 用于听懂(ASR、音频理解)波形16 kHzlog-mel100 帧/秒卷积降采样stride 250 向量每秒+ 不做量化,声学细节全保留,识别准确率的上限更高+ 序列短:一秒只有 50 个位置,Whisper 一个 30 秒窗口 1500 个− 不可逆:连续向量还原不回波形,所以这条路只能做输入− 编码器是密集计算,长音频的 prefill 成本全压在它身上Whisper 的常数就在 whisper/audio.py:hop 160 即 10 ms 一帧,两层 stride-2 卷积后一个 token 覆盖 20 ms路线 B · 离散 codec token —— 用于生成(TTS、语音输出)波形24 / 48 kHzcodec 编码器卷积下采样RVQ 量��化C 张码本整数 id帧 × C+ 可逆:解码器能把 id 还原成波形,所以能当模型的输出+ 是离散 id,可以直接套现成的自回归语言模型来建模− 量化有损,码本数不够时音质塌,够了序列又变长− 一帧对应 C 个 id,怎么塞进一维序列是个真问题,见第四节EnCodec 24 kHz 在 6 kbps 用 8 张码本,1024 词表即每个 id 10 bit,反推帧率 75 Hz很多 omni 模型两条路同时用:输入侧走 A 听懂用户说什么,输出侧走 B 生成回答的语音。同一个请求里两套音频表示并存,是 omni 流水线复杂度的来源之一。
路线 B 的「可逆」是它存在的全部理由。如果只做识别,没人会愿意先把音频压有损再喂给模型 —— 这也是为什么 ASR 模型几乎清一色走 mel,而 TTS 模型清一色走 codec。

生成侧必须走右边那条:只有离散 id 能被解码器还原成波形,连续向量不行。

那怎么把一小段声音变成整数?靠一张码本 —— 一张查找表,来一个向量就找最近的一行,记下行号。

问题是一张 1024 行的表只能表达 1024 种声音状态,编码一帧语音远远不够。把表做大?表越大查得越慢、越难训,做到十万行就不现实了。

RVQ(Residual Vector Quantization,残差向量量化)换了个方向:一张表不够就串几张,每张负责修正前面几张留下的误差。

一帧的向量被四张小码本逐层逼近 —— 图中每一步的「残差」都是上一步没编准的部分帧向量 x编码器输出码本 1 · 1024 行取最近行 → id₁ = 731残差 r₁ = x − 码本1[731]码本 2 · 1024 行量化 r₁ → id₂ = 88码本 3 · 1024 行量化 r₂ → id₃ = 402码本 4 · 1024 行量化 r₃ → id₄ = 15这一帧 = 四个整数[731, 88, 402, 15]为什么这样堆划算四张 1024 行的表,组合空间是 1024⁴ ≈ 1.1×10¹²,而参数量只是四张小表 —— 单张表要达到同样表达力得有一万亿行。还能截断:只取前两张码本也解得出完整波形,只是音质差 —— 码率可调就是这么来的。代价一:层与层之间是严格有序的 —— id₂ 的含义依赖 id₁ 的取值,所以不能把四个 id 当四个独立分类问题随便预测。代价二:一秒音频的整数个数是「帧率 × 码本数」。25 fps × 8 码本 = 200 个/秒,比同样时长的文字多一个量级。这两条代价直接催生了下一节的两种排布方案。
RVQ 的层间依赖是后面所有工程复杂度的根:如果各层独立,一帧就可以用 C 个并行的分类头一次算完;正因为有序,才必须在「一帧内串行解 C 次」和「把 C 层错开时间轴」之间做选择。

几个真实 codec 的规格,都能从各自 README 的码率反推:

codec采样率帧率码本数每秒整数个数
EnCodec 24 kHz @ 6 kbps24 kHz75 Hz8600
DAC 44 kHz @ 8 kbps44.1 kHz~86 Hz9~775
SNAC 24 kHz @ 0.98 kbps24 kHz多尺度分层3 层~120
Higgs Tokenizer24 kHz25 Hz8200
MOSS-Audio-Tokenizer-v248 kHz 立体声12.5 Hz12150

后两行是这两年的方向:帧率往下压,码本数往上加。原因很实际 —— 自回归的步数由帧率决定,而一帧内的多个码本可以在一步里算完。75 Hz 降到 12.5 Hz,同样长度的音频少跑六分之五的步数。

二、一帧几个码本,怎么塞进一维序列

新问题:RVQ 给出的是 [帧数, 码本数] 的二维网格,而模型只会读写一维序列。

摊平只有两个方向 —— 沿时间走(每步吐一帧的全部码本),还是沿码本层走(把各层错开到不同时间步)。现有模型就照这两个方向分成了两派:

同一段 4 帧 3 码本的音频,两种排法方案 A · delay pattern —— Higgs Audio v3、MOSS-TTS Delay码本 1f1f2f3f4码本 2f1f2f3f4码本 3f1f2f3f4同一列是同一个解码步:一步同时吐 3 个数,但它们属于不同帧+ 主干每步只跑一次,步数 = 帧数 + 码本数 − 1,接近纯 AR+ 层间依赖被时间错位天然满足,不需要额外的小网络− 开头结尾各有 C−1 步是空的,短音频尤其亏− 出音频前要「去延迟」重排,流式切块时边界要额外对齐方案 B · 帧内并行 —— MOSS-TTS-Local v1.5、Qwen3-Omni talker主干 AR每帧一个隐状态帧内小 transformer串行解 12 个码本这一帧的全部码本一次主干 forward 内部套 12 次微步,对外仍是「一步一帧」+ 序列长度就是帧数,12.5 Hz 时一秒只占 12.5 个位置+ 没有对齐负担,一帧解完就能直接送去解码波形− 微步是小算子密集调用,极易变成 launch-bound− 主干与小网络两套 KV,显存与 CUDA Graph 都要分别处理SGLang-Omni 的实测:这 1+12 步微解码 eager 约 22 ms/帧,图捕获后回放约 4 msMOSS-TTS 同时有 delay 与 local 两个版本,参数量和 codec 都一样 —— 这两行是同一支团队在同一个模型上对这个取舍的 A/B 对照,值得当作范例读。
方案 B 的 22 ms → 4 ms 不是模型变快了,是把上百次微小的 kernel launch 打包成一次 CUDA Graph 回放。这类「算得不多但发得太碎」的瓶颈是多码本 TTS 的通病,07 篇会展开。

这两派各自的官方架构图放在一起看最清楚。先是 delay pattern 那一路:

Higgs Audio v3 的生成架构:自回归骨干消费交错的文本与音频 token,参考文本与参考音频作为前缀,音频 token 经 Higgs Tokenizer 编解码
出处:sglang-omni docs/_static/image/higgs-architecture.png。左下角的 Ref TextRef Audio 就是 03 篇讲的克隆前缀;右侧 Multi-turn Generation 里 T 和 A 两种方块交替出现,是「一个序列同时承载文本和音频」的直接图示。

再是帧内并行那一路,骨干每帧只吐一个隐状态,由一个小的帧内 transformer 展开成固定深度的 RVQ 块:

MOSS-TTS-Local-Transformer-v1.5 的架构:Qwen3 骨干每帧产出一个全局隐状态,帧内 transformer 将其展开为十二个 RVQ 码本
出处:sglang-omni docs/_static/image/moss-tts-arch-local.png。同一支团队的 delay 版和 local 版参数量与 codec 都相同,是这个取舍难得的 A/B 对照。

三、两段各自的脾气

中间那层用的就是 02 篇的 codec token:一秒二三十帧,每帧几个整数。模型吐它,跟吐文字 token 一模一样。

关键是这两段的脾气正好相反 —— 而这个「相反」正是后面所有调度麻烦的源头:

两段的计算性质完全相反,所以调度方式也必须不同第一段 · AR codec 语言模型文本 token+参考前缀自回归解码一步一帧码本 id 序列帧 × C步数不定:说多久取决于文本多长,模型自己决定何时停有 KV cache,前缀可复用,显存随步数增长吐字延迟受批大小和主机调度影响,与 LLM 完全同构调度方式:连续批处理 + KV cache 管理,直接套 LLM 那一套第二段 · 声码器 / codec 解码器��码本 id 序列一批帧卷积上采样一次算完波形24~48 kHz步数固定:给多少帧就是多少计算量,没有自回归没有 KV cache,但流式解码时有跨块的因果状态要维护形状规整,是批处理和 CUDA Graph 的理想对象调度方式:攒批一次算,或按块流式吐 —— 两者不能兼得把两段塞进同一个调度器是行不通的:一个要按 token 步调度、另一个要按块调度,一个显存随时间涨、另一个恒定。SGLang-Omni 因此给每一段配了不同的调度器(AR 段用 OmniScheduler,声码器用 SimpleScheduler 或流式的 Code2WavScheduler),这是它多阶段设计的起点。
两段的分工也决定了故障形态:AR 段出问题表现为「说错话、说不停、卡住」,声码器出问题表现为「内容对但有杂音、有咔哒声、块与块之间不连续」。听到问题先判断在哪一段,能省掉一半排查时间。

四、第一段与文本大模型同构

上一节说第一段「跟吐文字 token 一模一样」。这句话不是打比方 —— 它是字面意义上的一样。

把两边逐项摆开看,会发现推理框架关心的每一项都对得上,只有最后两行不同。这件事的价值极大:它意味着过去五年给文本大模型做的所有优化,TTS 可以原样拿来用,一行不改。

这一项文本大模型TTS 的 AR 段
骨干Qwen3 / Llama 类 decoder-only同样是 Qwen3 —— MOSS-TTS-Local 直接用 Qwen3-4B
KV cache按 token 分页管理一模一样,一帧占一个位置
连续批处理新请求随时插入运行中的批一模一样
前缀缓存相同前缀命中 radix 树(一种前缀树,把已缓存的 token 序列按公共前缀组织起来,来一个新请求就沿树往下比,比到哪算哪)同一个参考音色的前缀可复用,但键要特殊处理,见第三节
CUDA Graphdecode 步按批大小分桶捕获一模一样,且收益更大 —— 每步计算量更小,启动开销占比更高
输出头一个 softmax,词表几万到十几万C 个码本头,每个词表约一千,还要处理层间依赖
每步输出一个 token一帧,即 C 个整数 —— 采样和收集的主机侧开销因此高一个量级

只有最后两行不一样,而这两行正是后面所有 TTS 特有优化的来源。

SGLang-Omni 把这层关系用得很直白:接入一个新 TTS 模型时,AR 段的工厂函数调 build_sglang_server_argscreate_sglang_infrastructure,拿到 SGLang 的 (model_worker, tree_cache, req_to_token_pool, token_to_kv_pool_allocator, model_config) 五件套,再包一层 OmniScheduler 就跑起来了 —— KV cache、批选择、抢占、请求上限全部是 SGLang 的原件,没有重写。

把两段式落到一个真实模型上,MOSS-TTS 的 delay 版官方架构图正好把「AR 段吐码本、声码器还原波形」这条分界画了出来:

MOSS-TTS delay-pattern 版本的架构:自回归骨干按 delay 模式产出多码本序列,再由 MOSS-Audio-Tokenizer 解码成波形
出处:sglang-omni docs/_static/image/moss-tts-arch-delay.png。骨干和 tokenizer 之间那条边,就是本篇说的两段分界 —— 左边是自回归、有 KV cache、步数不定,右边是一次算完、没有 KV、形状规整。

五、零样本克隆

「用 5 秒音频克隆音色」听起来像是某种模型适配,实际做的事情朴素得多:把参考音频用同一个 codec 编码成 token,连同它的文字一起拼在生成序列前面,然后让模型接着往下说。模型在预训练里见过大量「同一个人先说 A 再说 B」的数据,于是它会自然地保持音色一致。

生成序列的实际长相参考文本「今天天气不错」参考音频的码本 token5 秒 × 帧率 × C 个整数目标文本「会议改到三点」← 模型从这里开始生成生成出的码本会自动带上前面那个音色三条由此推出的工程结论① 参考文本不是可选项不给参考文本,模型只能从音频token 反推内容,克隆稳定性下降SGLang-Omni 的文档对 Higgs 和MOSS 都写了「显著提升质量」② 参考编码是重复劳动同一个音色会被成千上万次请求复用,每次重编一遍纯属浪费解法:按内容键做 LRU 缓存,同键并发时单飞�,只编一次③ 前缀缓存的键很危险参考 token 在序列里常以占位符形式出现,两个不同音色 id 相同拿 id 当 radix 键会让 A 的音色泄漏到 B 的输出里缓存键要带够维度才安全。SGLang-Omni 的 ReferenceEncodeKey 里放了七项:模型 id、模型版本、编码器 id、编码器配置哈希、产物类型、输入键、选项键 ——换个模型版本或换套编码器参数,旧缓存自动失效,不会拿着上一个版本编出来的音色接着用。
③ 不是理论风险。SGLang-Omni 的 TTS 接入文档把它写成一条明确警告:前缀里嵌了连续嵌入时,radix 缓存键必须由嵌入内容派生,否则「你会看到一个用户的音频泄漏到另一个用户那里」。这也是09 篇里内容哈希机制存在的理由。

顺带说清楚「注册音色」和「临时参考」的区别:前者有独立的生命周期和失效路径(用户上传、命名、长期存在),后者是单次请求里带的一段音频。SGLang-Omni 把这两类分开成 SpeakerArtifactCacheReferenceEncodeService 两套,理由就是键空间和失效时机不一样,混在一起会导致一方的清理逻辑误伤另一方。

六、首包延迟的构成

非流式 TTS 的体验很差 —— 合成一段 10 秒的语音要等两三秒,用户全程盯着空白。流式的目标是把第一段可播放音频尽早送出去,之后边合成边播。

首包延迟的构成 —— 横轴是时间,宽度大致按典型耗时比例解析参考音频编码AR 预填充自回归生成够第一块的帧数声码器解第一块传输缓存命中就归零最大的一块,且与首块帧数成正比块越小越快把首块帧数调小只要攒够 1 帧就送去解码,红色那段几乎归零Higgs 的实测:首块设为 1 帧后英文 TTFC 降了 73.6%第一个 PCM 包只有 1920 字节,用户几乎立刻听到声音代价:块小则声码器调用次数多,总吞吐下降容易归错因的一处同一个 PR 里还引入了裸 PCM 流式传输,很自然会把提速算到「换了传输格式」头上但实测 PCM 相对 SSE 近乎中性,收益全部来自首块帧数结论:优化前先把变量拆开,否则会记住一条错的经验还有个连续性约束:首块太小可能让块边界落在声学上不该切的位置,产生咔哒声。所以框架的默认值不是「越小越好」,而是模型自己给一个「连续性安全」的首块大小,客户端想更激进可以显式覆盖 —— 把选择权交出去,但默认值保守。
这张图也解释了为什么参考音频缓存的收益比看起来大:它砍掉的那一段(琥珀色)在首包延迟里的占比,在没有缓存时可以达到两三成,而且是每次请求都要付一遍的固定成本。

七、声码器的批处理与流式

声码器这一段有个内在矛盾,而且没有两全的解法。

它天生适合攒批:形状规整、没有自回归、一次算完。可流式又要求它尽早、分块吐出来。这两件事直接打架 —— 攒批要等,尽早要不等。

把两种做法画在同一条时间轴上:

同样四个请求,两种解法攒批解码 · 吞吐优先请求 A~D四个都等着,直到码本全部生成完一次批解码四段音频吞吐最高,但首声要等到这里流式解码 · 首声优先请求 A块1块2块3块4块5第一块出来就能播,首声快得多但每次调用都很小,GPU 算得快、发得慢两条都要,只能各自补短板:攒批那条按完全相同的帧数分桶(非因果解码器补零会污染结果),流式那条把有状态的解码步捕成 CUDA Graph。实测里流式声码器能占掉单请求墙钟时间的 58%~63% —— 它慢不是因为算得多,是因为调用太碎。
选哪条取决于场景,不存在通用答案:实时对话必须流式,批量配音应该攒批。同一套服务想两个都要,就得让客户端按请求指定,而不是在服务端定死。

两条路各自的做法、收益和代价:

  • 攒批解码:把多个请求的码本堆成 [B, C, T] 一次解,吞吐最高。SGLang-Omni 给 Higgs 接上真正的批解码后,并发 16 下吞吐 +7.8%、p99 延迟 −20%,并发 1 时中性。注意一个细节:非因果的解码器不能对短样本补零后一起解,补零会污染结果,所以只能按完全相同的长度分桶再堆。
  • 流式解码:每来一小块码本就解一块波形,且要跨块保持因果状态(注意力缓存、相位偏移)。它的问题是每次调用都很小,GPU 上算得快、发得慢 —— 实测里这一段能占到单请求墙钟时间的 58%~63%。解法是把这个有状态的解码步也捕成 CUDA Graph,SGLang-Omni 按帧数 T 分别捕获、按需回放,并发 8 下声码器耗时降了 40%,端到端 p50 降 22%,且输出逐位一致。
「逐位一致」是个能直接抄的验收标准

声码器的改动很难用耳朵验证 —— 人听不出 0.1% 的差别,但那 0.1% 可能是个真 bug。

把「改动前后输出的波形张量完全相同」当验收条件,比组织一轮主观听测既可靠又快。凡是声称「不改变输出」的优化,都该按这个标准验。

八、扩散路线的位置

上面整条链路是「离散 token + 自回归」的路线。另一条并行的技术路线是用扩散或流匹配来生成中间表示:AR 段只负责生成语义 token(内容和韵律),声学细节交给一个扩散模型从噪声里迭代出来,再过声码器。CosyVoice 系、Ming-Omni 的 talker(CFM + DiT)走的都是这条路。

两条路线的对比、扩散本身的机制、以及它对调度提出的完全不同的要求,是下一篇的内容。这里先记一个结论:扩散段的步数是固定的、可预知的,这跟自回归「不知道要跑多少步」是根本区别,也因此它的批处理和显存规划要简单得多,代价是每一步都要跑一次完整的网络。

九、指标

最后统一一下衡量快慢的词,因为 TTS 这边最容易出现「数字很漂亮但体验很差」。

先说清楚 RTF 是什么:Real-Time Factor,实时率 —— 合成 1 秒音频要花几秒。0.05 就是合成 1 秒音频花 0.05 秒,比实时快 20 倍。小于 1 才跟得上播放。

典型情形:有人告诉你「RTF 0.05,比实时快二十倍」。你接进去,用户抱怨说完话要等两秒才有声音。两边都没撒谎 —— RTF 量的是整段做完的总账,用户感觉到的是第一声什么时候来。

三个数必须一起看:

指标怎么测说明
WER / CER把合成出来的音频再用 ASR 转回文字,跟原文比客观可复现的「说没说对」。Seed-TTS-eval 是常用集,MOSS-TTS-Local 报的是英文 WER 1.86%、中文 CER 1.30%
RTF合成 1 秒音频花的秒数报的时候必须带并发数。并发 1 的 RTF 和并发 16 的 RTF 是两个数
TTFB / TTFC请求发出到第一块音频流式场景的唯一体感指标。跟 RTF 不相关,甚至常常反向

「跟 RTF 反向」这点要展开:把首块调到 1 帧能大幅降低 TTFC,但声码器调用变碎,总吞吐和 RTF 都会变差。选哪个取决于场景 —— 实时对话选 TTFC,批量配音选 RTF。同一套服务想两个都要,就得允许客户端按请求指定,而不是在服务端定死一个值。

下一篇06 - 扩散与流匹配