当一台手机把"小兔子住在森林里"这八个字变成八秒钟的妈妈声音时,背后到底发生了什么?

引言

文本转语音(Text-to-Speech,TTS)是一个看起来简单、实际复杂的技术。用户只看到两端:输入一句话,输出一段音频。中间的过程被工程师们用一个 synthesize() 函数封装得严严实实。

但如果有一天音频出了问题——比如音色不对、节奏奇怪、字咬不准——开发者就不得不打开这个黑盒,看看里面到底发生了什么。本文以一个开源的零样本声音克隆模型 ZipVoice 为例,把现代 TTS 系统的完整流程拆开讲一遍。读完这篇,读者应该能回答这些问题:

  • 为什么 TTS 要分这么多步,不能一次到位?
  • "梅尔频谱"是什么,凭什么是 TTS 的中间表示?
  • vocoder(声码器)和 TTS 不是一回事吗?
  • 模型为什么需要参考音频,又是怎么从音频里"提取音色"的?

为了避免读者卡在术语上,本文会先把基础概念铺一遍,再串起完整流程。


一、声音的本质:连续振动的离散表达

物理上,声音是空气分子的振动。一段音频文件的本质,是用一串数字记录每个时刻振动的幅度。

最常见的采样率是每秒 44100 次(CD 标准)或 24000 次(语音常用)。每秒 24000 个数字,每个数字取值范围一般是 -1.0 到 1.0(浮点)或 -32768 到 32767(16-bit 整数)。

1 秒 24kHz 单声道 16-bit 音频 = 24000 × 2 字节 = 48 KB

可以把它想象成一段心电图:横轴是时间,纵轴是振动幅度。

振幅
 ↑
 │   ╱╲    ╱╲
 │  ╱  ╲  ╱  ╲
 │ ╱    ╲╱    ╲
 │╱            ╲___╱╲___
 ├─────────────────────→ 时间

这种表达叫时域表示,也叫波形。直接处理时域数据有两个问题:

问题一:数据量大。8 秒音频就有 192000 个数字。让神经网络直接生成这么长的序列,计算量和误差累积都难以承受。

问题二:信息分布不直观。一段"啊"声和一段"呃"声,从波形上看可能很相似,但人耳听起来差别很大。差别藏在频率成分里,而波形本身不直接显示频率。

因此,几乎所有现代 TTS 系统都不直接生成波形,而是先生成一个更紧凑、更结构化的中间表示——梅尔频谱


二、把声音切片:帧的概念

人在说话时,发音器官的状态变化是相对缓慢的。一个声母大约持续 30 毫秒,一个韵母可以持续 100 毫秒以上。在毫秒级别内,声音特征几乎不变。

利用这个特点,工程师把连续音频按很短的时间窗口切成片,每片叫一(frame)。常用的切片参数:

  • 帧长(window length):约 40 毫秒(1024 个采样点 / 24000 Hz)
  • 帧移(hop length):约 11 毫秒(256 个采样点)
  • 相邻帧之间有重叠

切片后,6 秒音频变成约 568 帧。每帧是一段长度为 1024 的局部波形片段。

原始音频 (144000 个采样点)
─────────────────────────────────
切片:
[帧0] [帧1] [帧2] ... [帧567]
 ↑     ↑     ↑           ↑
 重叠  重叠  重叠         重叠

每一帧虽然只有 40 毫秒,但已经足够进行频域分析——也就是看看这一帧里包含哪些频率成分。


三、从波形到频谱:每一帧的"频率指纹"

把每一帧波形做一次傅里叶变换(DFT),就得到那一帧的频谱。具体到 ZipVoice 的参数(FFT 大小 1024),每帧波形被转换成 513 个频率点的复数表达。

直接使用 513 维的频率表达对模型来说仍然太多。人耳对低频敏感、对高频迟钝(这就是为什么你能轻易分辨 200Hz 和 220Hz,但很难分辨 8000Hz 和 8500Hz),所以工程师把 513 个线性频率点重新映射到 100 个梅尔(mel)刻度频带:

  • 低频区域细分得密
  • 高频区域合并得粗

最终每一帧用 100 个数字表达。再取对数(log mel)让数值范围更稳定。

