一段语音从一句话到耳朵 —— 现代 TTS 完整旅程
当一台手机把"小兔子住在森林里"这八个字变成八秒钟的妈妈声音时,背后到底发生了什么?
引言
文本转语音(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 查表:
- 从
lexicon.txt把每个汉字映射到声母+韵母+声调形式 - 从
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 是一个神经网络,它的任务是回答两个问题:
- 每个字应该说多长?比如"小"和"住"虽然都是一个字,但实际发音长度不同。Encoder 需要为每个 token 分配合适的帧数。
- 用什么音色说?通过参考音频提供的音色信息,让生成的 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):
- 每一帧做 IFFT:把 513 个复数还原成 1024 个时域采样点
- 加窗:每帧波形乘上 Hanning 窗(两端衰减的钟形曲线)
- 重叠相加:相邻帧之间有 768 个采样点的重叠,把它们叠加形成连续波形
最终得到大约 145000 个浮点数(24000 × 6 秒)。把 [-1, 1] 范围内的浮点转成 16-bit 整数,加上 WAV 文件头,就是最终的音频文件。
ISTFT 是一个完全确定性的数学变换,没有神经网络参与。但它的实现质量直接影响速度——用 FFT 算法(O(n log n))和用暴力 DFT(O(n²))能差出 60 倍。
五、参考音频是怎么提供"音色"的
读到这里,读者可能会有一个疑问:模型从来没"听过"参考音频的实际波形,只看到它的 mel,怎么就能克隆音色?
答案藏在两个细节里:
细节一:参考音频的 mel 同时输入了两个地方。
- 它的帧数
prompt_features_len输入到 Encoder(用来对齐时序) - 它的 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 的迭代次数,越多越精细越慢 |
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)