Files
maths-cs-ai-compendium-zh/chapter 09: audio and speech/03. text to speech and voice.md
T
flykhan 2536c937e3 feat: 完整中文翻译 maths-cs-ai-compendium(数学·计算机科学·AI 知识大全)
翻译自英文原版 maths-cs-ai-compendium,共 20 章全部完成。

第01章 向量 | 第02章 矩阵 | 第03章 微积分
第04章 统计学 | 第05章 概率论 | 第06章 机器学习
第07章 计算语言学 | 第08章 计算机视觉 | 第09章 音频与语音
第10章 多模态学习 | 第11章 自主系统 | 第12章 图神经网络
第13章 计算与操作系统 | 第14章 数据结构与算法
第15章 生产级软件工程 | 第16章 SIMD与GPU编程
第17章 AI推理 | 第18章 ML系统设计
第19章 应用人工智能 | 第20章 前沿人工智能

翻译说明:
- 所有数学公式 $...$ / $$...$$、代码块、图片引用完整保留
- mkdocs.yml 配置中文导航 + language: zh
- README.md 已翻译为中文(兼 docs/index.md)
- docs/ 目录包含指向各章文件的 symlink
- 约 29,000 行中文内容,排除 .cache/ 构建缓存
2026-05-03 10:23:20 +08:00