整段 6 秒音频经过这套处理,得到一个形状为 (568, 100) 的矩阵。这就是梅尔频谱(mel spectrogram),简称 mel。

可视化看就是一张图:

频率 (mel 刻度)
 ↑
100│ ░░░░░░░░░░░░░░░░░░░░  ← 高频
   │ ▒▒▒▒▒▒▓▓▓▓▒▒▓▓▓▓▒▒
   │ ▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓
 50│ ▓▓▓▓▓▓██████████▓▓▓
   │ ▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒
  0│ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒  ← 低频
   └─────────────────────→ 时间 (帧)
   0    100   200   300

颜色越深表示能量越强。这张图浓缩了所有发音器官的状态变化——音色、音调、节奏全在里面。两个不同人说同一句话,他们的 mel 看起来就是不一样的。

所以 TTS 的核心任务是:从文本生成一张这样的 mel 图。


四、ZipVoice 的五步管线

理解了 mel 是中间表达后,整个 ZipVoice TTS 的流程就清楚了:

文本字符串
    ↓ ① Tokenization
token ID 序列
    ↓ ② Encoder (神经网络)
text_condition (帧级别条件向量)
    ↓ ③ Decoder × 4 步 (Flow Matching)
mel 频谱
    ↓ ④ Vocoder (神经网络)
STFT 复数表示
    ↓ ⑤ ISTFT (反傅里叶变换)
波形 → WAV 文件

下面逐步展开。

第一步:Tokenization(文本 → 数字)

神经网络只能处理数字。中文字符 “小” 在模型里是 352,“兔” 拆成 t0 (302) + u4 (306)。

ZipVoice 的中文 tokenization 使用拼音 lexicon 查表

  1. lexicon.txt 把每个汉字映射到声母+韵母+声调形式
  2. tokens.txt 把每个拼音单元映射到整数 ID
小兔子住在森林里。
↓
[x0, iao3, t0, u4, z0, i5, zh0, u4, z0, ai4, s0, en1, l0, in2, l0, i3, .]
↓
[352, 241, 302, 306, 354, 223, 355, 306, 354, 167, 300, 198, 270, 250, 270, 221, 10]

最终是 17 个整数。

值得注意的是,ZipVoice 没有用国际音标(IPA),也没有用 BPE 分词,就是简单的拼音查表。这种方案对中文来说足够了,缺点是只能处理 lexicon 里有的字(68037 个汉字),生僻字会被忽略。

英文场景下,更多 TTS 模型使用 IPA + espeak-ng 做音素化。两套方案没有优劣,选哪个取决于训练数据。

第二步:Encoder(理解文本 + 配齐音色)

Encoder 是一个神经网络,它的任务是回答两个问题:

  1. 每个字应该说多长?比如"小"和"住"虽然都是一个字,但实际发音长度不同。Encoder 需要为每个 token 分配合适的帧数。
  2. 用什么音色说?通过参考音频提供的音色信息,让生成的 mel 在音色维度上对齐。

Encoder 接收四个输入:

输入名 形状 含义
tokens (1, 17) 待合成文本的 token
prompt_tokens (1, 17) 参考音频对应文本的 token
prompt_features_len (1) 参考音频的帧数 568
speed (1) 语速因子 1.0

输出 text_condition,形状 (1, num_frames, 100)。其中 num_frames 是参考音频帧数加上模型预测的合成音频帧数。比如对应上述输入,Encoder 输出 (1, 1136, 100)——前 568 帧对齐参考音频,后 568 帧规划将要合成的语音。

text_condition 不是 mel 频谱,它是 mel 的"草图"——告诉下游 Decoder 应该生成什么样的内容、什么节奏,但不包含具体的音色细节。具体的音色生成由下一步完成。

第三步:Decoder(从噪声生成 mel)

这一步是整个 TTS 系统的计算核心,也是最有意思的部分。

Decoder 的任务是从一段随机噪声开始,生成一张精确的 mel 频谱图。这听起来很神奇,但其实就是扩散模型那套思路的演进版——Flow Matching(流匹配)。

具体过程是这样的:

初始:x = randn(1, 1136, 100)  ← 完全是雪花点
目标:x = 真实 mel 频谱

