Glow-TTS这篇论文,我在读的时候最大的感受是:它把TTS从“一个字一个字往外蹦”的自回归时代,硬拉进了“一次性生成整句话”的并行时代。而且它不像FastSpeech那样依赖外部对齐工具,而是自己把文本和语音之间的对齐关系学了出来。这篇文章我就以读书笔记的形式,把Glow-TTS的模型结构、训练推理逻辑、以及我实际部署到sherpa-onnx和导出onnx时踩过的坑,一次性讲清楚。
先说明一下,这篇笔记适合两类人:一类是想搞清楚Glow-TTS内部原理、准备读论文或者复现的初学者;另一类是已经在用VITS、想回头理解前置模型、或者想用sherpa-onnx做CPU端部署的工程人员。我不会只贴公式,会把每个模块“为什么这么设计”“训练和推理有什么区别”“实际跑起来会遇到什么”都交代一遍。
1. 为什么读Glow-TTS:模型设计整体拆解
1.1 从自回归到非自回归:TTS的痛点在哪
之前的TTS主流是自回归模型,比如Tacotron 2。它的生成方式有点像人写字:先写第一个字,再看第一个字写第二个字,依此类推。这种方式有一个天然问题——生成速度受限于步数。一个10秒的句子,可能需要几千步推理,每一步都要等上一步的结果,GPU再快也没法并行。
而且自回归模型还有一个隐蔽的问题:误差累积。前面一个音素生成得不够准,后面所有音素都会跟着偏。训练时用的是真实语音做条件,推理时用的是自己生成的结果,这个gap叫exposure bias。你听一些早期TTS会觉得“越往后越糊”,就是这个原因。
非自回归模型的想法很直接:既然我有整句的文本,能不能一次性把整句的mel谱都算出来?FastSpeech做到了,但它依赖一个外部工具——Tacotron 2的attention对齐结果——来提取每个音素的时长。这就很尴尬:训练流程被割裂成两段,而且外部对齐如果错了,整个模型的上限就被锁死了。
Glow-TTS要解决的就是这两个问题:**既要并行生成,又要端到端学习对齐,还要保证生成质量不输自回归模型。**它的思路借鉴了Glow(一种生成流模型),把mel谱直接建模成可逆变换,训练时最大化似然,推理时采样隐变量就行。
1.2 Glow-TTS的核心思路:流模型加软对齐
Glow-TTS全称是A Generative Flow for Text-to-Speech via Monotonic Alignment Search。名字已经把核心说完了:用流模型做声学特征生成,用单调对齐搜索把文本和语音对齐起来。
整个模型分成三段:文本编码器(Text Encoder)、流解码器(Flow Decoder)、时长预测器(Duration Predictor)。训练时,文本经过编码器得到每个音素的均值和方差,流解码器把真实mel谱变换成隐变量z,然后用单调对齐搜索找到文本帧和语音帧的对应关系。推理时,先从标准正态分布采样一个z,用流解码器的逆变换把z变成mel谱,再用时长预测器决定每个音素说多久,把编码器输出按时长展开,喂给逆流。
这套设计与VAE颇有渊源。具体来说,文本编码器输出的均值和方差,其实是在定义一个分布——每个音素的语音特征应该长什么样。但Glow-TTS没有像VAE那样采样,而是直接取均值(推理时)或做对齐(训练时),把不确定性主要留给了时长预测器和采样噪声。这个设计简化了训练,但也要求流解码器本身的表达能力足够强,否则生成出来的mel谱会过于“平均”,缺少自然语的抑扬顿挫。
1.3 代码仓库怎么看:跑通实验的思路
论文官方实现是ESPnet风格的PyTorch代码,仓库结构不算复杂,但初次看容易迷失。我建议按这个顺序读代码:
glow_tts.py:模型主体,看forward和inference两个方法就够了。forward是训练路径,inference是推理路径。transformer.py:文本编码器,本质是Transformer加上相对位置编码(relative position embedding)。flows.py:流解码器,里面有ActNorm、1x1可逆卷积、Affine Coupling Layer等模块。monotonic_alignment.py:MAS的numpy实现,训练时的核心工具。duration_predictor.py:随机时长预测器,训练和推理逻辑完全不一样。
我第一次跑实验时,最困惑的地方是“为什么训练要分两阶段:先算对齐,再算loss”。后来看明白了——对齐搜索是单独的前向过程,它的结果(路径矩阵)作为中间变量传给loss计算,梯度不会回流到文本编码器和流解码器。所以MAS本质上是给模型提供“软标签”的模块,而不是可微分的网络层。
2. 核心细节解析与实操要点
2.1 单调对齐搜索(MAS)的实现与理解
MAS是整个Glow-TTS最核心的模块。它的目标很简单:给定文本编码器输出的每个音素的分布(均值和方差),以及流解码器变换后的隐变量z,找到一条“最有可能”的对齐路径,把z的每一帧分配给某个音素。
这里有个关键前提:对齐必须是单调的。也就是说,音素的顺序不能乱,第3个音素不能跑到第1个音素前面去。这个约束符合语音产生的物理事实——人类说话时音素的先后顺序是确定的,不可能颠倒。
MAS用的动态规划算法,本质上是在填一张表:dp[i][j]表示前i个音素对齐到前j帧的最大对数似然。转移时只能从dp[i-1][j-1]或dp[i][j-1]过来,分别表示“新开一个音素”和“当前音素多说一帧”。这张表填完之后,回溯就能得到最优路径。
实操中要注意一个细节:MAS是在numpy里算的,不是PyTorch张量。因为动态规划的循环在GPU上展开很麻烦,作者选择在CPU上用numpy算好之后,再转成torch.LongTensor传给损失函数。这在批处理时会有性能开销,尤其是batch size比较大、序列比较长的时候。实际训练时,我通常会限制单batch最长帧数在800到1000帧之间,否则MAS的耗时能占到整个step的30%以上。
还有一个很多人忽略的点:MAS的最优路径并不等于“每个音素分配多少帧”的唯一答案。因为同一个音素内部,每一帧的实际内容差异不大(都在同一个分布下采样),所以MAS找的路径本质上是“让总似然最大”的路径。这会给时长预测器提供比较稳定的训练目标,但也会让边界位置有些抖动。我观察到,某些音素(比如静音段和元音交界处)的时长在相邻epoch之间会有小幅波动,这是正常的,不需要额外干预。
2.2 流的解码器:从mel谱到隐变量
流解码器是Glow-TTS里最容易看得一头雾水的部分。它的设计借鉴了Glow的图像生成架构,但做了适配语音的改动。我建议先理解一个核心思想:生成流模型把“复杂分布”分解成“简单分布加一连串可逆变换”。
具体到Glow-TTS,mel谱(假设80维)先经过一个ActNorm层做逐通道归一化,然后经过1x1可逆卷积打乱通道间信息,最后进入Affine Coupling Layer。Affine Coupling Layer把80维通道分成两半(hidden_dim=64,split了一半为32维),一半用来计算缩放和偏移,另一半用这两个参数做仿射变换。这是典型的RealNVP结构。
但Glow-TTS用的不是普通的RealNVP耦合层,而是把耦合层里的变换网络换成了AFT(Attention Free Transformer)。AFT和Transformer的区别在于:它不用点积注意力,而是用一种简化的线性注意力机制,计算复杂度从O(n^2)降到O(n^8/3)。这让流解码器能处理更长的序列,同时在语音这种长序列场景下保持了不错的建模能力。
我实测下来,AFT的效果比传统Transformer耦合层要好,尤其是在音高变化比较丰富的语音上。但它有个副作用:模型参数总量上去了。Glow-TTS的base模型有接近3300万个参数,其中流解码器占了很大一部分。在CPU部署时,如果你用的是老一点的i5处理器,单次推理可能要几十秒,这个后面在部署章节详细说。
训练时流解码器做的是正变换:真实mel谱 -> 隐变量z。推理时做的是逆变换:采样的z -> mel谱。所以训练和推理的代码路径是完全相反的,这个设计是生成流模型的特点,也是它和普通神经网络的本质区别。
2.3 随机时长预测器的训练与采样细节
时长预测器的作用是:给定文本编码器的输出(每个音素的表征),预测每个音素应该持续多少帧。听起来很简单,但Glow-TTS的时长预测器有个特殊设计——它是随机的,不是确定性的。
训练时,它从流解码器里取中间层的输出作为条件特征,加上一个从标准正态分布采样来的噪声z_dur,一起通过两层卷积加LayerNorm,最后预测出时长分布的对数均值和对数方差。推理时,先采样一个噪声,把它和文本编码器的输出拼接,再预测时长。
为什么要把时长预测器设计成随机的?因为同一个文本,在人类朗读时可以有多种时长读法:有的快,有的慢,有的重音靠前,有的重音靠后。确定性模型只能给出一个平均时长,听起来容易“平”;随机模型每次可以采样不同的时长,增加多样性。
实际使用中需要注意,推理时不要直接把时长预测器的均值输出当作最终时长,而是把均值乘以一个缩放因子。Glow-TTS论文里建议的缩放范围是highway=1.0到slow=1.3之间,我一般取1.0或0.95。如果取太小(比如0.8),语音会变得异常急促,而且音素之间的间隙会消失,听起来像在赶时间。
训练时长预测器有个trick:它不是在每个step都和主模型同权重更新。因为在训练早期,文本编码器和流解码器的对齐还没有稳定,如果时长预测器学得太快,会把早期的不稳定对齐当作ground truth记住,后期很难纠正。我用的时候,会把时长预测器的学习率调低一点,比如设置成主模型的一半,或者在训练的前1万个step里给时长预测器的loss降低权重。这个小技巧在实际训练里非常有效,能明显减少训练中后期时长预测不准的问题。
3. 从论文到落地:onnx导出与sherpa-onnx部署实操
3.1 为什么现场推理优先选onnx
很多人问,为什么要费劲导出onnx,直接用PyTorch推理不行吗?答案是“行,但不适合落地”。PyTorch的推理依赖Python环境和torch库,打包成服务后体积动辄几百MB,而且在没有GPU的机器上性能受限于Python解释器的GIL和多线程调度。onnxruntime则是一个纯C++实现的推理引擎,支持多线程、量化、内存优化,还能在手机端、嵌入式设备上运行。
更重要的是,sherpa-onnx(k2-fsa团队开发的离线推理工具)已经为几个TTS模型提供了导出和推理的支持,Glow-TTS就是其中之一。它把PyTorch模型导出成onnx之后,可以直接在CPU上跑推理,不需要GPU,也不需要装PyTorch。这在工业界非常实用——不是每个客户都有NVIDIA显卡的机器。
我实测下来,sherpa-onnx在CPU上跑Glow-TTS,单次合成(约10秒语音)的时间在普通i7笔记本上是1秒到3秒之间,比PyTorch直接推理快了不少,因为onnxruntime会自动做算子融合和线程优化。
3.2 导出Glow-TTS到onnx的关键步骤
导出onnx时,最容易出问题的两个地方是动态轴(序列长度不固定)和模型里的Python控制流。Glow-TTS训练时是整句处理的,推理时也是整句处理,但长度是变量,所以导出时要把batch和time维度设置成动态轴。
我在实际导出中用的方法比较保守:先用PyTorch跑一次推理,拿到模型的输入输出结构;然后一个个模块导出,最后把子模型拼在一起。具体来说是拆成三个onnx文件:
text_encoder.onnx:文本输入 -> 音素级隐状态duration_predictor.onnx:音素隐状态 -> 每个音素的时长flow_decoder.onnx:隐变量z -> mel谱
为什么拆开导出?因为Glow-TTS的完整推理流程有三个阶段,分别需要不同的输入(文本token、无声、噪声),如果合成一个onnx文件,动态轴和条件输入处理会变得很复杂,容易出错。拆开之后,每个子模型的输入输出都是固定的,导出脚本好写,调试也方便。代价是需要手动写一个调度逻辑,在Python端用onnxruntime分别跑这三个子模型。
如果你不想自己写导出脚本,可以用sherpa-onnx官方提供的转换脚本,它已经把Glow-TTS和VITS的导出流程写好了,支持动态轴,还能导出fp16版本。不过注意,fp16在CPU上不一定更快,反而可能因为反量化开销变慢,而且音质可能有损。我建议先用fp32跑通,再看有没有性能瓶颈再考虑量化。
3.3 sherpa-onnx实际部署的注意点
部署sherpa-onnx的流程不复杂,但有几个坑值得记下来:
模型输入需要特定格式。Glow-TTS的文本编码器接收token id序列,但sherpa-onnx封装好了文本前端(text frontend),你只需要传入原始文本字符串,它会自动做token化。如果你自己写前端,要特别注意和国际音标IPA的映射关系,G2P(grapheme-to-phoneme)出错的概率比你想象的高。
采样率必须匹配。Glow-TTS训练时的采样率是22050Hz,输出的mel谱也是基于这个采样率。如果你用sherpa-onnx的vocoder(比如HiFi-GAN)去合成,vocoder的采样率也要是22050Hz。我犯过把vocoder换成24000Hz版本的错误,结果声音整体偏高,听起来像卡通人物。
推理时的温度参数(temperature)。sherpa-onnx提供了temperature参数,控制生成时隐变量z的采样温度。温度越高,语音的韵律变化越丰富,但风险是产生不稳定的音质。我推荐从0.667开始尝试(这是VITS论文里常用的值,对Glow-TTS也适用),如果声音太“平”,可以加到0.8;如果出现破音,就降回0.5。
sherpa-onnx还有一个非常实用的功能:离线批量合成。你可以一次传多句文本,它内部会并行跑多个推理任务。在CPU上,4线程并行合成比单线程几乎线性加速,我实测4条句子合成8秒语音,4线程耗时比单线程少了70%。如果你的场景是服务器端批量生成语音,这个功能能省不少时间。
4. 常见问题与排查技巧实录
4.1 训练不收敛:loss下不去,或者生成的声音像机器人
Glow-TTS训练时,如果loss在1万步之后还在高位横盘,我建议按顺序排查这几项:
- mel谱的采样参数。默认的hop_size是256,frame_length是1024,n_mels是80。如果你自己预处理数据时用了不同的参数,模型输出的mel谱和vocoder期望的输入会不匹配,听起来就是严重失真。这个最隐蔽,因为loss看起来正常,但合成的语音完全不对。
- 学习率。初始学习率建议在1e-4到5e-4之间,配合Noam式衰减。我之前用AdamW默认的1e-3跑过,结果训练后期loss出现了明显的震荡,生成的声音空洞感很强。
- batch size和梯度累积。Glow-TTS对batch size比较敏感,我建议至少用16条句子(如果你的显存不够,用梯度累积凑够等效batch size)。小batch训练会让MAS找到的对齐不稳定,模型很难学到一致的时长分布。
还有个我个人的体验:如果你用多卡训练,需要注意BN(BatchNorm)的问题。Glow-TTS的流解码器里有ActNorm,它和BN类似,也有跨卡统计的需求。如果你用DistributedDataParallel,必须关掉BN层的跨卡同步,否则ActNorm在每张卡上维护的统计量不一致,推理时合成出来的语音会有明显的“卡顿感”。
4.2 训练好了但合成音质差:声音闷、噪点多、有金属音
合成音质差的排查优先级:
- 先检查vocoder。Glow-TTS只生成mel谱,最终的声音质量完全取决于vocoder。如果你用HiFi-GAN,先试试官方预训练权重,如果官方权重音质没问题,说明你的mel谱没问题,问题出在vocoder微调或者适配参数上。
- mel谱的归一化方式。Glow-TTS输出的mel谱是线性尺度的,而HiFi-GAN更习惯处理经过log变换的mel谱。我在第一次部署时忘了在mel谱和vocoder之间加log变换,结果声音特别闷,听起来像隔了一层被子。
- 检查采样噪声的scale。推理时从N(0, I)采样的z,如果方差太大,生成的mel谱会带有高频噪点。我会在采样后对z乘以一个系数(比如0.9),能把噪点压下去不少。这个方法不是论文里的标准做法,但我在实践里用了效果很好。
还有一种容易被忽略的情况:训练数据和推理文本的语言不匹配。Glow-TTS的文本编码器是音素级别的,如果你的训练数据是英文,但推理文本里有中文拼音token,模型会把这个token当成未知音素处理,生成的结果基本不可用。
4.3 对齐失败:出现“漏音”或者“重音”
训练时偶尔会看到MAS找到的对齐路径有异常——某个音素分配了0帧,或者某个音素的时长突然异常长。这种情况我遇到过几次,原因多为:
- 文本token序列里出现了重复音素。比如英文单词“better”里的t和r,音素标注时如果同时保留“t”和“t”,会让MAS在两个相邻相同音素之间的分配非常不稳定。解决方法是清洗训练数据,合并连续重复的相同音素。
- 静音段的处理方式。句首句尾的静音段,我建议在预处理时统一裁剪到0.1到0.3秒之间。如果有的句子静音段特别长(比如1秒),MAS会因为静音段的高似然而把大片帧分给静音token,导致后续音素被压缩。
- 输入序列过长时MAS的数值稳定性。帧数超过1000之后,动态规划表里的log概率差值会变得很小,float32精度下容易出现下溢。我在代码里加了double精度计算MAS概率表,效果立竿见影。
4.4 时长预测太“平”:所有句子读出来节奏都一样
如果你发现合成的语音虽然音质没错,但总感觉“每个字都拖得很长,没有重音”,大概率是时长预测器采样时的温度问题。Glow-TTS的随机时长预测器,推理时如果温度设得太高(比如大于0.8),时长抖动会很大;设得太低(比如小于0.2),又会趋向均值,听起来就是“节奏平”。
我的经验是,把温度设为0.667,然后把时长预测结果乘一个0.95到1.1之间的缩放系数。这样既能保留一定的节奏变化,又不会因为个别音素时长过长而拖慢整体语速。
另外提醒一个坑:sherpa-onnx默认的sampling temperature可能同时作用在隐变量z和时长噪声上。如果你想要更自然的韵律,可以分别调这两个温度,而不是只调一个。我用的时候,z的温度设为0.667,时长的温度设为0.3到0.5,效果比两者都用0.667要自然一些。
5. Glow-TTS到VITS:读完后可以怎么扩展
读完Glow-TTS,你会发现VITS的很多设计都延续了它的骨架。VITS最大的改动是加了一个后验编码器(posterior encoder)和VAE的结构,让模型能从真实语音里提取更多细节,同时也用对抗训练(GAN)来提升mel谱的质量。如果你想在Glow-TTS基础上继续深入,我建议重点看这几个差异点:
- VITS训练时多了“重建”环节:它把mel谱既输入给流解码器,也通过后验编码器提取隐变量,然后用一个判别器来区分真实和生成的mel谱。这相当于在Glow-TTS的似然目标上叠加了一个对抗目标。
- VITS推理时也用随机采样:但它采样的是后验编码器输出的分布,而不是简单从标准正态采样。所以VITS合成的语音在韵律上和情感表现力上往往比Glow-TTS更丰富。
- VITS的时长预测器直接沿用Glow-TTS的随机时长预测器设计,但把预测的目标从帧级时长改成了token级时长,然后在内部展开,这一步和Glow-TTS的推理流程几乎一样。
如果你已经成功跑通了Glow-TTS的部署,那么迁移到VITS只需要改模型的加载逻辑和token化的预处理,其他工程框架(比如sherpa-onnx的离线合成流程)基本可以复用。我实际迁移过一次,耗时不到一天。
回到部署这层,我再补充一个实用经验:如果你只想快速做一个CPU上的中文TTS demo,直接以VITS为起点会更容易出效果。因为VITS的社区权重更丰富,中文支持也更好。Glow-TTS的更重要意义在于让你理解流模型和单调对齐在TTS里的用法,它是VITS的基石。
我个人在实际操作中的体会是,读Glow-TTS不能只满足于“能跑通”,要亲手改一改它的时长预测器、试一下不同的温度参数、甚至把flow的解耦层数量从12改到6看看效果变化。只有亲手折腾过一遍,才会真正理解为什么Glow-TTS选择flow而不是GAN或者VAE——因为flow给了你精确的似然计算,以及一个可控的采样空间,这在调试时实在太重要了。