712 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 语音合成与声音
*语音合成(Text-to-Speech Synthesis)逆向执行 ASR 流水线,从书面文本生成自然听感的音频。本文涵盖 TTS 流水线(文本规范化、G2P、声学模型、声码器)、Tacotron、WaveNet、HiFi-GAN、声音克隆、声音转换以及语音活动检测(VAD)。*
- 在文件 01 中,我们构建了信号处理工具包:波形、语谱图、梅尔滤波器组和 MFCC。在文件 02 中,我们将语音转换为文本。现在我们反方向操作:给定文本,合成自然听感的语音。这就是**语音合成(TTS)**,一个同样通向声音转换、声音克隆和语音活动检测的问题。
- 将 TTS 想象成一场舞台表演。剧本就是文本输入。导演(声学模型)决定每句台词应该如何发音——音高、时长、重音。管弦乐队(声码器)随后演奏乐谱,产生听众实际听到的声波。现代神经 TTS 用媲美人类说话者的演绎,取代了基于规则系统那种僵硬、机械的发音。
![TTS 流水线:文本被规范化、转换为音素、由声学模型处理生成梅尔语谱图,然后通过声码器生成最终波形](../images/tts_pipeline.svg)
- **语音合成流水线** 标准 TTS 流水线包含四个阶段:(1) 文本规范化,(2) 音素转换,(3) 声学模型,(4) 声码器。一些现代系统将阶段 3 和 4 合并为一个端到端模型,但这种概念分解仍然有用。
- **文本规范化** 将原始文本转换为可发音的形式。缩写展开("Dr."变为"Doctor")、数字变为词语("1984"变为"nineteen eighty-four")、货币符号被口头发音("$5"变为"five dollars"),以及处理 URL 或特殊字符。这一阶段通常基于规则和语言特定文法,不过也存在神经规范化模型。此处的错误会传播到所有下游阶段:如果"St."被读作"saint"而不是"street",整个发音就错了。
- **字素到音素(G2P)转换** 将规范化文本映射为音素序列。英语尤其不规则("though"、"through"、"tough"中的"ough"发音各不相同),因此词典查找(CMU 发音词典)处理常见词语,而神经序列到序列模型(第 06 章的编码器-解码器或第 07 章的 Transformer)处理词汇表外的词语。浅层正字法语言(西班牙语、芬兰语)需要更简单的 G2P。输出通常是 IPA(国际音标)序列或等效的内部音素集合。
- **声学模型** 接收音素序列并产生中间声学表示,几乎总是**梅尔语谱图**(文件 01)。梅尔语谱图捕获每个时间帧的频谱包络,编码了声码器重构波形所需的感知相关信息。声学模型必须决定时长(每个音素持续多久)、音高(基频 $F_0$)和能量(响度)。
- **声码器** 接收梅尔语谱图并产生原始音频波形。这是一个不适定的反演问题:由于相位信息已被丢弃,许多波形可以产生相同的语谱图。经典声码器(Griffin-Lim、WORLD)使用迭代或信号模型方法,但神经声码器现在在质量上占主导地位。
- **声码器:WaveNet**van den Oord 等人,2016)是第一个生成几乎与人类录音无法区分的语音的神经声码器。它自回归地对波形建模,预测每个样本 $x_t$ 的条件概率依赖于所有先前样本:
$$P(x) = \prod_{t=1}^{T} P(x_t \mid x_1, \ldots, x_{t-1}, c)$$
- 其中 $c$ 是条件信号(梅尔语谱图)。每个样本是 16 位,因此对 65536 个值进行朴素 softmax 是不切实际的。WaveNet 使用 **μ-law 压扩** 减少到 256 个量化级别,或者后来的变体使用 logistics 混合分布。
- WaveNet 的核心构建模块是**扩张因果卷积**。因果意味着滤波器权重只看过去样本(无未来泄露)。扩张意味着滤波器以指数增长的间隔跳过样本:扩张因子 $1, 2, 4, 8, \ldots, 512$。这提供了指数级大的感受野,同时保持参数量线性增长。
- 每层的门控激活函数为:
$$z = \tanh(W_{f} \ast x) \odot \sigma(W_{g} \ast x)$$
- 其中 $W_f$ 和 $W_g$ 是滤波器和门控卷积权重,$\ast$ 表示扩张因果卷积,$\odot$ 是逐元素乘法。这种门控机制(来自第 06 章的 LSTM)允许网络控制信息流。
- WaveNet 产生卓越的质量,但推理速度极慢:生成一秒 24 kHz 音频需要 24000 次顺序前向传播。这推动了所有后续声码器研究。
- **WaveRNN**Kalchbrenner 等人,2018)用单层循环网络取代了 WaveNet 的深层卷积堆叠。它将每个 16 位样本拆分为粗(高 8 位)和细(低 8 位)分量,使用 GRU(第 06 章)预测每个分量。这种双 softmax 方法显著减少了计算量,同时保持了高质量。经过精心内核优化后,WaveRNN 在移动 CPU 上足以实现实时运行。
- **WaveGlow**Prenger 等人,2019)是一种基于**流**的声码器,完全避免了自回归生成。它使用一系列可逆变换(仿射耦合层,第 06 章的正则化流)将简单高斯分布映射到波形分布。训练使用变量变换公式最大化精确对数似然:
$$\log P(x) = \log P(z) + \sum_{i} \log \left| \det \frac{\partial f_i}{\partial f_{i-1}} \right|$$
- 其中 $z = f(x)$ 是通过将 $x$ 传递经流得到的潜在变量。推理时,抽取样本 $z \sim \mathcal{N}(0, I)$ 并通过逆流以单次并行前向传播推出。WaveGlow 用模型大小(耦合层的大网络)换取生成速度。
- **HiFi-GAN**Kong 等人,2020)使用**生成对抗网络**从梅尔语谱图合成波形。生成器通过一系列转置卷积对梅尔语谱图进行上采样,每个卷积后跟一个**多感受野融合(MRF)**模块。MRF 模块并行应用多个具有不同核大小和扩张率的残差块,然后将它们的输出求和。这使得生成器能够同时捕获多个时间尺度的模式。
![HiFi-GAN 生成器架构:梅尔语谱图输入经过转置卷积上采样层,每层后跟多感受野融合块,这些融合块组合了具有不同扩张模式的并行残差堆叠](../images/hifi_gan_generator.svg)
- HiFi-GAN 使用两种鉴别器类型。**多周期鉴别器(MPD)**通过以不同周期(2、3、5、7、11)折叠一维波形,将其重塑为二维,然后应用二维卷积。这捕获了不同基频下的周期结构。**多尺度鉴别器(MSD)**在原始波形、2 倍降采样和 4 倍降采样版本上操作,捕获不同时间分辨率下的模式。
- 训练目标结合了对抗损失、**梅尔语谱图重构损失**(合成音频与真实音频的梅尔语谱图之间的 L1 距离)和**特征匹配损失**(中间鉴别器特征之间的 L1 距离):
$$\mathcal{L}_G = \mathcal{L}_{\text{adv}}(G) + \lambda_{\text{mel}} \mathcal{L}_{\text{mel}}(G) + \lambda_{\text{fm}} \mathcal{L}_{\text{fm}}(G)$$
- HiFi-GAN 实现了与 WaveNet 相当的合成质量,同时速度提升超过 1000 倍,可在单个 GPU 上实现实时生成。
- **神经源-滤波器(NSF)模型**将传统信号处理与神经网络相结合。在经典源-滤波器模型中,浊音由声源激励(基频 $F_0$ 处的周期脉冲序列)通过声道滤波器(频谱包络)产生。NSF 模型用神经网络替代手工设计的滤波器,同时保留显式源信号。输入的 $F_0$ 轮廓提供了纯数据驱动声码器有时难以处理的精细音高控制。
- **声学模型:Tacotron**Wang 等人,2017)是第一个直接将字符序列转换为梅尔语谱图的端到端神经 TTS 系统。它使用带注意力机制的编码器-解码器架构(第 07 章)。编码器使用卷积库、高速网络和双向 GRU 处理字符/音素序列。解码器是一个自回归 GRU,逐个预测梅尔帧,使用前一帧和注意力上下文作为输入。
- **Tacotron 2**Shen 等人,2018)显著改进了架构。编码器是一个 3 层一维卷积堆叠后跟双向 LSTM(第 06 章)。解码器是一个 2 层 LSTM,带**位置敏感注意力**,该注意力机制不仅基于编码器输出和解码器状态,还基于先前步骤累积的注意力权重来条件化。这防止了注意力跳过或重复词语的常见失败模式。
![Tacotron 2 架构:字符/音素编码器包含卷积层和 BiLSTM,位置敏感注意力对齐到梅尔语谱图帧,自回归解码器包含停止标记预测](../images/tacotron2_architecture.svg)
- 解码器步骤 $i$ 下编码器位置 $j$ 的位置敏感注意力能量为:
$$e_{i,j} = w^T \tanh(W_s s_{i-1} + W_h h_j + W_f f_{i,j} + b)$$
- 其中 $s_{i-1}$ 是前一个解码器状态,$h_j$ 是位置 $j$ 处的编码器输出,$f_{i,j}$ 是通过将累积注意力权重 $\sum_{k<i} \alpha_{k,j}$ 与一维卷积滤波器卷积得到的位置特征。注意力权重为 $\alpha_{i,j} = \text{softmax}(e_{i,j})$。
- Tacotron 2 的解码器还在每个步骤预测一个**停止标记**概率,指示梅尔语谱图何时完成。输出的梅尔语谱图随后传递给声码器(最初是 WaveNet,后来被 HiFi-GAN 或类似模型取代)。
- Tacotron 2 的自回归特性意味着合成速度受限于梅尔帧的数量。对于典型的每秒 80 帧的梅尔语谱图,一个 5 秒的发音需要 400 个顺序解码步骤。
- **FastSpeech**Ren 等人,2019)使用**非自回归**声学模型解决了速度问题。FastSpeech 不是顺序生成梅尔帧,而是并行生成所有帧。关键挑战在于确定每个音素应该产生多少梅尔帧,FastSpeech 通过**时长预测器**来处理。
- 时长预测器是一个小型卷积网络,预测每个音素的整数时长(梅尔帧数)。训练期间,真实时长使用其注意力对齐从预训练的自回归教师模型(Tacotron 2)中提取。推理期间,使用预测时长通过**长度调节器**将音素级隐藏序列扩展到帧级,该调节器简单地将每个音素的隐藏表示重复预测的帧数。
- **FastSpeech 2**Ren 等人,2021)通过移除教师-学生蒸馏改进了 FastSpeech。它直接使用强制对齐(来自文件 02 的声学模型框架)提取真实时长,并在时长之外添加了显式的音高($F_0$)和能量**方差适配器**。每个适配器是一个小型卷积预测器,其输出条件化解码器:
```math
\begin{aligned}
\hat{d}_i &= \text{DurationPredictor}(h_i) \\
\hat{p}_i &= \text{PitchPredictor}(h_i) \\
\hat{e}_i &= \text{EnergyPredictor}(h_i)
\end{aligned}
```
- 其中 $h_i$ 是音素 $i$ 的编码器隐藏状态。训练时使用真实值;推理时,预测值提供对韵律的显式控制。这种可控性是 FastSpeech 2 的主要优势:调整音高、速度或能量就像缩放预测器输出一样简单。
- FastSpeech 2 在推理时通常比 Tacotron 2 快 10-20 倍,并避免了常见的自回归失败模式,如词语跳过、重复和注意力崩塌。
- **VITS**Kim 等人,2021)是一个**端到端** TTS 模型,直接从文本生成波形,消除了独立的声码器阶段。VITS 结合了条件变分自编码器(第 06 章)、正则化流和对抗训练。后验编码器将真实梅尔语谱图映射到潜在空间,先验编码器将音素(通过基于 Transformer 的文本编码器和时长预测器)映射到同一潜在空间,解码器(基于 HiFi-GAN)从潜在样本生成波形。
- VITS 的训练目标结合了:
- **重构损失**:VAE 迫使潜在分布编码声学信息
- **KL 散度**:对齐文本条件化的先验与音频条件化的后验
- **对抗损失**:鉴别器确保波形质量
- **时长损失**:训练随机时长预测器
- VITS 比两阶段系统(FastSpeech 2 + HiFi-GAN)产生更高质量,因为声学模型和声码器被联合优化,避免了预测梅尔语谱图与真实梅尔语谱图之间的不匹配,这种不匹配会降低两阶段系统的性能。
- **VALL-E**Wang 等人,2023)从根本上将 TTS 重构为离散音频令牌上的**语言建模问题**。它使用神经音频编解码器(EnCodec)将语音表示为来自多个码本级的一系列离散码。给定文本提示和一个 3 秒的注册话语(也编码为离散令牌),VALL-E 使用 Transformer 语言模型自回归地预测音频令牌。
- VALL-E 使用两个模型:一个**自回归(AR)模型**逐个令牌地生成第一个码本级,以及一个**非自回归(NAR)模型**并行预测剩余的码本级,以第一个级别和彼此为条件。这种编解码器语言模型方法实现了卓越的零样本声音克隆:3 秒样本足以重现说话人的声音、音色,甚至情感基调。
- **StyleTTS**Li 等人,2022)和 **StyleTTS 2** 将语音解耦为内容和风格组件。风格编码器从参考音频中提取风格向量,捕获说话人身份、韵律和录音条件。推理时,风格可以从学习的先验分布中采样,或从参考话语中迁移。StyleTTS 2 使用扩散模型(第 08 章)作为风格先验,生成多样化且自然的韵律。
- **Kokoro**(2024)是一个轻量级、高质量的开放源码 TTS 模型,以其小巧的规模(约 82M 参数)和令人印象深刻的自热度而著称。它采用受 StyleTTS 2 启发的架构,包含基于扩散的风格先验和微调的 ISTFTNet 声码器,该声码器直接预测 STFT 系数(来自文件 01)而不是原始波形样本。尽管模型大小仅为 VALL-E 等模型的一小部分,Kokoro 在英语、日语、法语、韩语和中文上实现了接近人类的自然度,证明了精心策划的训练数据和高效架构设计可以与暴力规模相抗衡。Kokoro 的小体积使其非常适合本地和边缘部署。
- **Orpheus**Canopy Labs2025)是一个开放源码 TTS 模型系列(1B 和 3B 参数),构建在 VALL-E 开创的**编解码器语言模型**范式之上。Orpheus 更进一步,使用 LLM 骨干网络(微调的 Llama 3)直接生成 SNAC 音频编解码器令牌。其突出特点是类似人类的情感表达能力:它能够以卓越的自然度处理笑声、叹息、犹豫和情感韵律。Orpheus 可以通过在输入文本中使用 `[laugh]``[sigh]` 等标签进行提示,从而对副语言表达进行细粒度控制。
- **Dia**Nari Labs2025)是一个开放源码对话 TTS 模型,从单个文本转录生成逼真的多说话人对话。Dia 构建在 1.6B 参数的编码器-解码器 Transformer 之上,处理对话中的话轮转换、说话人特定声音和非语言线索(笑声、停顿)。它还支持从简短音频提示进行声音克隆,从而在对话上下文中实现零样本说话人生成。
- **Sesame CSM**(会话语音模型,2025)专注于自然的多人轮换会话语音。Sesame 不是为了优化朗读式 TTS,而是对真实对话的动态进行建模:反馈词("嗯哼")、打断、说话人之间的节奏变化和情感响应。该模型使用以对话上下文(文本和音频历史)为条件的 Transformer 骨干网络,生成的语音风格能适应对话的流程。
- **Fish Speech**Fish Audio2024)是一个开放源码 TTS 系统,使用双自回归架构:一个大语言模型从文本生成语义令牌,一个较小模型将这些转换为 VQGAN 声学令牌,再由声码器解码为波形。Fish Speech 支持从 10-15 秒参考音频进行零样本声音克隆,并实现适合实时应用的低延迟。其模块化设计允许独立替换组件(例如,不同的声码器)。
- **ChatTTS**2024)是一个开放源码会话 TTS 模型,专为聊天机器人和虚拟助手等对话应用设计。它通过在文本输入中嵌入特殊令牌,生成自然、会话风格的语音,并对韵律特征(笑声、停顿、填充词)进行细粒度控制。ChatTTS 支持中英混合合成和多说话人生成。
- **Bark**Suno2023)是一个基于 Transformer 的开放源码模型,从文本提示生成语音、音乐和音效。它使用三个阶段的 Transformer 模型流水线(文本 → 语义令牌 → 粗声学令牌 → 细声学令牌),并支持声音克隆、多语言合成以及音乐和环境音等非语音音频。Bark 的通用性以可控性为代价——它不如专用 TTS 系统精确,但更灵活。
- **Parler-TTS**Hugging Face2024)采用**自然语言描述**方式进行声音控制:用户无需提供参考音频片段来控制风格,而是提供文本描述,例如"一位女性说话者,声音温暖、富有表现力,在安静的房间中。"Parler-TTS 在带注释的语音数据上训练,其中每个话语都配有一个描述说话风格的自然语言描述,从而无需任何参考音频即可实现直观控制。
- **Neuphonic** 是一个基于 API 的 TTS 平台,针对超低延迟语音合成进行了优化,面向实时语音代理和会话 AI 应用。它通过流式架构实现低于 100 毫秒的首音时间,在完整输入文本可用之前就开始生成音频。Neuphonic 专注于部署和延迟优化层面,而不是新颖的模型架构,围绕现代神经 TTS 提供生产级基础设施。
- **KittenTTS** 是一个紧凑、快速的 TTS 模型,专为效率低资源部署设计。它优先考虑最小延迟和小模型大小,适用于边缘和嵌入式应用,以牺牲一定自然度换取在 CPU 和移动设备上的实时性能。
- 现代 TTS 格局正在分化为两种范式:(1) **编解码器语言模型**VALL-E、Orpheus、Fish Speech),将语音生成视为离散音频码上的下一个令牌预测,利用 LLM 的扩展规律;以及 (2) **流/扩散模型**VITS、StyleTTS 2、Kokoro),通过迭代细化生成连续梅尔语谱图或波形。编解码器语言模型在零样本克隆和表现力方面表现出色;流/扩散模型通常更小、更快。两者都在快速向人类级别的自然度收敛。
- **韵律建模**控制语音的"音乐":音高、时长、能量、节奏和语调。没有良好的韵律,即使单个音素清晰,合成语音听起来也平淡且机械。可以把韵律想象成单调的 GPS 语音与富有表现力的有声读物旁白之间的区别。
- **音高**(基频 $F_0$)是语音感知的高低程度。它在问句末尾上升,在陈述句末尾下降,并在情感性语音中连续变化。$F_0$ 使用 CREPE(一种神经音高追踪器)或 YIN(基于自相关,来自文件 01)等算法从音频中提取。在 TTS 中,音高由声学模型预测(FastSpeech 2 的音高预测器)或隐式学习(Tacotron 2)。
- **时长**决定了语速和节奏。重读音节更长,功能词缩短,停顿标记短语边界。时长建模在非自回归模型(FastSpeech)中是显式的,在自回归模型(Tacotron 的注意力对齐决定时长)中是隐式的。
- **能量**(响度)承载着重音。"我没说他**偷**了" vs "我没说他**偷**了"具有完全不同的含义,完全通过能量模式传达。
- **风格嵌入**捕获更高级的韵律模式。**全局风格令牌(GST)**框架(Wang 等人,2018)学习一个风格令牌库(对一组学习到的嵌入进行软注意力),捕获"兴奋"、"悲伤"或"低语"等说话风格。风格嵌入从参考话语中提取并添加到编码器输出中,允许在推理时进行风格迁移。
- **声音转换(VC)**改变话语的说话人身份,同时保留语言内容。想象一下录下自己的声音,然后让输出听起来像某个特定的目标说话人。VC 需要将说话人身份与内容解耦。
![声音转换流水线:源语音被分解为内容表示和说话人嵌入,目标说话人嵌入替换源说话人嵌入,解码器以目标声音重构语音](../images/voice_conversion_pipeline.svg)
- **说话人嵌入**(在文件 04 中进一步详述)将说话人身份编码为固定维度的向量。这些可以来自预训练的说话人验证模型(x-vectors、ECAPA-TDNN)。在 VC 中,源语音被编码为与说话人无关的内容表示,然后使用目标说话人嵌入进行解码。
- **解耦表示**将语音分离为独立因素:内容(音素)、说话人身份、音高和节奏。方法包括:
- **信息瓶颈**:压缩内容表示,使其紧密到丢失说话人信息(AutoVC)
- **对抗训练**:在内容表示上训练说话人分类器,并使用梯度反转去除说话人信息
- **向量量化**:VQ-VAE 迫使内容通过离散瓶颈,这自然剥离了说话人身份(因为码本条目表示音素类别,而非说话人特征)
- **声音克隆**以目标说话人的声音合成语音。**多说话人 TTS**在来自许多说话人的数据上训练,以说话人嵌入条件化模型。推理时,从注册音频中提取新说话人的嵌入,并用于条件化生成。
- **少样本声音克隆**使用少量数据(几分钟)适应新说话人。说话人编码器从注册音频中提取嵌入,TTS 模型以此嵌入为条件生成语音。这是 SV2TTS(Jia 等人,2018)中使用的方法:一个单独训练的说话人编码器、一个以说话人嵌入为条件的 Tacotron 2 合成器,以及一个 WaveRNN 声码器。
- **零样本声音克隆**完全不需要适应:一个简短的话语(3-30 秒)就足够了。VALL-E 通过将注册音频作为语言模型的提示来实现这一点。该模型学会以相同的声音继续生成,因为它是在大规模多说话人数据上训练的,其中话语内声音一致性是统计上的常态。
- **语音活动检测(VAD)**在每个时间帧回答一个简单的二值问题:是否有人在说话?尽管简单,VAD 是 ASR(文件 02)、说话人日志(文件 04)和降噪(文件 05)的关键预处理步骤。好的 VAD 通过跳过静音减少计算量,并通过防止噪声被作为语音处理来提高准确性。
- 经典 VAD 使用能量阈值法(语音比静音响亮)、过零率(语音具有特征性的过零模式)和频谱特征。这些在信噪比较低的嘈杂环境中会失效。
- **神经 VAD**模型将问题视为帧级二分类。小型 RNN 或 CNN 接收声学特征(来自文件 01 的对数梅尔能量)并预测语音/非语音概率。
- **WebRTC VAD**Google)是一个经典轻量级 VAD,使用基于 GMM 的分类器对简单的频谱特征进行分类。它以四个激进级别(0-3)运行,速度极快,但在音乐、非语音发声和低 SNR 环境中表现不佳。由于其零依赖的简单性,它仍然被广泛用作基线。
- **Silero VAD**Silero Team2021)是生产环境中的事实标准神经 VAD。其架构是一个小型深度可分离一维卷积堆叠(第 08 章的 MobileNet 思路应用于音频),后跟一个用于时间上下文的单层 LSTM,最后是一个线性头产生每帧的语音概率。整个模型小于 2MB(约 1M 参数),以 30-100 ms 块处理音频。
- **输入**:原始 16 kHz 音频(无需手动特征提取——卷积前端直接从波形中学习自己的特征)。
- **窗口化有状态推理**:LSTM 隐藏状态在块之间传递,因此模型处理流式音频而无需重新处理完整历史。每次调用处理一个 30、60 或 100 ms 的块,并返回 $[0, 1]$ 范围内的语音概率。
- **自适应阈值**:Silero VAD 使用独立的开始和结束阈值,而不是单个固定阈值,并设有最小语音/静音持续时间,防止在噪声边界上快速切换。语音段必须超过开始阈值并持续最小时长才被确认,静音必须低于结束阈值持续一段时间后段才关闭。
- **性能**Silero VAD 在 CPU 上以 1-2% 的实时因子运行(处理 1 秒音频约需 10-20 ms),使其适用于边缘设备、手机和实时流水线。它在嘈杂和音乐丰富的音频上显著优于 WebRTC VAD,同时保持足够小以便于设备端部署。
- Silero VAD 通常用作 Whisper(文件 02)的前端,将长音频在转录前分割成话语级块,也用于说话人日志流水线(文件 04),在提取说话人嵌入之前识别语音区域。
- **声学活动检测(AAD)**将 VAD 泛化为检测任何声学活动,而不仅仅是语音。这在智能家居设备、安防系统和野生动物监测中很有用。AAD 模型检测诸如玻璃破碎、狗叫或警报等事件,通常使用文件 04 中描述的音频分类框架。
- **TTS 评估指标**衡量客观质量和主观自然度:
- **平均意见得分(MOS)**:人类听者在 1-5 量表上对自然度进行评分。黄金标准,但昂贵且缓慢。
- **梅尔倒谱失真(MCD)**:测量合成与参考梅尔倒谱之间的距离。越低越好,但并不总是与感知相关。
- **PESQ / POLQA**:最初为电话语音设计的标准化感知评估指标。
- **说话人相似度**:合成与参考音频的说话人嵌入之间的余弦相似度(与声音克隆相关)。
- **可懂度**:将合成音频输入 ASR 系统(文件 02)并计算词错误率(WER)来衡量。
## 编程任务(使用 CoLab 或 notebook
- **任务 1:基于梅尔语谱图的 Griffin-Lim 声码器。** 实现 Griffin-Lim 迭代相位重构算法,将梅尔语谱图转换回波形。这演示了声码器问题以及为何需要神经声码器。
```python
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
# 生成合成波形(模拟元音的谐波之和)
sr = 16000
duration = 1.0
t = jnp.linspace(0, duration, int(sr * duration))
f0 = 220.0 # 基频
waveform = (
0.6 * jnp.sin(2 * jnp.pi * f0 * t) +
0.3 * jnp.sin(2 * jnp.pi * 2 * f0 * t) +
0.1 * jnp.sin(2 * jnp.pi * 3 * f0 * t)
)
# 计算 STFT
n_fft = 1024
hop_length = 256
window = jnp.hanning(n_fft)
def stft(signal, n_fft, hop_length, window):
"""计算短时傅里叶变换。"""
n_frames = 1 + (len(signal) - n_fft) // hop_length
frames = jnp.stack([
signal[i * hop_length : i * hop_length + n_fft] * window
for i in range(n_frames)
])
return jnp.fft.rfft(frames, n=n_fft)
def istft(stft_matrix, hop_length, window, length):
"""使用重叠相加法计算逆 STFT。"""
n_fft = (stft_matrix.shape[1] - 1) * 2
n_frames = stft_matrix.shape[0]
frames = jnp.fft.irfft(stft_matrix, n=n_fft)
frames = frames * window[None, :]
output = jnp.zeros(length)
for i in range(n_frames):
start = i * hop_length
end = start + n_fft
if end <= length:
output = output.at[start:end].add(frames[i])
return output
# 正向 STFT
S = stft(waveform, n_fft, hop_length, window)
magnitude = jnp.abs(S)
# 梅尔滤波器组
n_mels = 80
mel_low = 0.0
mel_high = 2595 * jnp.log10(1 + (sr / 2) / 700)
mel_points = jnp.linspace(mel_low, mel_high, n_mels + 2)
hz_points = 700 * (10 ** (mel_points / 2595) - 1)
freq_bins = jnp.floor((n_fft + 1) * hz_points / sr).astype(int)
mel_filterbank = jnp.zeros((n_mels, n_fft // 2 + 1))
for m in range(n_mels):
f_left = freq_bins[m]
f_center = freq_bins[m + 1]
f_right = freq_bins[m + 2]
for k in range(f_left, f_center):
mel_filterbank = mel_filterbank.at[m, k].set(
(k - f_left) / max(f_center - f_left, 1)
)
for k in range(f_center, f_right):
mel_filterbank = mel_filterbank.at[m, k].set(
(f_right - k) / max(f_right - f_center, 1)
)
# 转到梅尔并返回(伪逆)
mel_spec = magnitude @ mel_filterbank.T
magnitude_reconstructed = mel_spec @ jnp.linalg.pinv(mel_filterbank.T)
magnitude_reconstructed = jnp.maximum(magnitude_reconstructed, 1e-7)
# Griffin-Lim 算法
def griffin_lim(magnitude, n_iter, hop_length, window, signal_length):
"""迭代相位重构。"""
n_fft = (magnitude.shape[1] - 1) * 2
key = jax.random.PRNGKey(42)
phase = jax.random.uniform(key, magnitude.shape, minval=-jnp.pi, maxval=jnp.pi)
for _ in range(n_iter):
complex_spec = magnitude * jnp.exp(1j * phase)
signal = istft(complex_spec, hop_length, window, signal_length)
reanalysis = stft(signal, n_fft, hop_length, window)
phase = jnp.angle(reanalysis)
complex_spec = magnitude * jnp.exp(1j * phase)
return istft(complex_spec, hop_length, window, signal_length)
reconstructed = griffin_lim(magnitude_reconstructed, n_iter=60, hop_length=hop_length,
window=window, signal_length=len(waveform))
# 绘制对比图
fig, axes = plt.subplots(3, 1, figsize=(12, 8))
axes[0].plot(t[:1000], waveform[:1000], color='#3498db', linewidth=0.8)
axes[0].set_title('原始波形')
axes[0].set_ylabel('振幅')
axes[1].imshow(jnp.log1p(mel_spec.T), aspect='auto', origin='lower', cmap='magma')
axes[1].set_title('梅尔语谱图(中间表示)')
axes[1].set_ylabel('梅尔频带')
axes[2].plot(t[:1000], reconstructed[:1000], color='#e74c3c', linewidth=0.8)
axes[2].set_title('Griffin-Lim 重构波形(60 次迭代)')
axes[2].set_xlabel('时间 (秒)')
axes[2].set_ylabel('振幅')
plt.tight_layout()
plt.show()
# 测量重构误差
mse = jnp.mean((waveform[:len(reconstructed)] - reconstructed[:len(waveform)]) ** 2)
print(f"原始与重构之间的 MSE{mse:.6f}")
print("注意:通过梅尔反演导致的相位信息丢失会引起伪影。")
```
- **任务 2:时长预测器(FastSpeech 风格)。** 训练一个小型卷积时长预测器,将音素嵌入映射到时长。这是实现非自回归 TTS 的核心组件。
```python
import jax
import jax.numpy as jnp
import jax.random as jr
import matplotlib.pyplot as plt
# 模拟带真实时长的音素序列
# 在真实 TTS 中,时长来自强制对齐或教师注意力
def generate_synthetic_data(key, n_samples=200, max_phonemes=30, embed_dim=64):
"""生成合成音素嵌入和时长。"""
keys = jr.split(key, 4)
lengths = jr.randint(keys[0], (n_samples,), 5, max_phonemes)
all_embeddings = []
all_durations = []
all_masks = []
for i in range(n_samples):
L = int(lengths[i])
emb = jr.normal(keys[1], (max_phonemes, embed_dim))
# 时长:元音(偶数索引)较长,辅音较短
base_dur = jnp.where(jnp.arange(max_phonemes) % 2 == 0, 8.0, 4.0)
noise = jr.normal(jr.fold_in(keys[2], i), (max_phonemes,)) * 1.5
dur = jnp.clip(base_dur + noise, 1.0, 20.0).astype(jnp.float32)
mask = (jnp.arange(max_phonemes) < L).astype(jnp.float32)
all_embeddings.append(emb)
all_durations.append(dur * mask)
all_masks.append(mask)
return (jnp.stack(all_embeddings), jnp.stack(all_durations),
jnp.stack(all_masks))
key = jr.PRNGKey(42)
embeddings, durations, masks = generate_synthetic_data(key)
# 时长预测器:2 层一维卷积 + 线性投影
def init_duration_predictor(key, embed_dim=64, hidden_dim=128, kernel_size=3):
"""初始化时长预测器权重。"""
keys = jr.split(key, 4)
scale1 = jnp.sqrt(2.0 / (embed_dim * kernel_size))
scale2 = jnp.sqrt(2.0 / (hidden_dim * kernel_size))
params = {
'conv1_w': jr.normal(keys[0], (kernel_size, embed_dim, hidden_dim)) * scale1,
'conv1_b': jnp.zeros(hidden_dim),
'conv2_w': jr.normal(keys[1], (kernel_size, hidden_dim, hidden_dim)) * scale2,
'conv2_b': jnp.zeros(hidden_dim),
'linear_w': jr.normal(keys[2], (hidden_dim, 1)) * jnp.sqrt(2.0 / hidden_dim),
'linear_b': jnp.zeros(1),
}
return params
def duration_predictor(params, x):
"""从音素嵌入预测对数时长。x: (batch, seq, embed)。"""
# 卷积层 1 加 ReLU
h = jax.lax.conv_general_dilated(
x.transpose(0, 2, 1), # (batch, embed, seq)
params['conv1_w'].transpose(2, 1, 0), # (out, in, kernel)
window_strides=(1,), padding='SAME'
).transpose(0, 2, 1) + params['conv1_b'] # 回到 (batch, seq, hidden)
h = jax.nn.relu(h)
# 卷积层 2 加 ReLU
h = jax.lax.conv_general_dilated(
h.transpose(0, 2, 1),
params['conv2_w'].transpose(2, 1, 0),
window_strides=(1,), padding='SAME'
).transpose(0, 2, 1) + params['conv2_b']
h = jax.nn.relu(h)
# 线性投影到标量
log_dur = (h @ params['linear_w'] + params['linear_b']).squeeze(-1)
return log_dur
# 损失:对数时长的 MSE(FastSpeech 中的标准做法)
def loss_fn(params, embeddings, durations, masks):
log_dur_pred = duration_predictor(params, embeddings)
log_dur_true = jnp.log(jnp.clip(durations, 1.0, None))
sq_err = (log_dur_pred - log_dur_true) ** 2 * masks
return jnp.sum(sq_err) / jnp.sum(masks)
grad_fn = jax.jit(jax.value_and_grad(loss_fn))
# 训练循环
params = init_duration_predictor(jr.PRNGKey(0))
lr = 1e-3
losses = []
for epoch in range(300):
loss_val, grads = grad_fn(params, embeddings, durations, masks)
params = jax.tree.map(lambda p, g: p - lr * g, params, grads)
losses.append(float(loss_val))
# 在一个样本上评估
log_dur_pred = duration_predictor(params, embeddings[:1])
dur_pred = jnp.exp(log_dur_pred[0])
dur_true = durations[0]
mask = masks[0]
valid_len = int(jnp.sum(mask))
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
axes[0].plot(losses, color='#3498db', linewidth=1.5)
axes[0].set_xlabel('轮次')
axes[0].set_ylabel('MSE 损失(对数时长)')
axes[0].set_title('时长预测器训练')
axes[0].set_yscale('log')
x_pos = jnp.arange(valid_len)
width = 0.35
axes[1].bar(x_pos - width/2, dur_true[:valid_len], width, color='#27ae60',
label='真实值', alpha=0.8)
axes[1].bar(x_pos + width/2, dur_pred[:valid_len], width, color='#e74c3c',
label='预测值', alpha=0.8)
axes[1].set_xlabel('音素索引')
axes[1].set_ylabel('时长(帧)')
axes[1].set_title('时长预测与真实值对比')
axes[1].legend()
plt.tight_layout()
plt.show()
```
- **任务 3:使用上采样卷积的简单神经声码器。** 构建一个最小化的 HiFi-GAN 风格生成器,使用转置卷积和残差块将梅尔语谱图上采样为波形。
```python
import jax
import jax.numpy as jnp
import jax.random as jr
import matplotlib.pyplot as plt
def init_residual_block(key, channels, kernel_size, dilation):
"""初始化扩张残差卷积块。"""
k1, k2 = jr.split(key)
scale = jnp.sqrt(2.0 / (channels * kernel_size))
return {
'conv1_w': jr.normal(k1, (kernel_size, channels, channels)) * scale,
'conv1_b': jnp.zeros(channels),
'conv2_w': jr.normal(k2, (kernel_size, channels, channels)) * scale,
'conv2_b': jnp.zeros(channels),
'dilation': dilation
}
def residual_block(params, x):
"""x: (batch, time, channels)。带 LeakyReLU 的扩张卷积残差块。"""
h = jax.nn.leaky_relu(x, negative_slope=0.1)
# 简化:使用标准卷积(扩张在概念上处理)
h = jax.lax.conv_general_dilated(
h.transpose(0, 2, 1),
params['conv1_w'].transpose(2, 1, 0),
window_strides=(1,),
padding='SAME',
rhs_dilation=(params['dilation'],)
).transpose(0, 2, 1) + params['conv1_b']
h = jax.nn.leaky_relu(h, negative_slope=0.1)
h = jax.lax.conv_general_dilated(
h.transpose(0, 2, 1),
params['conv2_w'].transpose(2, 1, 0),
window_strides=(1,),
padding='SAME'
).transpose(0, 2, 1) + params['conv2_b']
return x + h
def init_generator(key, n_mels=80, upsample_rates=(8, 8, 4),
channels=128):
"""初始化最小化的 HiFi-GAN 风格生成器。"""
keys = jr.split(key, 10)
params = {}
# 输入投影:梅尔频带 -> 通道
params['input_w'] = jr.normal(keys[0], (7, n_mels, channels)) * 0.02
params['input_b'] = jnp.zeros(channels)
# 上采样块(转置卷积)
in_ch = channels
for i, rate in enumerate(upsample_rates):
k_size = rate * 2
scale = jnp.sqrt(2.0 / (in_ch * k_size))
out_ch = in_ch // 2
params[f'up{i}_w'] = jr.normal(keys[i+1], (k_size, in_ch, out_ch)) * scale
params[f'up{i}_b'] = jnp.zeros(out_ch)
# 每个尺度下的残差块
params[f'res{i}_0'] = init_residual_block(jr.fold_in(keys[i+4], 0),
out_ch, 3, 1)
params[f'res{i}_1'] = init_residual_block(jr.fold_in(keys[i+4], 1),
out_ch, 3, 3)
in_ch = out_ch
# 输出投影到单声道波形
params['output_w'] = jr.normal(keys[8], (7, in_ch, 1)) * 0.02
params['output_b'] = jnp.zeros(1)
params['upsample_rates'] = upsample_rates
return params
def generator_forward(params, mel):
"""mel: (batch, time, n_mels) -> waveform: (batch, time * prod(rates), 1)。"""
# 输入投影
h = jax.lax.conv_general_dilated(
mel.transpose(0, 2, 1),
params['input_w'].transpose(2, 1, 0),
window_strides=(1,), padding='SAME'
).transpose(0, 2, 1) + params['input_b']
for i, rate in enumerate(params['upsample_rates']):
h = jax.nn.leaky_relu(h, negative_slope=0.1)
# 通过转置卷积上采样
k_size = rate * 2
h = jax.lax.conv_transpose(
h.transpose(0, 2, 1),
params[f'up{i}_w'].transpose(2, 1, 0),
strides=(rate,),
padding='SAME'
).transpose(0, 2, 1) + params[f'up{i}_b']
# 残差块
h = residual_block(params[f'res{i}_0'], h)
h = residual_block(params[f'res{i}_1'], h)
h = jax.nn.leaky_relu(h, negative_slope=0.1)
out = jax.lax.conv_general_dilated(
h.transpose(0, 2, 1),
params['output_w'].transpose(2, 1, 0),
window_strides=(1,), padding='SAME'
).transpose(0, 2, 1) + params['output_b']
return jnp.tanh(out)
# 创建一个合成梅尔语谱图(模拟元音)
n_mels = 80
n_frames = 50
mel = jnp.zeros((1, n_frames, n_mels))
# 在低频梅尔频带中添加能量(模拟共振峰)
mel = mel.at[:, :, 5:15].set(1.0)
mel = mel.at[:, :, 20:25].set(0.6)
# 初始化并运行生成器
key = jr.PRNGKey(42)
params = init_generator(key, n_mels=n_mels, upsample_rates=(8, 8, 4),
channels=128)
waveform = generator_forward(params, mel)
print(f"输入梅尔形状:{mel.shape}")
print(f"输出波形形状:{waveform.shape}")
print(f"上采样因子:{8 * 8 * 4} = {8*8*4}x")
fig, axes = plt.subplots(2, 1, figsize=(12, 6))
axes[0].imshow(mel[0].T, aspect='auto', origin='lower', cmap='magma')
axes[0].set_title('输入梅尔语谱图')
axes[0].set_ylabel('梅尔频带')
axes[0].set_xlabel('')
waveform_np = waveform[0, :, 0]
axes[1].plot(waveform_np[:2000], color='#9b59b6', linewidth=0.5)
axes[1].set_title('生成器输出波形(未经训练 - 随机噪声)')
axes[1].set_ylabel('振幅')
axes[1].set_xlabel('样本')
plt.tight_layout()
plt.show()
print("注意:输出是噪声,因为生成器未经训练。")
print("在实践中,对抗损失 + 梅尔损失训练会将其塑造成语音。")
```
- **任务 4:使用简单 RNN 的语音活动检测。** 在合成音频特征上训练一个基于小型 GRU 的 VAD 模型,对帧进行语音或静音分类。
```python
import jax
import jax.numpy as jnp
import jax.random as jr
import matplotlib.pyplot as plt
# 生成合成对数梅尔能量特征及语音/静音标签
def generate_vad_data(key, n_sequences=100, n_frames=200, n_features=40):
"""模拟对数梅尔特征:语音区域能量更高且具有结构。"""
keys = jr.split(key, 5)
all_features = []
all_labels = []
for i in range(n_sequences):
k = jr.fold_in(keys[0], i)
k1, k2, k3 = jr.split(k, 3)
# 随机语音/静音模式
label = jnp.zeros(n_frames)
n_segments = jr.randint(k1, (), 2, 6)
for seg in range(int(n_segments)):
start = jr.randint(jr.fold_in(k2, seg), (), 0, n_frames - 20)
length = jr.randint(jr.fold_in(k3, seg), (), 10, 50)
end = jnp.minimum(start + length, n_frames)
label = label.at[int(start):int(end)].set(1.0)
# 特征:语音帧具有更高能量 + 频谱结构
noise = jr.normal(jr.fold_in(keys[1], i), (n_frames, n_features)) * 0.3
speech_pattern = jnp.outer(label, jnp.exp(-jnp.arange(n_features) / 15.0))
features = speech_pattern * 2.0 + noise + 0.1
all_features.append(features)
all_labels.append(label)
return jnp.stack(all_features), jnp.stack(all_labels)
key = jr.PRNGKey(123)
features, labels = generate_vad_data(key)
train_features, train_labels = features[:80], labels[:80]
test_features, test_labels = features[80:], labels[80:]
# 基于 GRU 的简单 VAD 模型
def init_vad_model(key, input_dim=40, hidden_dim=64):
keys = jr.split(key, 6)
scale_ih = jnp.sqrt(2.0 / input_dim)
scale_hh = jnp.sqrt(2.0 / hidden_dim)
return {
'W_z': jr.normal(keys[0], (input_dim, hidden_dim)) * scale_ih,
'U_z': jr.normal(keys[1], (hidden_dim, hidden_dim)) * scale_hh,
'b_z': jnp.zeros(hidden_dim),
'W_r': jr.normal(keys[2], (input_dim, hidden_dim)) * scale_ih,
'U_r': jr.normal(keys[3], (hidden_dim, hidden_dim)) * scale_hh,
'b_r': jnp.zeros(hidden_dim),
'W_h': jr.normal(keys[4], (input_dim, hidden_dim)) * scale_ih,
'U_h': jr.normal(keys[5], (hidden_dim, hidden_dim)) * scale_hh,
'b_h': jnp.zeros(hidden_dim),
'W_out': jr.normal(jr.fold_in(keys[0], 99), (hidden_dim, 1)) * 0.1,
'b_out': jnp.zeros(1),
}
def gru_step(params, h, x):
"""单步 GRU。"""
z = jax.nn.sigmoid(x @ params['W_z'] + h @ params['U_z'] + params['b_z'])
r = jax.nn.sigmoid(x @ params['W_r'] + h @ params['U_r'] + params['b_r'])
h_tilde = jnp.tanh(x @ params['W_h'] + (r * h) @ params['U_h'] + params['b_h'])
h_new = (1 - z) * h + z * h_tilde
return h_new
def vad_forward(params, x):
"""x: (batch, time, features) -> logits: (batch, time)。"""
batch_size, n_frames, _ = x.shape
hidden_dim = params['W_z'].shape[1]
h = jnp.zeros((batch_size, hidden_dim))
outputs = []
for t in range(n_frames):
h = gru_step(params, h, x[:, t, :])
logit = (h @ params['W_out'] + params['b_out']).squeeze(-1)
outputs.append(logit)
return jnp.stack(outputs, axis=1)
def bce_loss(params, features, labels):
"""VAD 的二元交叉熵损失。"""
logits = vad_forward(params, features)
probs = jax.nn.sigmoid(logits)
probs = jnp.clip(probs, 1e-7, 1 - 1e-7)
loss = -(labels * jnp.log(probs) + (1 - labels) * jnp.log(1 - probs))
return jnp.mean(loss)
grad_fn = jax.jit(jax.value_and_grad(bce_loss))
# 训练
params = init_vad_model(jr.PRNGKey(0))
lr = 5e-3
losses = []
for epoch in range(200):
loss_val, grads = grad_fn(params, train_features, train_labels)
params = jax.tree.map(lambda p, g: p - lr * g, params, grads)
losses.append(float(loss_val))
if epoch % 50 == 0:
print(f"轮次 {epoch}:损失 = {loss_val:.4f}")
# 在测试集上评估
test_logits = vad_forward(params, test_features)
test_preds = (jax.nn.sigmoid(test_logits) > 0.5).astype(jnp.float32)
accuracy = jnp.mean(test_preds == test_labels)
print(f"\n测试准确率:{accuracy:.4f}")
# 可视化一个测试示例
idx = 0
fig, axes = plt.subplots(3, 1, figsize=(14, 7))
axes[0].imshow(test_features[idx].T, aspect='auto', origin='lower', cmap='magma')
axes[0].set_title('对数梅尔能量特征')
axes[0].set_ylabel('梅尔频带')
axes[1].fill_between(range(200), test_labels[idx], alpha=0.4, color='#27ae60',
label='真实值')
axes[1].plot(jax.nn.sigmoid(test_logits[idx]), color='#e74c3c',
linewidth=1.5, label='预测概率')
axes[1].axhline(0.5, color='gray', linestyle='--', linewidth=0.8)
axes[1].set_ylabel('语音概率')
axes[1].legend()
axes[1].set_title('VAD 预测')
axes[2].fill_between(range(200), test_labels[idx], alpha=0.4, color='#27ae60',
label='真实值')
axes[2].fill_between(range(200), test_preds[idx], alpha=0.4, color='#f39c12',
label='预测(阈值=0.5')
axes[2].set_ylabel('语音 / 静音')
axes[2].set_xlabel('')
axes[2].legend()
axes[2].set_title('VAD 二值决策')
plt.tight_layout()
plt.show()
```