第 1 步: x = x + Decoder(t=0,    x, text_condition, ...) × 0.143
第 2 步: x = x + Decoder(t=0.143, x, text_condition, ...) × 0.190
第 3 步: x = x + Decoder(t=0.333, x, text_condition, ...) × 0.267
第 4 步: x = x + Decoder(t=0.6,   x, text_condition, ...) × 0.4
最终:   x ≈ 真实 mel

每次 Decoder 给出一个速度向量 v,告诉 x 应该往哪个方向变。t 是当前的"进度"参数,从 0 走到 1,0 表示纯噪声,1 表示终点。

为什么是 4 步

理论上 Flow Matching 可以走任意多步。论文里展示的对比实验:

  • numSteps = 1:质量很差(一步迈得太大,容易踏空)
  • numSteps = 4:质量已经很好(实际部署常用)
  • numSteps = 16:质量略好但慢 4 倍
  • numSteps = 32:边际收益微乎其微

ZipVoice 通过蒸馏(distillation)把训练时的多步流压缩成 4 步等效流。这就是它能做到"4 步生成高质量语音"的原因。

为什么 Decoder 这么慢

每一步 Decoder 都要把 (1136, 100) 这么大的矩阵跑一遍完整的 Transformer 网络。1136 帧 × 100 维 = 113600 个浮点数,Transformer 内部还有几十层注意力机制和前馈层。在中低端 ARM 芯片上跑一次这样的前向传播需要 13-15 秒。

整个 Decoder 阶段的总时间 = 单步时间 × numSteps = 13 × 4 = 52 秒。这是当前端侧 TTS 速度瓶颈的最大来源。

走完 4 步后,x 就是合成的 mel 频谱。但模型只关心后半段(去掉对应参考音频的那 568 帧),所以最终用于 vocoder 的 mel 是 (568, 100)

第四步:Vocoder(mel → 频域复数)

Decoder 已经生成了 mel 频谱,但 mel 只是"频率维度的指纹",丢失了相位信息(每个频率点是从波峰开始还是波谷开始)。要还原成可听的波形,必须把相位补回来。这是 vocoder 的工作。

ZipVoice 用的 vocoder 叫 vocos,它是一个独立训练的神经网络,输入 mel,输出 STFT 的频域表示。

具体来说,vocos 输出三个张量:

输出 形状 含义
mag (1, 513, 568) 每帧每个频率点的幅度
x (1, 513, 568) cos 分量(相当于复数的水平方向)
y (1, 513, 568) sin 分量(相当于复数的垂直方向)

把它们组合成复数:

real = mag * x   # 实部
imag = mag * y   # 虚部

为什么不直接让 vocoder 输出 real 和 imag?因为分开预测幅度和方向更容易学习——模型一个网络头预测"声音多响",另两个头预测"频率的相位结构",比让一个头同时预测两个高度耦合的量稳定得多。

第五步:ISTFT(频域 → 时域波形)

有了每一帧的复数 STFT,最后一步是反短时傅里叶变换(ISTFT):

  1. 每一帧做 IFFT:把 513 个复数还原成 1024 个时域采样点
  2. 加窗:每帧波形乘上 Hanning 窗(两端衰减的钟形曲线)
  3. 重叠相加:相邻帧之间有 768 个采样点的重叠,把它们叠加形成连续波形

最终得到大约 145000 个浮点数(24000 × 6 秒)。把 [-1, 1] 范围内的浮点转成 16-bit 整数,加上 WAV 文件头,就是最终的音频文件。

ISTFT 是一个完全确定性的数学变换,没有神经网络参与。但它的实现质量直接影响速度——用 FFT 算法(O(n log n))和用暴力 DFT(O(n²))能差出 60 倍。


五、参考音频是怎么提供"音色"的

读到这里,读者可能会有一个疑问:模型从来没"听过"参考音频的实际波形,只看到它的 mel,怎么就能克隆音色?

答案藏在两个细节里:

细节一:参考音频的 mel 同时输入了两个地方

  1. 它的帧数 prompt_features_len 输入到 Encoder(用来对齐时序)
  2. 它的 mel 数据 prompt_features 直接拼接到 Decoder 的 speech_condition 前半部分

也就是说,Decoder 在做 Flow Matching 时,看到的是这样一个状态:

speech_condition: 
[参考音频 mel, 568 帧] [全 0, 568 帧]
    ↑                      ↑
   音色锚点            待生成区域

x (噪声 → mel):
[随机噪声, 1136 帧]

模型被训练成"在保持前半段 mel 不变的前提下,生成后半段使整体看起来像同一个人在说话"。这是一个条件生成问题——参考音频成了生成条件,模型必须让生成的 mel 在音色统计特征上与参考音频一致。

细节二:模型在大规模数据上学过"音色一致性"

ZipVoice 用 100k 小时多语言数据训练。在训练时,每个样本都被切成两段:前半段作为 prompt,后半段作为目标。模型反复学习"如何让前后两段的音色在 mel 上保持一致"。

所以推理时,给模型一段陌生的参考音频,它能把音色特征"延续"到合成段落。这就是所谓的"零样本声音克隆"——不需要额外训练就能克隆任何说话人的音色。


六、串联起来看

把整个流程的形状变化列出来,能更直观地理解信息是如何流动的:

阶段 输入 输出
Tokenization 字符串 “小兔子住在森林里。” int32[17]
参考音频 mel 提取 float[145361] (6 秒 PCM) float[568, 100]
Encoder tokens(17) + prompt_tokens(17) + len(1) + speed(1) float[1136, 100]
Decoder × 4 text_condition(1136, 100) + 4 步迭代 float[1136, 100]
提取生成段 float[1136, 100] float[568, 100]
Vocoder float[1, 100, 568] mag/x/y, 各 float[1, 513, 568]
ISTFT 复数 STFT float[145408]
保存 WAV float[145408] WAV 文件 (8.7 秒)

注意从 145361 个采样点(6 秒输入)到 145408 个采样点(6 秒生成)+ 568 帧(8.7 秒生成)的对应关系——TTS 生成的总长度由 Encoder 决定。


七、为什么 TTS 系统设计成这么多步

读到这里,读者应该能理解为什么不能一步从文本到波形:

理论上不可能:文本到波形之间有几个数量级的信息扩展。一句 17 个字的文本,对应 145408 个数字的波形。直接学习这种端到端映射会让模型规模超过训练数据能支撑的程度。

工程上分层有利

  • Tokenizer 和 Lexicon 处理语言学知识(无需机器学习)
  • Encoder 处理"说什么、说多长、什么音色"(高层语义)
  • Decoder 处理"频谱长什么样"(中层结构)
  • Vocoder 处理"具体的相位细节"(低层信号)
  • ISTFT 是纯数学(无需模型)

每一层各司其职,任何一层有问题都可以独立调试。这就是为什么本文一开始能列出"7 个调试细节"——因为每个细节对应一个独立的处理阶段。


八、结语

现代 TTS 系统的复杂度远超用户感知。一个 tts.synthesize(text) 调用背后,是文本规范化、音素查表、神经网络编码、迭代去噪、声码器频域生成、傅里叶反变换、PCM 写盘等十几个步骤的串联。

理解这套管线对工程师有两层意义:

第一,调试时知道往哪看。音色不对查 mel;杂音查 vocoder 输入;节奏不对查 Encoder;数字读不准查 tokenizer。每种现象都有对应的调试入口。

第二,优化时知道改哪里。如果想加速,Decoder 占 95% 的时间,必须从那里下手。如果想换音色克隆方式,研究 prompt 是怎么传到 speech_condition 的就行。如果想支持新语言,重做 lexicon 即可,模型可能不用动。

希望这篇文章帮读者建立起对 TTS 内部结构的整体直觉。下一篇会专门讨论 Decoder 那部分的算法细节——为什么 Flow Matching 能做到 4 步生成,它和扩散模型的本质差异在哪里。


附:术语速查

术语 通俗解释
Token 文本被切分后的最小单位,对应一个整数 ID
帧(frame) 一小段(约 11ms)的音频片段
Mel 频谱 一段音频的"频率指纹",形状 (帧数, 100)
STFT 短时傅里叶变换,把时域波形转到频域
ISTFT 反短时傅里叶变换,频域转回时域
Encoder 处理输入信息的神经网络
Decoder 生成输出的神经网络
Vocoder 把频谱转成可听波形的神经网络
Flow Matching 一种迭代去噪的生成方法,扩散模型的演进版
numSteps Flow Matching 的迭代次数,越多越精细越慢
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