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/ 构建缓存
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
# 数字信号处理
|
||||
|
||||
*数字信号处理将原始音频波形转换为结构化表示,机器学习模型可以从中学习。本文涵盖声音物理学、采样与量化、傅里叶变换(DFT、FFT)、语谱图、梅尔滤波器组、MFCC 和加窗,以及所有语音和音频 AI 所需的特征提取流水线。*
|
||||
|
||||
- **声音**是一种通过介质(空气、水、固体)传播的压力波。振动物体(声带、吉他弦、扬声器纸盆)推拉空气分子,产生交替的高压区域(压缩)和低压区域(稀疏)。
|
||||
|
||||
- 这些压力变化以大约 343 m/s 的速度在空气中向外传播,到达你的耳朵后,使耳膜振动并转换为神经信号。
|
||||
|
||||
- 可以把声音想象成向平静的水面投下一块石头:石头是振动源,涟漪是压力波,水面漂浮的软木塞就是麦克风或耳膜,它响应着波的到来。
|
||||
|
||||
- 软木塞上下浮动的幅度是**振幅**,每秒浮动的次数是**频率**,波到达时软木塞是处于浮动的最高点还是最低点则是**相位**。
|
||||
|
||||
- **波形**是压力(或电压,在麦克风将声音转换为电信号后)随时间变化的曲线图。最简单的波形是**纯音**,即单一正弦波:
|
||||
|
||||
$$x(t) = A \sin(2\pi f t + \phi)$$
|
||||
|
||||
- 其中:
|
||||
- $A$ 是振幅(偏离零点的最大偏差,决定响度),
|
||||
- $f$ 是以 Hz 为单位的频率(每秒周期数,决定音高),
|
||||
- $\phi$ 是以弧度为单位的相位(波的时间偏移)。
|
||||
|
||||
- **周期** $T = 1/f$,是一个完整周期持续的时长。
|
||||
|
||||

|
||||
|
||||
- **振幅**决定了感知到的响度。振幅加倍,功率变为四倍(因为功率与振幅的平方成正比)。
|
||||
|
||||
- 人耳的听觉范围覆盖极大的振幅跨度,因此我们使用对数刻度:**分贝**(dB)。声压级的计算方式为:
|
||||
|
||||
$$L = 20 \log_{10}\left(\frac{A}{A_\text{ref}}\right) \text{ dB}$$
|
||||
|
||||
- 其中 $A_\text{ref}$ 是参考振幅(通常取听阈,$20 \mu\text{Pa}$)。耳语约为 30 dB,正常对话 60 dB,摇滚音乐会 110 dB。每增加 6 dB,振幅大约翻倍;每增加 10 dB,感知响度大约翻倍。此处的对数与第 03 章中的对数函数相同。
|
||||
|
||||
- **频率**决定音高。低频(20–250 Hz)听起来低沉;高频(2000–20000 Hz)听起来尖锐。人耳听觉范围大致为 20 Hz 到 20 kHz。音乐会标准音 A 为 440 Hz。频率加倍,音高升高一个**八度**。
|
||||
|
||||
- 大多数自然声音不是纯音,而是许多频率的复杂混合——这就是为什么钢琴和小提琴演奏同一个音符时听起来不同:它们共享相同的**基频**,但**谐波**(基频的整数倍)及其相对振幅(**音色**)不同。
|
||||
|
||||
- **相位**决定了波从其周期中的哪个起点开始。两个振幅和频率相同但相位不同的波可以发生相长干涉(相位对齐,振幅相加)或相消干涉(相位相反,振幅抵消)。
|
||||
|
||||
- 相位在立体声音频和波束成形中至关重要,但在许多语音处理流水线中基本上被丢弃,因为人类对音高和音色的感知大多与相位无关。
|
||||
|
||||
- 现实世界的音频信号是时间的**连续**函数,但计算机处理的是离散数值。**采样**通过以固定间隔测量信号值,将连续信号转换为离散序列。
|
||||
|
||||
- **采样率** $f_s$ 是每秒的测量次数。CD 音频使用 $f_s = 44{,}100$ Hz;电话通信使用 8000 Hz;现代语音模型通常使用 16000 Hz。
|
||||
|
||||
- **奈奎斯特-香农采样定理**指出:当且仅当采样率至少是信号中最高频率的两倍时,连续信号才能从其样本中完美重建:
|
||||
|
||||
$$f_s \geq 2 f_\text{max}$$
|
||||
|
||||
- 频率 $f_s / 2$ 称为**奈奎斯特频率**。如果信号中包含高于奈奎斯特频率的频率成分,这些频率会折叠回有效范围内,表现为虚假的低频成分。这种现象称为**混叠**。混叠是不可逆的:一旦发生,就无法从样本中恢复原始信号。
|
||||
|
||||
- 混叠的日常类比是电影中的马车轮效应:车轮转速刚好高于帧率时,看起来像是在缓慢地倒转,因为摄像机对旋转的采样不足。在音频中,一个 15 kHz 的音调以 16 kHz 采样($f_\text{奈奎斯特} = 8$ kHz)时,会混叠为 $16 - 15 = 1$ kHz,一个完全不同的音高。
|
||||
|
||||

|
||||
|
||||
- 为防止混叠,**抗混叠滤波器**(一个低通滤波器)在采样前滤除所有高于 $f_s/2$ 的频率。这一步由模数转换器(ADC)硬件在信号数字化之前完成。
|
||||
|
||||
- **量化**将每个连续取值的样本映射到有限电平集合中的最近值。一个 $n$ 位量化器有 $2^n$ 个电平。CD 音频使用 16 位量化($2^{16} = 65{,}536$ 个电平);电话通信通常使用 8 位配合 $\mu$ 律或 A 律**压扩**(一种非线性映射,为小振幅分配更多电平,以匹配人类感知)。量化会引入**量化噪声**,这是一种舍入误差,其方差为 $\Delta^2/12$,其中 $\Delta$ 是相邻电平之间的步长。
|
||||
|
||||
- **时域分析**直接从波形中提取特征,无需变换到其他域。这些特征简单、计算快速,能够捕捉信号的基本性质。
|
||||
|
||||
- **能量**衡量一帧(共 $N$ 个样本)的整体响度:
|
||||
|
||||
$$E = \sum_{n=0}^{N-1} x[n]^2$$
|
||||
|
||||
- 语音段能量高;静音段能量低。能量是第 01 章中平方 $\ell_2$ 范数在信号向量上的应用。
|
||||
|
||||
- **过零率**(ZCR)统计一帧内信号改变符号的次数:
|
||||
|
||||
$$\text{ZCR} = \frac{1}{2(N-1)} \sum_{n=1}^{N-1} |\text{sign}(x[n]) - \text{sign}(x[n-1])|$$
|
||||
|
||||
- 高 ZCR 表明高频成分或噪声;低 ZCR 表明低频成分或浊音(声带周期性振动时)。ZCR 是一种粗略的频率估计方法:一个 $f$ Hz 的纯音每秒过零 $2f$ 次。
|
||||
|
||||
- **自相关**衡量信号与其延迟副本之间的相似度:
|
||||
|
||||
$$R[k] = \sum_{n=0}^{N-1-k} x[n] \cdot x[n+k]$$
|
||||
|
||||
- 在延迟 $k = 0$ 处,自相关等于能量。对于周期信号,自相关在等于周期及其整数倍的延迟处出现峰值。这是**基音检测**的标准技术:找出 $R[k]$ 在 $k=0$ 之后的第一个显著峰值,则基音频率为 $f_s / k_\text{峰值}$。自相关与第 01 章的点积相关:$R[k]$ 是信号与其 $k$ 位移版本的点积。
|
||||
|
||||
- **频域分析**揭示信号的频谱内容,这些信息在波形中不可见。核心工具是**离散傅里叶变换**(DFT),它将 $N$ 个样本的信号分解为 $N$ 个复数值的频率分量:
|
||||
|
||||
$$X[k] = \sum_{n=0}^{N-1} x[n] \cdot e^{-j 2\pi k n / N}, \quad k = 0, 1, \ldots, N-1$$
|
||||
|
||||
- 每个 $X[k]$ 是一个复数,其幅度 $|X[k]|$ 给出频率 $f_k = k \cdot f_s / N$ Hz 处的振幅,相位 $\angle X[k]$ 给出相位偏移。DFT 是从时域基(单位脉冲)到频域基(复指数)的基变换,这是第 02 章基概念的直接应用。DFT 可以写为矩阵乘法 $\mathbf{X} = W \mathbf{x}$,其中 $W$ 是 $N \times N$ 的 DFT 矩阵,其元素为 $W_{kn} = e^{-j2\pi kn/N}$。
|
||||
|
||||
- **快速傅里叶变换**(FFT)是一种以 $O(N \log N)$ 次运算计算 DFT 的算法(而非朴素的 $O(N^2)$),其原理是将问题递归地拆分为偶数索引和奇数索引的子问题(库利-图基算法)。这种加速使得实时频谱分析成为可能。FFT 是整个计算领域最重要的算法之一。
|
||||
|
||||
- **功率谱** $|X[k]|^2$ 显示能量在各频率上的分布。**幅度谱** $|X[k]|$ 显示振幅。绘制这些谱图可以揭示哪些频率主导了信号:元音在基频的整数倍处有强谐波;擦音(如"s")在宽高频范围内有能量分布。
|
||||
|
||||
- **语谱图**是信号频率内容随时间变化的可视化表示。它是将信号切分成短的、重叠的帧,对每帧计算 FFT,然后将得到的幅度谱并排放置。横轴是时间,纵轴是频率,每个点的颜色(或亮度)代表幅度。语谱图是音频处理中最重要的单一可视化工具。
|
||||
|
||||

|
||||
|
||||
- **梅尔刻度**是一种感知频率刻度,反映人类对音高的感知方式。人类将频率的等比率感知为音高的等间隔(正如我们将强度的等比率感知为响度的等间隔)。在约 1000 Hz 以下,梅尔刻度近似线性;在 1000 Hz 以上,它变为近似对数:
|
||||
|
||||
$$m = 2595 \log_{10}\left(1 + \frac{f}{700}\right)$$
|
||||
|
||||
- 其逆变换为 $f = 700(10^{m/2595} - 1)$。梅尔刻度解释了为什么音乐中的半音在对数频率轴上等间距排列:A4(440 Hz)到 A5(880 Hz)和 A5 到 A6(1760 Hz)听起来都是"向上一个八度",尽管以 Hz 为单位的间隔分别是 440 和 880。
|
||||
|
||||
- **梅尔滤波器组**是一组在梅尔刻度上均匀分布的三角形带通滤波器。每个滤波器覆盖一个频带,对该频带内的频谱能量进行求和,产生一个数值。典型的语音系统使用 40–80 个梅尔滤波器。低频滤波器窄(在人类感知敏感的频率分辨率高的区域),高频滤波器宽(在人类不敏感的低分辨率区域)。这模仿了人耳耳蜗的频率分辨率。
|
||||
|
||||

|
||||
|
||||
- **梅尔频率倒谱系数**(MFCC)是语音和音频的经典特征表示。它们将梅尔谱压缩为少量去相关化的系数,捕捉谱包络的形状(编码声道配置,从而编码语音身份),同时丢弃精细的谱细节(编码音高和相位)。
|
||||
|
||||
- MFCC 流水线:
|
||||
1. **预加重**:应用一阶高通滤波器 $y[n] = x[n] - \alpha x[n-1]$(通常 $\alpha = 0.97$)以提升被声道衰减的高频成分。
|
||||
2. **分帧**:将信号切分为重叠的帧(通常 25 ms 长,步进 10 ms)。
|
||||
3. **加窗**:对每帧乘以窗口函数(汉明窗)以减少频谱泄漏(见下文)。
|
||||
4. **FFT**:计算每帧加窗后的功率谱。
|
||||
5. **梅尔滤波器组**:对功率谱应用三角形梅尔滤波器组,得到梅尔频带能量。
|
||||
6. **对数**:对梅尔频带能量取对数。对数压缩动态范围,并将乘法(频谱分量之间)转换为加法,匹配人类响度感知。
|
||||
7. **DCT**:对对数梅尔能量应用离散余弦变换。DCT 对梅尔频带进行去相关化(因为相邻频带高度相关)并将能量压缩到前几个系数中。保留前 13 个系数(MFCC-0 至 MFCC-12)。
|
||||
|
||||

|
||||
|
||||
- 第 7 步中的 DCT 本质上是"频谱的傅里叶变换"(因此得名**倒谱** cepstrum = spectrum 的字母重排)。低阶倒谱系数捕捉宽泛的谱形状(声道谐振,称为**共振峰**),而高阶系数捕捉精细的谱细节(音高谐波)。通过只保留前 13 个系数,我们保留了共振峰信息并丢弃了音高细节。
|
||||
|
||||
- **Delta** 和 **delta-delta** MFCC(MFCC 的一阶和二阶时间导数,通过相邻帧之间的有限差分计算)捕捉谱形状的动态变化,增加时间上下文。完整的 MFCC 特征向量通常是 39 维的:13 个静态 + 13 个 delta + 13 个 delta-delta。
|
||||
|
||||
- 现代神经网络模型(第 06 章)已在很大程度上用学习到的特征取代了 MFCC:对数梅尔语谱图(第 6 步的输出,跳过 DCT)是深度学习 ASR 和音频分类的标准输入。模型学习自己的去相关化。尽管如此,MFCC 在低资源场景、经典 ML 流水线以及理解信号处理基础方面仍然很重要。
|
||||
|
||||
- **加窗**是在计算 FFT 之前对信号帧乘以平滑窗口函数的过程。不加窗时,FFT 假设帧无限重复;帧的突然开始和结束会创建人工的不连续性,使能量扩散到所有频率,这种伪影称为**频谱泄漏**。
|
||||
|
||||
- **矩形窗** $w[n] = 1$ 对所有 $n$:无渐减,泄漏最大,但主瓣最宽(在给定帧长下频率分辨率最佳)。实践中很少使用。
|
||||
|
||||
- **汉明窗**:$w[n] = 0.54 - 0.46 \cos(2\pi n / (N-1))$。在边缘处渐减到接近零,大大减少泄漏。是语音处理的标准选择。
|
||||
|
||||
- **汉宁窗**(也称为 Hanning 窗):$w[n] = 0.5 - 0.5 \cos(2\pi n / (N-1))$。在边缘处精确渐减到零。与汉明窗非常相似,但旁瓣抑制略好。
|
||||
|
||||
- **布莱克曼窗**:$w[n] = 0.42 - 0.5 \cos(2\pi n / (N-1)) + 0.08 \cos(4\pi n / (N-1))$。旁瓣抑制更好,但主瓣更宽(频率分辨率更差)。当旁瓣伪影特别严重时使用。
|
||||
|
||||
- 存在一个根本性的权衡:泄漏越少的窗口,主瓣越宽,意味着它们无法分辨两个间隔很近的频率。这就是**频谱分辨率与泄漏的权衡**,是第 03 章不确定原理的结果。
|
||||
|
||||
- **重叠相加**(OLA)是一种从加窗、处理后的帧重建信号的技术。帧之间有重叠(通常 50–75%),处理后将加窗后的输出相加。如果窗口和重叠选择得当(例如,汉宁窗配合 50% 重叠),重叠的窗口相加为常数,可实现完美重建。这对任何基于帧的音频修改(降噪、变调、变速)都至关重要。
|
||||
|
||||
- **短时傅里叶变换**(STFT)是语谱图背后的正式框架。它对信号的每个加窗帧应用 DFT:
|
||||
|
||||
```math
|
||||
\text{STFT}\{x[n]\}(m, k) = \sum_{n=0}^{N-1} x[n + mH] \cdot w[n] \cdot e^{-j 2\pi k n / N}
|
||||
```
|
||||
|
||||
- 其中 $m$ 是帧索引,$H$ 是步进大小(连续帧之间的样本数),$w[n]$ 是窗口函数,$N$ 是 FFT 大小。输出是一个二维复数值矩阵:信号的**时频表示**。
|
||||
|
||||
- STFT 体现了根本的**时频权衡**:
|
||||
- 长帧(大 $N$):频率分辨率高(能区分间隔很近的频率),但时间分辨率差(无法精确定位频率何时变化)。
|
||||
- 短帧(小 $N$):时间分辨率高,但频率分辨率差。
|
||||
- 时间分辨率和频率分辨率的乘积有下界:$\Delta t \cdot \Delta f \geq \frac{1}{4\pi}$。这是**加伯极限**,是物理中海森堡不确定原理在信号处理中的类比。
|
||||
|
||||
- 典型语音 STFT 参数:25 ms 帧长(在 16 kHz 下 $N = 400$),10 ms 步进($H = 160$),汉明窗,512 点 FFT(从 400 进行零填充以提高效率和频谱插值平滑度)。
|
||||
|
||||
- **滤波**通过放大某些频率和衰减其他频率来修改信号的频率内容。**滤波器**是一个接受输入信号并产生输出信号的系统。滤波器由其**频率响应** $H(f)$ 表征,它描述了每个频率上所施加的增益和相位偏移。
|
||||
|
||||
- **低通滤波器**:通过低于截止频率 $f_c$ 的频率,衰减高于 $f_c$ 的频率。用于去除高频噪声和细节。采样前的抗混叠滤波器就是低通滤波器。
|
||||
|
||||
- **高通滤波器**:通过高于 $f_c$ 的频率,衰减低于 $f_c$ 的频率。用于去除低频隆隆声和直流偏移。MFCC 提取中的预加重滤波器($y[n] = x[n] - 0.97 x[n-1]$)就是一个简单的高通滤波器。
|
||||
|
||||
- **带通滤波器**:通过范围 $[f_1, f_2]$ 内的频率,衰减范围外的频率。梅尔滤波器组中的每个三角形就是一个带通滤波器。
|
||||
|
||||
- **带阻(陷波)滤波器**:衰减特定的窄频范围。用于去除特定干扰(例如 50/60 Hz 的电源线嗡嗡声)。
|
||||
|
||||
- **有限冲激响应**(FIR)滤波器将每个输出样本计算为当前和过去输入样本的加权和:
|
||||
|
||||
$$y[n] = \sum_{k=0}^{M} b_k \cdot x[n-k]$$
|
||||
|
||||
- 权重 $b_k$ 是**滤波器系数**(也称为**抽头**)。滤波器的阶数为 $M$。FIR 滤波器始终稳定(输出不会发散),并且可以设计为具有完美的线性相位(所有频率的延迟相同,从而保持波形形状)。其缺点是实现陡峭的截止需要大量抽头(高 $M$),增加了计算量。输出是输入与系数向量的卷积,正是第 06 章中的一维卷积运算。
|
||||
|
||||
- **无限冲激响应**(IIR)滤波器使用反馈:输出既依赖于过去的输入,也依赖于过去的输出:
|
||||
|
||||
```math
|
||||
y[n] = \sum_{k=0}^{M} b_k \cdot x[n-k] - \sum_{k=1}^{L} a_k \cdot y[n-k]
|
||||
```
|
||||
|
||||
- 反馈项 $a_k$ 创建了一个递归结构,其冲激响应理论上持续无限长。IIR 滤波器可以用比 FIR 滤波器少得多的系数实现陡峭的截止,但可能不稳定(如果传递函数的极点位于单位圆之外,输出将无界增长——这是 $z$ 变换中的概念)。它们还具有非线性相位,可能使波形形状失真。经典滤波器设计(巴特沃斯、切比雪夫、椭圆滤波器)都是 IIR 的。
|
||||
|
||||
- **传递函数**通过 $z$ 变换获得:
|
||||
|
||||
$$H(z) = \frac{\sum_{k=0}^{M} b_k z^{-k}}{1 + \sum_{k=1}^{L} a_k z^{-k}}$$
|
||||
|
||||
- 分子的根称为**零点**,分母的根称为**极点**。极零点图完全刻画了滤波器的行为。单位圆附近的极点放大附近的频率;单位圆附近的零点衰减它们。FIR 滤波器只有零点(分母为 1)。这与第 02 章和第 03 章中的特征值和求根概念相联系。
|
||||
|
||||
- **卷积定理**:时域中的卷积等于频域中的逐元素乘法。这意味着滤波既可以通过将信号与滤波器的冲激响应直接卷积来实现,也可以通过将它们的傅里叶变换相乘再逆变换来实现。对于长滤波器,频域方法(使用 FFT)更快:$O(N \log N)$ 对比 $O(NM)$。
|
||||
|
||||
- **逆 STFT**(iSTFT)从其 STFT 表示重建时域信号。这对于任何在频域中修改音频的系统(降噪、源分离、语音转换)都至关重要。重建使用重叠相加:
|
||||
|
||||
```math
|
||||
x[n] = \frac{\sum_{m} w[n - mH] \cdot \text{IDFT}\{X(m, k)\}[n - mH]}{\sum_{m} w[n - mH]^2}
|
||||
```
|
||||
|
||||
- 分母对窗口重叠进行归一化,确保当合成窗口与分析窗口匹配且重叠足够时实现完美重建。
|
||||
|
||||
- **语音 DSP 流水线总结**:原始音频以 16 kHz 采样、预加重、切分为 25 ms 的汉明窗帧(步进 10 ms),每帧进行 FFT 变换,通过梅尔滤波器组,进行对数压缩,然后要么保留为对数梅尔特征(用于神经网络模型),要么进行 DCT 变换生成 MFCC(用于经典模型)。整个流水线将一维时域信号转换为适合下游机器学习的二维时频表示,这将是文件 02 的主题。
|
||||
|
||||
## 编程练习(在 CoLab 或 notebook 中完成)
|
||||
|
||||
1. 生成一个正弦波,以不同采样率采样,演示混叠现象。绘制连续信号、正确采样版本和欠采样(混叠)版本的对比图。
|
||||
```python
|
||||
import jax.numpy as jnp
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# 参数
|
||||
f_signal = 5.0 # 5 Hz 信号
|
||||
duration = 1.0 # 1 秒
|
||||
|
||||
# "连续"信号(非常高的采样率)
|
||||
t_cont = jnp.linspace(0, duration, 10000)
|
||||
x_cont = jnp.sin(2 * jnp.pi * f_signal * t_cont)
|
||||
|
||||
# 正确采样(fs = 50 Hz,远高于奈奎斯特频率 10 Hz)
|
||||
fs_good = 50
|
||||
t_good = jnp.arange(0, duration, 1.0 / fs_good)
|
||||
x_good = jnp.sin(2 * jnp.pi * f_signal * t_good)
|
||||
|
||||
# 欠采样(fs = 7 Hz,低于奈奎斯特频率 10 Hz)-> 混叠
|
||||
fs_bad = 7
|
||||
t_bad = jnp.arange(0, duration, 1.0 / fs_bad)
|
||||
x_bad = jnp.sin(2 * jnp.pi * f_signal * t_bad)
|
||||
|
||||
# 混叠后的频率:|f_signal - fs_bad| = |5 - 7| = 2 Hz
|
||||
f_alias = abs(f_signal - fs_bad)
|
||||
x_alias_cont = jnp.sin(2 * jnp.pi * f_alias * t_cont)
|
||||
|
||||
fig, axes = plt.subplots(3, 1, figsize=(12, 9))
|
||||
|
||||
# 图 1:原始信号
|
||||
axes[0].plot(t_cont, x_cont, color='#3498db', linewidth=1.5, label=f'原始 {f_signal} Hz 信号')
|
||||
axes[0].set_title(f'原始 {f_signal} Hz 信号')
|
||||
axes[0].set_xlabel('时间 (s)'); axes[0].set_ylabel('振幅')
|
||||
axes[0].legend(); axes[0].grid(True, alpha=0.3)
|
||||
|
||||
# 图 2:正确采样
|
||||
axes[1].plot(t_cont, x_cont, color='#3498db', linewidth=1, alpha=0.4, label='原始信号')
|
||||
axes[1].stem(t_good, x_good, linefmt='#27ae60', markerfmt='o', basefmt='k-',
|
||||
label=f'以 {fs_good} Hz 采样(高于奈奎斯特频率)')
|
||||
axes[1].set_title(f'正确采样:fs = {fs_good} Hz > 2 x {f_signal} Hz')
|
||||
axes[1].set_xlabel('时间 (s)'); axes[1].set_ylabel('振幅')
|
||||
axes[1].legend(); axes[1].grid(True, alpha=0.3)
|
||||
|
||||
# 图 3:混叠采样
|
||||
axes[2].plot(t_cont, x_cont, color='#3498db', linewidth=1, alpha=0.4, label='原始信号')
|
||||
axes[2].stem(t_bad, x_bad, linefmt='#e74c3c', markerfmt='o', basefmt='k-',
|
||||
label=f'以 {fs_bad} Hz 采样(低于奈奎斯特频率)')
|
||||
axes[2].plot(t_cont, x_alias_cont, color='#f39c12', linewidth=1.5, linestyle='--',
|
||||
label=f'混叠信号表现为 {f_alias} Hz')
|
||||
axes[2].set_title(f'混叠采样:fs = {fs_bad} Hz < 2 x {f_signal} Hz')
|
||||
axes[2].set_xlabel('时间 (s)'); axes[2].set_ylabel('振幅')
|
||||
axes[2].legend(); axes[2].grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout(); plt.show()
|
||||
```
|
||||
|
||||
2. 计算并可视化由多个正弦波组成的信号的 FFT。显示幅度谱并识别组成频率。
|
||||
```python
|
||||
import jax.numpy as jnp
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# 创建复合信号:220 Hz + 440 Hz + 880 Hz(A3 + A4 + A5)
|
||||
fs = 8000 # 8 kHz 采样率
|
||||
duration = 0.1 # 100 ms
|
||||
t = jnp.arange(0, duration, 1.0 / fs)
|
||||
n_samples = len(t)
|
||||
|
||||
# 三个频率分量,不同振幅
|
||||
x = 1.0 * jnp.sin(2 * jnp.pi * 220 * t) + \
|
||||
0.6 * jnp.sin(2 * jnp.pi * 440 * t) + \
|
||||
0.3 * jnp.sin(2 * jnp.pi * 880 * t)
|
||||
|
||||
# 计算 FFT
|
||||
X = jnp.fft.fft(x)
|
||||
freqs = jnp.fft.fftfreq(n_samples, d=1.0 / fs)
|
||||
magnitude = jnp.abs(X) / n_samples # 归一化
|
||||
|
||||
# 只绘制正频率部分
|
||||
pos_mask = freqs >= 0
|
||||
freqs_pos = freqs[pos_mask]
|
||||
mag_pos = magnitude[pos_mask] * 2 # 翻倍以补偿负频率的能量
|
||||
|
||||
fig, axes = plt.subplots(2, 1, figsize=(12, 7))
|
||||
|
||||
# 时域
|
||||
axes[0].plot(t * 1000, x, color='#3498db', linewidth=1)
|
||||
axes[0].set_title('复合信号:220 Hz + 440 Hz + 880 Hz')
|
||||
axes[0].set_xlabel('时间 (ms)'); axes[0].set_ylabel('振幅')
|
||||
axes[0].grid(True, alpha=0.3)
|
||||
|
||||
# 频域
|
||||
axes[1].plot(freqs_pos, mag_pos, color='#e74c3c', linewidth=1.5)
|
||||
axes[1].set_title('幅度谱(FFT)')
|
||||
axes[1].set_xlabel('频率 (Hz)'); axes[1].set_ylabel('幅度')
|
||||
axes[1].set_xlim(0, 1500)
|
||||
# 标注峰值
|
||||
for f_peak, amp in [(220, 1.0), (440, 0.6), (880, 0.3)]:
|
||||
axes[1].annotate(f'{f_peak} Hz', xy=(f_peak, amp), fontsize=10,
|
||||
ha='center', va='bottom', color='#9b59b6',
|
||||
arrowprops=dict(arrowstyle='->', color='#9b59b6'))
|
||||
axes[1].grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout(); plt.show()
|
||||
```
|
||||
|
||||
3. 在 JAX 中从头构建完整的 MFCC 流水线:预加重、分帧、加窗、FFT、梅尔滤波器组、对数、DCT。可视化梅尔滤波器组和生成的 MFCC 热力图。
|
||||
```python
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# --- 生成一个合成类语音信号 ---
|
||||
key = jax.random.PRNGKey(42)
|
||||
fs = 16000
|
||||
duration = 1.0
|
||||
t = jnp.arange(0, duration, 1.0 / fs)
|
||||
|
||||
# 模拟浊音语音:基频 + 谐波,振幅衰减
|
||||
f0 = 150.0 # 基频
|
||||
x = sum(jnp.sin(2 * jnp.pi * f0 * k * t) / k for k in range(1, 8))
|
||||
# 添加一些噪声
|
||||
x = x + 0.1 * jax.random.normal(key, t.shape)
|
||||
x = x / jnp.max(jnp.abs(x)) # 归一化
|
||||
|
||||
# --- 第 1 步:预加重 ---
|
||||
alpha = 0.97
|
||||
x_pre = jnp.concatenate([x[:1], x[1:] - alpha * x[:-1]])
|
||||
|
||||
# --- 第 2 步:分帧 ---
|
||||
frame_len = int(0.025 * fs) # 25 ms = 400 个样本
|
||||
hop_len = int(0.010 * fs) # 10 ms = 160 个样本
|
||||
n_frames = (len(x_pre) - frame_len) // hop_len + 1
|
||||
frames = jnp.stack([x_pre[i * hop_len : i * hop_len + frame_len]
|
||||
for i in range(n_frames)])
|
||||
|
||||
# --- 第 3 步:汉明窗 ---
|
||||
hamming = 0.54 - 0.46 * jnp.cos(2 * jnp.pi * jnp.arange(frame_len) / (frame_len - 1))
|
||||
windowed = frames * hamming
|
||||
|
||||
# --- 第 4 步:FFT ---
|
||||
n_fft = 512
|
||||
spectra = jnp.fft.rfft(windowed, n=n_fft)
|
||||
power_spectra = jnp.abs(spectra) ** 2 / n_fft
|
||||
|
||||
# --- 第 5 步:梅尔滤波器组 ---
|
||||
n_mels = 40
|
||||
f_min, f_max = 0.0, fs / 2.0
|
||||
|
||||
def hz_to_mel(f):
|
||||
return 2595 * jnp.log10(1 + f / 700)
|
||||
|
||||
def mel_to_hz(m):
|
||||
return 700 * (10 ** (m / 2595) - 1)
|
||||
|
||||
mel_min = hz_to_mel(f_min)
|
||||
mel_max = hz_to_mel(f_max)
|
||||
mel_points = jnp.linspace(mel_min, mel_max, n_mels + 2)
|
||||
hz_points = mel_to_hz(mel_points)
|
||||
|
||||
freq_bins = jnp.floor((n_fft + 1) * hz_points / fs).astype(jnp.int32)
|
||||
n_freqs = n_fft // 2 + 1
|
||||
filterbank = jnp.zeros((n_mels, n_freqs))
|
||||
|
||||
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(int(f_left), int(f_center)):
|
||||
if f_center != f_left:
|
||||
filterbank = filterbank.at[m, k].set((k - f_left) / (f_center - f_left))
|
||||
# 下降沿
|
||||
for k in range(int(f_center), int(f_right)):
|
||||
if f_right != f_center:
|
||||
filterbank = filterbank.at[m, k].set((f_right - k) / (f_right - f_center))
|
||||
|
||||
# 应用滤波器组
|
||||
mel_spectra = jnp.dot(power_spectra, filterbank.T)
|
||||
|
||||
# --- 第 6 步:对数 ---
|
||||
log_mel = jnp.log(mel_spectra + 1e-10)
|
||||
|
||||
# --- 第 7 步:DCT(第二类) ---
|
||||
n_mfcc = 13
|
||||
n_mel_channels = log_mel.shape[1]
|
||||
dct_matrix = jnp.zeros((n_mfcc, n_mel_channels))
|
||||
for i in range(n_mfcc):
|
||||
for j in range(n_mel_channels):
|
||||
dct_matrix = dct_matrix.at[i, j].set(
|
||||
jnp.cos(jnp.pi * i * (j + 0.5) / n_mel_channels)
|
||||
)
|
||||
mfccs = jnp.dot(log_mel, dct_matrix.T)
|
||||
|
||||
# --- 可视化 ---
|
||||
fig, axes = plt.subplots(3, 1, figsize=(14, 11))
|
||||
|
||||
# 梅尔滤波器组
|
||||
freq_axis = jnp.linspace(0, fs / 2, n_freqs)
|
||||
for m in range(n_mels):
|
||||
color = '#3498db' if m % 2 == 0 else '#e74c3c'
|
||||
axes[0].plot(freq_axis, filterbank[m], color=color, alpha=0.6, linewidth=0.8)
|
||||
axes[0].set_title(f'梅尔滤波器组({n_mels} 个滤波器)')
|
||||
axes[0].set_xlabel('频率 (Hz)'); axes[0].set_ylabel('权重')
|
||||
axes[0].grid(True, alpha=0.3)
|
||||
|
||||
# 对数梅尔语谱图
|
||||
im1 = axes[1].imshow(log_mel.T, aspect='auto', origin='lower',
|
||||
extent=[0, duration, 0, n_mels], cmap='viridis')
|
||||
axes[1].set_title('对数梅尔语谱图')
|
||||
axes[1].set_xlabel('时间 (s)'); axes[1].set_ylabel('梅尔频带')
|
||||
plt.colorbar(im1, ax=axes[1], label='对数能量')
|
||||
|
||||
# MFCC
|
||||
im2 = axes[2].imshow(mfccs.T, aspect='auto', origin='lower',
|
||||
extent=[0, duration, 0, n_mfcc], cmap='coolwarm')
|
||||
axes[2].set_title(f'MFCC(前 {n_mfcc} 个系数)')
|
||||
axes[2].set_xlabel('时间 (s)'); axes[2].set_ylabel('MFCC 索引')
|
||||
plt.colorbar(im2, ax=axes[2], label='系数值')
|
||||
|
||||
plt.tight_layout(); plt.show()
|
||||
```
|
||||
|
||||
4. 实现 FIR 低通和高通滤波器,并可视化它们对包含低频和高频分量信号的影响。同时显示时域和频域的视图。
|
||||
```python
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# 创建包含低频(100 Hz)和高频(2000 Hz)分量的信号
|
||||
fs = 8000
|
||||
duration = 0.05 # 50 ms,便于清晰显示
|
||||
t = jnp.arange(0, duration, 1.0 / fs)
|
||||
|
||||
x_low = jnp.sin(2 * jnp.pi * 100 * t)
|
||||
x_high = 0.5 * jnp.sin(2 * jnp.pi * 2000 * t)
|
||||
x = x_low + x_high
|
||||
|
||||
# 使用窗函数法设计简单的 FIR 低通滤波器
|
||||
def fir_lowpass(cutoff_hz, fs, n_taps=51):
|
||||
"""使用窗函数法设计 FIR 低通滤波器。"""
|
||||
fc = cutoff_hz / fs # 归一化截止频率
|
||||
n = jnp.arange(n_taps)
|
||||
mid = (n_taps - 1) / 2.0
|
||||
# Sinc 函数(理想低通冲激响应)
|
||||
h = jnp.where(n == mid, 2 * fc,
|
||||
jnp.sin(2 * jnp.pi * fc * (n - mid)) / (jnp.pi * (n - mid)))
|
||||
# 应用汉明窗
|
||||
window = 0.54 - 0.46 * jnp.cos(2 * jnp.pi * n / (n_taps - 1))
|
||||
h = h * window
|
||||
h = h / jnp.sum(h) # 归一化到直流增益为 1
|
||||
return h
|
||||
|
||||
def apply_filter(x, h):
|
||||
"""通过卷积应用 FIR 滤波器。"""
|
||||
return jnp.convolve(x, h, mode='same')
|
||||
|
||||
# 500 Hz 低通滤波器(通过 100 Hz,阻塞 2000 Hz)
|
||||
h_lp = fir_lowpass(500, fs, n_taps=51)
|
||||
x_lp = apply_filter(x, h_lp)
|
||||
|
||||
# 高通 = 冲激 - 低通(频谱反转)
|
||||
delta = jnp.zeros(51)
|
||||
delta = delta.at[25].set(1.0)
|
||||
h_hp = delta - h_lp
|
||||
x_hp = apply_filter(x, h_hp)
|
||||
|
||||
# 计算所有信号的频谱
|
||||
def compute_spectrum(signal, fs):
|
||||
X = jnp.fft.rfft(signal)
|
||||
freqs = jnp.fft.rfftfreq(len(signal), d=1.0 / fs)
|
||||
mag = jnp.abs(X) / len(signal) * 2
|
||||
return freqs, mag
|
||||
|
||||
fig, axes = plt.subplots(3, 2, figsize=(14, 10))
|
||||
|
||||
# 时域图
|
||||
for i, (sig, title, color) in enumerate([
|
||||
(x, '原始信号(100 Hz + 2000 Hz)', '#3498db'),
|
||||
(x_lp, '低通滤波后(< 500 Hz)', '#27ae60'),
|
||||
(x_hp, '高通滤波后(> 500 Hz)', '#e74c3c')
|
||||
]):
|
||||
axes[i, 0].plot(t * 1000, sig[:len(t)], color=color, linewidth=1)
|
||||
axes[i, 0].set_title(f'时域:{title}')
|
||||
axes[i, 0].set_xlabel('时间 (ms)'); axes[i, 0].set_ylabel('振幅')
|
||||
axes[i, 0].grid(True, alpha=0.3)
|
||||
|
||||
# 频域图
|
||||
for i, (sig, title, color) in enumerate([
|
||||
(x, '原始信号', '#3498db'),
|
||||
(x_lp, '低通', '#27ae60'),
|
||||
(x_hp, '高通', '#e74c3c')
|
||||
]):
|
||||
freqs, mag = compute_spectrum(sig, fs)
|
||||
axes[i, 1].plot(freqs, mag, color=color, linewidth=1.5)
|
||||
axes[i, 1].set_title(f'频谱:{title}')
|
||||
axes[i, 1].set_xlabel('频率 (Hz)'); axes[i, 1].set_ylabel('幅度')
|
||||
axes[i, 1].set_xlim(0, 3000)
|
||||
axes[i, 1].axvline(x=500, color='#f39c12', linestyle='--', alpha=0.7,
|
||||
label='截止频率(500 Hz)')
|
||||
axes[i, 1].legend(); axes[i, 1].grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout(); plt.show()
|
||||
```
|
||||
@@ -0,0 +1,592 @@
|
||||
# 自动语音识别
|
||||
|
||||
*自动语音识别将口语音频转换为书面文本,弥合人类语音与机器可读语言之间的鸿沟。本文涵盖 GMM-HMM、CTC 损失、RNN-转导器、基于注意力的编码器-解码器模型(LAS)、Whisper 以及端到端 ASR,从经典流水线到现代神经架构。*
|
||||
|
||||
- **自动语音识别**(ASR)是将口语音频转换为书面文本的任务。它是 AI 领域最古老的问题之一(20 世纪 50 年代的第一批系统就能识别单个数字),也是商业部署最广泛的任务之一(语音助手、转录服务、字幕生成)。
|
||||
|
||||
- 难点在于语音的巨大变异性:不同的说话人、口音、语速、背景噪声、麦克风特性,以及将连续声学信号映射到离散单词这一根本性歧义问题。
|
||||
|
||||
- 可以把 ASR 想象成法庭速记员。速记员听到连续的声音流,在心理上将其分割成单词,利用上下文解决歧义(如"they're" vs "their" vs "there"),然后打出结果。ASR 系统做同样的事情,但分阶段进行,每个阶段可以独立或联合优化。
|
||||
|
||||
- **经典 ASR 流水线**通过一系列不同阶段处理音频:原始音频被转换为特征(MFCC 或对数梅尔频谱图,见文件 01),**声学模型**评估每个特征帧与每个语音单元的匹配程度,**发音模型**(词典)将语音单元映射为单词,**语言模型**评估词序列的合理程度,**解码器**搜索使联合得分最大化的词序列。每个组件分别训练和调优。
|
||||
|
||||

|
||||
|
||||
- **音素**是语言中区分单词的最小声音单位。英语大约有 39-44 个音素(具体数量取决于方言和所用音素库)。例如,"bat"和"pat"相差一个音素(/b/ vs /p/)。大多数 ASR 系统建模的是**上下文相关音素**,称为**三音素**:由其左邻和右邻共同定义的音素(例如,"b_t"上下文中的"a"与"c_t"上下文中的"a"是不同的单元),因为音素的声学实现受其邻接音素的强烈影响(这称为**协同发音**)。
|
||||
|
||||
- 可能的三音素数量巨大(40 个音素的三次方 = 64,000),因此**决策树聚类**将声学上相似的三音素分组为**声学状态**(通常为 2000-10,000 个类别)。每个声学状态拥有自己的声学模型。这种聚类是第 06 章中决策树算法的一种应用形式。
|
||||
|
||||
- **GMM-HMM**(高斯混合模型-隐马尔可夫模型)是从 20 世纪 80 年代到 21 世纪初主导的声学建模方法。HMM(见第 05 章)对语音的时间结构进行建模:每个音素是一个从左到右的 HMM,有 3-5 个状态,每个状态代表一个子音素段(起始、中间、结束)。状态间的转移隐式地建模时长。
|
||||
|
||||
- 在每个 HMM 状态,发射概率(给定状态下特定特征向量的可能性)由**高斯混合模型**(GMM)建模:多元高斯分布的加权和(见第 05 章):
|
||||
|
||||
```math
|
||||
p(\mathbf{x} | s) = \sum_{m=1}^{M} w_m \cdot \mathcal{N}(\mathbf{x} ; \boldsymbol{\mu}_m, \boldsymbol{\Sigma}_m)
|
||||
```
|
||||
|
||||
- 其中 $\mathbf{x}$ 是特征向量(例如 39 维 MFCC),$s$ 是 HMM 状态,$M$ 是混合分量数(通常为 8-64),$w_m$ 是混合权重,$\boldsymbol{\mu}_m$ 和 $\boldsymbol{\Sigma}_m$ 是每个高斯分量的均值和协方差。协方差矩阵通常使用对角形式以提高计算效率(假设特征维度独立,对于 MFCC 而言由于 DCT 去相关性,这一假设近似成立)。
|
||||
|
||||
- 训练使用 **Baum-Welch 算法**(EM 算法的特例,见第 05 章)从有标注的语音数据中迭代估计 GMM 参数和 HMM 转移概率。解码(寻找最可能的状态序列)使用 **Viterbi 算法**(动态规划,见第 05 章):
|
||||
|
||||
```math
|
||||
\delta_t(j) = \max_{i} \left[ \delta_{t-1}(i) \cdot a_{ij} \right] \cdot b_j(\mathbf{x}_t)
|
||||
```
|
||||
|
||||
- 其中 $\delta_t(j)$ 是在时间 $t$ 以状态 $j$ 结束的最佳路径的概率,$a_{ij}$ 是从状态 $i$ 到状态 $j$ 的转移概率,$b_j(\mathbf{x}_t)$ 是在状态 $j$ 下特征 $\mathbf{x}_t$ 的发射概率。
|
||||
|
||||
- **DNN-HMM**(Hinton 等人,2012)用深度神经网络(DNN,见第 06 章)取代了 GMM 发射模型,从特征帧窗口中预测声学状态后验概率 $p(s | \mathbf{x})$。HMM 仍然处理时间结构和序列化,但神经网络提供了更具判别力的发射分数。这种混合方法相对于 GMM 将词错误率降低了 20-30%,并在 2012-2016 年间占据主导地位。
|
||||
|
||||
- **WFST 解码**(加权有限状态换导器)是传统 ASR 的标准解码框架。每个组件(HMM 拓扑 H、上下文依赖 C、词典 L、语法/语言模型 G)都表示为加权有限状态换导器,它们被组合成单个搜索图 $H \circ C \circ L \circ G$。然后 Viterbi 搜索在此组合图中寻找最低成本路径。WFST 允许知识源的模块化组合和高效的动态规划搜索。其数学框架来自有限自动机理论(与第 05 章中的状态机相关)。
|
||||
|
||||
- **端到端 ASR** 消除了独立的组件(发音模型、音素库、WFST 解码器),训练一个直接将音频特征映射到字符或子词的单一神经网络。关键挑战是**对齐问题**:输入(每秒数百个特征帧)和输出(每秒几个字符)的长度相差很大,且训练时它们之间的对齐关系是未知的。
|
||||
|
||||
- **连接主义时序分类**(CTC)(Graves 等人,2006)通过引入一个特殊的**空白**标记解决了对齐问题,允许网络输出任意长度的字符和空白序列,只要通过合并连续重复和移除空白后能得到正确的转录文本。例如,转录文本"cat"可以由输出序列"--cc-aa-t--"产生(其中"-"是空白)。
|
||||
|
||||
- 形式上,CTC 定义了一个多对一映射 $\mathcal{B}$,从所有长度为 $T$ 的输出序列(使用字母表加上空白)到标签序列。标签序列 $\mathbf{y}$ 的概率是所有能约简到它的对齐路径的概率之和:
|
||||
|
||||
$$P(\mathbf{y} | \mathbf{x}) = \sum_{\boldsymbol{\pi} \in \mathcal{B}^{-1}(\mathbf{y})} \prod_{t=1}^{T} p(\pi_t | \mathbf{x})$$
|
||||
|
||||

|
||||
|
||||
- 直接计算此和需要枚举指数数量的对齐路径,但 **CTC 前向-后向算法**使用动态规划在 $O(T \cdot |\mathbf{y}|)$ 时间内高效计算,类似于第 05 章中的 HMM 前向-后向算法。
|
||||
|
||||
- CTC 做了一个**条件独立性假设**:给定输入,每个时间步的输出独立于所有其他输出。这意味着 CTC 无法建模输出之间的依赖关系(例如,它无法学习到"q"几乎总是后跟"u")。必须使用外部语言模型来处理此类依赖关系。
|
||||
|
||||
- **CTC 解码**选项:
|
||||
- **贪婪解码**:在每个时间步取最可能的标记,然后合并。速度快但效果次优。
|
||||
- **束搜索**:在每个步骤维护得分最高的 $k$ 个部分假设,合并能约简为相同前缀的假设。可以结合语言模型得分。
|
||||
- **前缀束搜索**:一种改进的束搜索,正确处理 CTC 空白合并,确保假设在合并后进行对比。
|
||||
|
||||
- **RNN-转导器**(RNN-T)(Graves,2012)通过添加一个显式的**预测网络**(类语言模型的 RNN)扩展了 CTC,使每个输出以之前的输出为条件,从而消除了条件独立性假设。RNN-T 有三个组件:
|
||||
- **编码器**:处理音频特征,生成隐藏表示 $\mathbf{h}_t^\text{enc}$(通常是 LSTM 或 Conformer 层的堆叠)。
|
||||
- **预测网络**:自回归 RNN,根据之前发射的标签生成隐藏表示 $\mathbf{h}_u^\text{pred}$。
|
||||
- **联合网络**:在每个(时间,标签)位置组合编码器和预测网络的输出,产生下一个标记(包括空白)的分布:
|
||||
|
||||
$$p(y | t, u) = \text{softmax}(W \cdot \text{tanh}(W_\text{enc} \mathbf{h}_t^\text{enc} + W_\text{pred} \mathbf{h}_u^\text{pred} + b))$$
|
||||
|
||||
- RNN-T 可以在每个时间步发射零个或多个标签(通过先发射非空白标记再前进到下一个时间步,或发射空白前进但不输出)。训练使用二维(时间,标签)网格上的前向-后向算法,复杂度为 $O(T \cdot U)$,其中 $U$ 是输出长度。RNN-T 是设备端流式 ASR 的主导架构(用于 Google Pixel 手机和类似产品),因为它天然支持流式处理:编码器从左到右处理音频,预测网络增量生成输出。
|
||||
|
||||
- **Listen, Attend and Spell**(LAS)(Chan 等人,2016)是一种基于注意力的编码器-解码器模型(序列到序列架构,见第 06 章)。它有三个组件:
|
||||
- **Listener**(编码器):金字塔形双向 LSTM,处理完整的输入序列并下采样 8 倍(通过在每层拼接连续隐藏状态对),生成较短的编码器隐藏状态序列。
|
||||
- **Attention**(注意力):在每个解码步骤中,计算所有编码器状态上的注意力权重,形成上下文向量(与第 07 章中相同的注意力机制)。
|
||||
- **Speller**(解码器):自回归 LSTM,在上下文向量和之前生成的字符的条件下逐字符生成输出转录文本。
|
||||
|
||||
- LAS 取得了很强的结果,但需要完整的语音片段才能开始解码(因为注意力需要关注所有编码器状态),因此不适合流式应用。此外,它在处理超长语音片段时表现不佳,因为长序列上的注意力会变得弥散。
|
||||
|
||||
- **Conformer**(Gulati 等人,2020)将卷积的局部模式捕捉能力与自注意力的全局依赖建模能力相结合。每个 Conformer 块以三明治结构包含四个模块:
|
||||
1. **前馈模块**(半步):带残差连接的前馈网络,使用一半的残差权重。
|
||||
2. **多头自注意力模块**:标准 Transformer 自注意力(来自第 07 章),使用相对位置编码。
|
||||
3. **卷积模块**:逐点卷积、门控线性单元(GLU)、一维深度可分离卷积、批归一化、Swish 激活函数和另一个逐点卷积。深度可分离卷积捕捉局部上下文(类似于特征序列上的 n-gram)。
|
||||
4. **前馈模块**(半步):与模块 1 相同。
|
||||
|
||||
- 输出为:$\mathbf{y} = \text{LayerNorm}(\mathbf{x} + \frac{1}{2}\text{FFN}_1 + \text{MHSA} + \text{Conv} + \frac{1}{2}\text{FFN}_2)$。实验证明这种马卡龙式结构(FFN-注意力-卷积-FFN)配合半步残差优于其他排序方式。Conformer 已成为 CTC 和 RNN-T 系统的默认编码器,性能优于纯 Transformer 和纯 LSTM 编码器。
|
||||
|
||||

|
||||
|
||||
- **Whisper**(Radford 等人,2023)是 OpenAI 的大规模基于注意力的 ASR 模型。它使用标准的编码器-解码器 Transformer 架构(来自第 07 章),在从互联网抓取的 68 万小时弱监督数据(音频与近似转录文本配对)上进行训练。关键设计选择:
|
||||
- 输入:80 通道对数梅尔频谱图(来自文件 01),使用 25 ms 窗口和 10 ms 步长,归一化为零均值和单位方差。
|
||||
- 编码器:标准 Transformer 编码器,使用正弦位置嵌入和预激活层归一化。
|
||||
- 解码器:Transformer 解码器,使用字节级 BPE 分词器(来自第 07 章)自回归生成标记。
|
||||
- 多任务:单个模型处理转录、翻译、语言识别和时间戳预测,通过解码器提示中的特殊任务标记进行条件控制。
|
||||
- 训练数据的规模(而非架构创新)是 Whisper 在跨领域、跨口音和跨语言上强泛化能力的主要驱动力。
|
||||
|
||||
- **wav2vec 2.0**(Baevski 等人,2020)是一种用于语音表示的**自监督**预训练框架。核心思想是从大量未标注的音频中学习语音表示,然后用少量标注数据进行微调。这遵循了与 BERT(来自第 07 章)相同的自监督范式,但针对连续音频信号进行了适配。
|
||||
|
||||
- wav2vec 2.0 架构包含三个部分:
|
||||
- **特征编码器**:多层一维 CNN,处理原始波形样本,以 20 ms 的帧率(在 16 kHz 下每 320 个样本一个向量)生成潜在表示 $\mathbf{z}_t$。
|
||||
- **量化模块**:使用**乘积量化**(将向量分成组,每组独立量化,从 $G$ 个码本中各选 $V$ 个条目)将潜在表示离散化为有限码本。这为对比学习目标产生目标 $\mathbf{q}_t$。
|
||||
- **上下文网络**:Transformer 编码器,接收(部分掩码的)潜在表示并生成上下文化的表示 $\mathbf{c}_t$。
|
||||
|
||||

|
||||
|
||||
- 在预训练期间,随机跨度内的潜在表示被**掩码**(替换为可学习的掩码嵌入),模型必须从一组干扰项(从同一语音片段的其他位置采样的负样本)中识别出掩码位置的真实量化表示。对比损失为:
|
||||
|
||||
$$\mathcal{L} = -\log \frac{\exp(\text{sim}(\mathbf{c}_t, \mathbf{q}_t) / \kappa)}{\sum_{\tilde{\mathbf{q}} \in Q_t} \exp(\text{sim}(\mathbf{c}_t, \tilde{\mathbf{q}}) / \kappa)}$$
|
||||
|
||||
- 其中 $\text{sim}$ 是余弦相似度,$\kappa$ 是温度参数,$Q_t$ 包括真实量化目标和干扰项。额外的**多样性损失**鼓励均衡使用所有码本条目。该损失本质上是 InfoNCE 对比损失,与视觉自监督学习中使用的对比目标函数属于同一族。
|
||||
|
||||
- 预训练后,在其上添加线性投影和 CTC 头部,然后在标注数据上进行微调。wav2vec 2.0 仅使用 10 分钟标注数据(使用 53,000 小时未标注音频进行预训练)即达到了接近最优的结果,展示了自监督学习在低资源语音识别中的强大能力。
|
||||
|
||||
- **HuBERT**(Hsu 等人,2021)是另一种自监督方法,用**掩码预测**目标(预测掩码帧的离散聚类分配)替代对比目标。目标由离线聚类步骤产生(第一次迭代使用 MFCC 的 k-means,后续迭代使用 HuBERT 特征的 k-means)。与 wav2vec 2.0 相比,HuBERT 简化了训练流程(无需量化模块或对比采样),且达到相当或更好的结果。
|
||||
|
||||
- **Fast Conformer**(Rekesh 等人,2023,NVIDIA NeMo)用**下采样注意力**机制替代标准 Conformer 中的二次自注意力:输入序列在计算注意力之前被压缩(通常通过步进卷积实现 8 倍压缩),然后再扩展回来。这将注意力成本从 $O(T^2)$ 降低到 $O(T^2/64)$,同时保留全局上下文,使训练超长语音片段(长达几分钟)不会出现内存问题。Fast Conformer 是 NVIDIA NeMo 工具包中的默认编码器,构成了其生产级模型的基础架构。
|
||||
|
||||
- **Parakeet**(NVIDIA,2024)是一系列基于 Fast Conformer 编码器的高精度英文 ASR 模型,配备 CTC 和 RNN-T 解码器,在 64,000 小时英语语音上训练。Parakeet 模型(0.6B 和 1.1B 参数)在发布时于标准基准上取得了最低的词错误率,在大多数英语测试集上超越了 Whisper large-v3。关键要素是高效的 Fast Conformer 架构、激进的数据增强(SpecAugment、速度扰动、噪声混合)和大规模监督训练数据——这表明对已知组件的精心工程化仍能推动技术前沿。
|
||||
|
||||
- **Canary**(NVIDIA,2024)将 NeMo 框架扩展到多语言和多任务 ASR。它使用 Fast Conformer 编码器配合基于注意力的解码器(而非 CTC 或 RNN-T),在单个模型中处理多种语言的转录和翻译(类似于 Whisper 的多任务设计,但使用更高效的 Fast Conformer 骨干网络)。Canary 模型支持英语、德语、西班牙语和法语,具有竞争性的准确率。
|
||||
|
||||
- **Moonshine**(Useful Sensors,2024)是一系列针对**设备端和边缘部署**专门优化的 ASR 模型。编码器使用混合架构,将初始的 Transformer/Conformer 层替换为小型 CNN 后接少量 Transformer 层,大幅缩小了模型体积(基础模型不到 3000 万参数)。Moonshine 面向 CPU 和低功耗设备上的实时流式处理,在这些场景下 Whisper 过大过慢,Moonshine 以少量精度换取 5-10 倍的更低延迟和内存占用。
|
||||
|
||||
- **Distil-Whisper**(Gandhi 等人,2023)应用**知识蒸馏**(第 06 章)将 Whisper 压缩为更小更快的模型。学生模型仅使用 2 个解码器层(相比之下 Whisper 有 32 层),同时保留完整的编码器,并训练以匹配 Whisper 的输出分布。Distil-Whisper 在 WER 上与教师模型差距在 1% 以内,同时速度快了 6 倍,使其在全尺寸 Whisper 模型过慢的实时应用中变得实用。
|
||||
|
||||
- **通用语音模型(USM)**(Zhang 等人,2023,Google)将自监督预训练扩展到 1200 万小时跨 300 多种语言的未标注音频,随后进行监督微调。USM 证明了 wav2vec 2.0 / 自监督范式可以扩展到真正大规模的数据范围,在标注数据非常有限的低资源语言上取得了强性能。
|
||||
|
||||
- **大规模多语言语音(MMS)**(Pratap 等人,2023,Meta)将 wav2vec 2.0 预训练扩展到超过 1,100 种语言,利用宗教录音和其他来源的多语言音频。MMS 覆盖的语言数量远超之前的任何 ASR 系统,首次为许多资源匮乏的语言提供了语音识别能力。
|
||||
|
||||
- 现代 ASR 的格局正趋于几个主导范式:(1)Conformer 族编码器配合 CTC 或 RNN-T 用于流式处理,(2)编码器-解码器 Transformer 用于离线/多任务,(3)自监督预训练用于低资源场景,(4)规模化——更多的数据和更大的模型持续提升准确率。这些选择取决于部署约束:延迟预算、可用算力、语言数量,以及应用是流式还是批处理。
|
||||
|
||||
- **语言模型集成**通过引入声学模型无法捕捉的语言知识来改进 ASR。基本思想是在解码时将声学模型得分 $p(\mathbf{x} | \mathbf{y})$(音频与转录文本的匹配程度)与语言模型得分 $p(\mathbf{y})$(转录文本作为句子的合理性)相结合。
|
||||
|
||||
- **浅融合**在束搜索时结合得分:
|
||||
|
||||
$$\hat{\mathbf{y}} = \arg\max_\mathbf{y} \left[ \log p_\text{AM}(\mathbf{y} | \mathbf{x}) + \lambda \log p_\text{LM}(\mathbf{y}) \right]$$
|
||||
|
||||
- 其中 $\lambda$ 是可调权重,$p_\text{LM}$ 是外部语言模型(通常是 n-gram 或神经语言模型,来自第 07 章)。这种方法简单有效,但要求 LM 使用与 ASR 模型相同的标记词汇表。
|
||||
|
||||
- **深度融合**(Gulcehre 等人,2015)将语言模型集成到解码器网络内部:LM 隐藏状态与解码器隐藏状态拼接,通过门控机制后进入输出投影层。整个系统(包括预训练的 LM)被联合微调。这种方法集成更深入,但训练更复杂。
|
||||
|
||||
- **冷融合**(Sriram 等人,2018)与深度融合类似,但 ASR 解码器从头开始与集成语言模型一起训练,而非微调预训练的解码器。这迫使声学模型学习互补信息,而非重复 LM 已经知道的内容。
|
||||
|
||||
- **重打分**(N-best 重打分)是一种两遍方法:首先使用束搜索生成 $N$ 个候选转录文本,然后使用更强大的语言模型(例如,大型 Transformer LM)对它们重新排序。这种方法实现简单,且允许使用对第一遍解码来说太慢的非常大的 LM。
|
||||
|
||||
- **内部语言模型估计**(ILME)解决了一个微妙的问题:端到端模型从训练转录文本中隐式学习了一个内部 LM,这在浅融合时可能与外部 LM 冲突(本质上是对语言先验进行了双重计数)。ILME 估计内部 LM 并在融合时减去其得分:
|
||||
|
||||
$$\hat{\mathbf{y}} = \arg\max_\mathbf{y} \left[ \log p_\text{E2E}(\mathbf{y} | \mathbf{x}) - \beta \log p_\text{ILM}(\mathbf{y}) + \lambda \log p_\text{LM}(\mathbf{y}) \right]$$
|
||||
|
||||
- **流式 vs. 离线 ASR** 是一个基本的架构选择。离线(或批处理)ASR 在处理完整个语音片段后才产生输出。流式 ASR 在音频到达时增量产生输出,具有有界延迟。
|
||||
|
||||
- 流式处理对实时应用至关重要:实时字幕、语音助手(用户在说完之前就期望得到响应)、电话通话转录。挑战在于某些未来上下文有助于识别(知道下一个词是"York"有助于消歧"New"),但流式系统不能无限等待未来的上下文。
|
||||
|
||||
- **单向编码器**(从左到右 LSTM、因果卷积、因果 Transformer)天然支持流式处理,因为每个输出仅依赖于过去和当前的输入。双向编码器(查看未来上下文)不能直接支持流式处理。
|
||||
|
||||
- **分块注意力**(也称为逐块或分段注意力)将输入划分为固定长度的块,仅在每个块内(以及可选的前面几个块)应用自注意力。这将延迟限制在块大小加上处理时间,同时在每个块内仍允许一定的局部双向上下文。其权衡是:块越小,准确率下降越多。
|
||||
|
||||
- **前瞻**允许流式编码器在当前帧产生输出之前,窥视少量的未来帧(例如 300-900 ms)。这是通过在单向计算中添加少量右上下文来实现的。前瞻窗口增加了延迟,但显著提升了准确率。
|
||||
|
||||
- **流式 ASR 中的延迟**包含几个组成部分:
|
||||
- **算法延迟**:从音频到达到模型能够处理它的延迟(由块大小、前瞻和特征提取决定)。
|
||||
- **计算延迟**:运行模型前向传播所需的时间。
|
||||
- **端点检测延迟**:检测用户说话完毕的延迟。
|
||||
- **首词延迟**:第一个词出现的速度。**最终确认延迟**:最终输出被确认的速度(流式系统通常产生暂定输出,随着更多音频到达而被修正)。
|
||||
|
||||
- **ASR 的评估指标**:
|
||||
|
||||
- **词错误率**(WER)是主要指标。通过将系统输出(假设)与参考文本(真实转录文本)进行对齐计算,使用编辑距离(将一个转换为另一个所需的最少替换、插入和删除次数),然后:
|
||||
|
||||
$$\text{WER} = \frac{S + D + I}{N}$$
|
||||
|
||||
- 其中 $S$ 是替换数,$D$ 是删除数,$I$ 是插入数,$N$ 是参考文本中的总词数。如果插入过多,WER 可能超过 100%。5% 的 WER 被认为大致相当于人类在清晰朗读语音上的水平;对话或噪声环境下的语音则困难得多(10-20%+)。
|
||||
|
||||
- **字符错误率**(CER)是相同的公式应用于字符级别而非词级别。CER 对于没有明确词边界的语言(如中文、日语)以及评估近似正确情况的接近程度("cat" vs "bat" 是 100% WER 但 33% CER)更有参考价值。
|
||||
|
||||
- **词信息损失**(WIL)和**词信息保留**(WIP)是信息论替代指标,比 WER 更精确地考虑了参考文本与假设之间的相关性,但使用较少。
|
||||
|
||||
- **实时因子**(RTF)衡量计算效率:处理时间与音频时长的比值。RTF < 1 表示系统运行速度快于实时;RTF > 1 表示系统无法跟上实时音频。流式系统必须保持 RTF < 1。
|
||||
|
||||
- **数据增强**对鲁棒 ASR 至关重要。常见技术:
|
||||
- **速度扰动**:以 0.9 倍和 1.1 倍速度对音频进行重采样(改变音高和时长)。
|
||||
- **SpecAugment**(Park 等人,2019):掩码频谱图中的随机频率带和时间步。这是音频领域的 dropout 类比,也是 ASR 中最有效的正则化技术之一。无需额外数据。
|
||||
- **噪声增强**:将干净语音与录制的噪声以各种信噪比混合。
|
||||
- **房间脉冲响应模拟**:将干净语音与模拟的房间声学进行卷积,以模拟混响环境。
|
||||
|
||||
- **ASR 的分词**决定了模型的输出词汇表。选项包括:
|
||||
- **字符**:简单,词汇量小(英语约 30 个),但输出序列长且无隐式语言建模。
|
||||
- **子词 / BPE**(来自第 07 章):在词汇表大小和序列长度之间取得平衡的子词单元。现代系统的标准(Whisper 使用字节级 BPE,约 50,000 个标记)。
|
||||
- **词**:词汇量大(50,000+),输出序列短,但无法处理词表外的词。
|
||||
- **音素**:语言上合理,紧凑,但需要发音词典。
|
||||
|
||||
- ASR 的演进可以概括为:从高度工程化的模块化系统(GMM-HMM + WFST 解码,1990 年代-2010 年代)到混合系统(DNN-HMM,2012-2016),再到将流水线越来越多地吸收到单一神经网络中的端到端系统(CTC、RNN-T、LAS,2016-2020),最后到利用海量未标注或弱标注数据的大型预训练模型(wav2vec 2.0、Whisper,2020 至今)。每一次转变都在提升准确率的同时简化了工程复杂度,遵循了机器学习中从手工设计特征到从数据中学习表示的更广泛趋势(第 06 章中 CNN 替代图像特征、第 07 章中 Transformer 替代 NLP 特征也是如此)。
|
||||
|
||||
## 编程任务(使用 CoLab 或 notebook)
|
||||
|
||||
1. 在 JAX 中从头实现 CTC 损失。创建一个包含短序列 logits 和目标标签的玩具示例,计算 CTC 前向算法得到总概率,并计算负对数似然损失。
|
||||
```python
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
def ctc_forward(log_probs, targets):
|
||||
"""
|
||||
CTC 前向算法(对数域,数值稳定性)。
|
||||
log_probs: (T, V) 词汇表上的对数概率(索引 0 = 空白)
|
||||
targets: (U,) 目标标签索引(不含空白)
|
||||
返回:目标序列在 CTC 下的对数概率。
|
||||
"""
|
||||
T, V = log_probs.shape
|
||||
U = len(targets)
|
||||
|
||||
# 构建带有空白的扩展标签序列:[blank, y1, blank, y2, ..., yU, blank]
|
||||
S = 2 * U + 1
|
||||
labels = jnp.zeros(S, dtype=jnp.int32) # 全部为空白
|
||||
for i in range(U):
|
||||
labels = labels.at[2 * i + 1].set(targets[i])
|
||||
|
||||
# 初始化 alpha(对数域)
|
||||
NEG_INF = -1e30
|
||||
alpha = jnp.full((T, S), NEG_INF)
|
||||
alpha = alpha.at[0, 0].set(log_probs[0, labels[0]]) # 以空白开始
|
||||
alpha = alpha.at[0, 1].set(log_probs[0, labels[1]]) # 或第一个标签
|
||||
|
||||
# 前向填充
|
||||
for t in range(1, T):
|
||||
for s in range(S):
|
||||
# 同一状态
|
||||
a = alpha[t - 1, s]
|
||||
# 从前一状态来
|
||||
if s > 0:
|
||||
a = jnp.logaddexp(a, alpha[t - 1, s - 1])
|
||||
# 跳过空白(如果当前标签与两步前的标签不同)
|
||||
if s > 1 and labels[s] != 0 and labels[s] != labels[s - 2]:
|
||||
a = jnp.logaddexp(a, alpha[t - 1, s - 2])
|
||||
alpha = alpha.at[t, s].set(a + log_probs[t, labels[s]])
|
||||
|
||||
# 总对数概率:最后时间步的最后两个状态之和
|
||||
log_prob = jnp.logaddexp(alpha[T - 1, S - 1], alpha[T - 1, S - 2])
|
||||
return log_prob, alpha
|
||||
|
||||
# --- 玩具示例 ---
|
||||
T = 12 # 输入长度(时间步)
|
||||
V = 5 # 词汇表大小(0=空白,1='c',2='a',3='t',4='x')
|
||||
targets = jnp.array([1, 2, 3]) # "c", "a", "t"
|
||||
|
||||
# 创建随机 logits 并转换为对数概率
|
||||
key = jax.random.PRNGKey(42)
|
||||
logits = jax.random.normal(key, (T, V))
|
||||
log_probs = jax.nn.log_softmax(logits, axis=-1)
|
||||
|
||||
log_prob, alpha = ctc_forward(log_probs, targets)
|
||||
ctc_loss = -log_prob
|
||||
|
||||
print(f"目标序列: {targets.tolist()} ('c', 'a', 't')")
|
||||
print(f"输入长度 T={T}, 词汇表大小 V={V}")
|
||||
print(f"CTC 对数概率: {log_prob:.4f}")
|
||||
print(f"CTC 损失(负对数概率): {ctc_loss:.4f}")
|
||||
|
||||
# 可视化前向变量(alpha)网格
|
||||
fig, ax = plt.subplots(figsize=(12, 5))
|
||||
# 将对数转换为线性以便可视化
|
||||
alpha_linear = jnp.exp(alpha - jnp.max(alpha)) # 归一化以便观察
|
||||
im = ax.imshow(alpha_linear.T, aspect='auto', origin='lower', cmap='viridis')
|
||||
ax.set_xlabel('时间步 (t)')
|
||||
ax.set_ylabel('扩展标签索引 (s)')
|
||||
|
||||
label_names = ['_', 'c', '_', 'a', '_', 't', '_'] # _ = 空白
|
||||
ax.set_yticks(range(len(label_names)))
|
||||
ax.set_yticklabels(label_names)
|
||||
ax.set_title(f'CTC 前向变量(alpha 网格)| 损失 = {ctc_loss:.2f}')
|
||||
plt.colorbar(im, ax=ax, label='归一化概率')
|
||||
plt.tight_layout(); plt.show()
|
||||
```
|
||||
|
||||
2. 在 JAX 中构建一个简单的编码器-解码器基于注意力的 ASR 模型(最小化的 LAS 类架构)。使用一维卷积编码器和带有点积注意力的单层解码器。在合成数据上运行并可视化注意力权重。
|
||||
```python
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# --- 最小化的基于注意力的编码器-解码器 ASR 模型 ---
|
||||
|
||||
def init_params(key, input_dim, hidden_dim, vocab_size):
|
||||
"""初始化小型 LAS 类模型的参数。"""
|
||||
keys = jax.random.split(key, 8)
|
||||
scale = 0.1
|
||||
params = {
|
||||
# 编码器:简单的线性投影(模拟卷积输出)
|
||||
'enc_w': jax.random.normal(keys[0], (input_dim, hidden_dim)) * scale,
|
||||
'enc_b': jnp.zeros(hidden_dim),
|
||||
# 注意力:查询、键、值投影
|
||||
'attn_q': jax.random.normal(keys[1], (hidden_dim, hidden_dim)) * scale,
|
||||
'attn_k': jax.random.normal(keys[2], (hidden_dim, hidden_dim)) * scale,
|
||||
'attn_v': jax.random.normal(keys[3], (hidden_dim, hidden_dim)) * scale,
|
||||
# 解码器 RNN(为演示使用简单 Elman RNN)
|
||||
'dec_wh': jax.random.normal(keys[4], (hidden_dim, hidden_dim)) * scale,
|
||||
'dec_wx': jax.random.normal(keys[5], (vocab_size, hidden_dim)) * scale,
|
||||
'dec_wc': jax.random.normal(keys[6], (hidden_dim, hidden_dim)) * scale,
|
||||
'dec_b': jnp.zeros(hidden_dim),
|
||||
# 输出投影
|
||||
'out_w': jax.random.normal(keys[7], (hidden_dim, vocab_size)) * scale,
|
||||
'out_b': jnp.zeros(vocab_size),
|
||||
}
|
||||
return params
|
||||
|
||||
def encode(params, x):
|
||||
"""编码器:线性投影(占位符,代表卷积/LSTM 堆叠)。"""
|
||||
return jnp.tanh(x @ params['enc_w'] + params['enc_b'])
|
||||
|
||||
def attend(params, query, enc_out):
|
||||
"""在编码器输出上的点积注意力。"""
|
||||
q = query @ params['attn_q'] # (hidden,)
|
||||
k = enc_out @ params['attn_k'] # (T_enc, hidden)
|
||||
v = enc_out @ params['attn_v'] # (T_enc, hidden)
|
||||
d_k = q.shape[-1]
|
||||
scores = (k @ q) / jnp.sqrt(d_k) # (T_enc,)
|
||||
weights = jax.nn.softmax(scores) # (T_enc,)
|
||||
context = weights @ v # (hidden,)
|
||||
return context, weights
|
||||
|
||||
def decode_step(params, h_prev, y_prev_onehot, enc_out):
|
||||
"""单步解码:RNN + 注意力。"""
|
||||
# 嵌入前一个标记
|
||||
y_emb = y_prev_onehot @ params['dec_wx'] # (hidden,)
|
||||
# 注意力到编码器
|
||||
context, attn_w = attend(params, h_prev, enc_out)
|
||||
# RNN 更新
|
||||
h = jnp.tanh(h_prev @ params['dec_wh'] + y_emb + context @ params['dec_wc']
|
||||
+ params['dec_b'])
|
||||
# 输出 logits
|
||||
logits = h @ params['out_w'] + params['out_b']
|
||||
return h, logits, attn_w
|
||||
|
||||
# --- 设置 ---
|
||||
key = jax.random.PRNGKey(0)
|
||||
input_dim = 40 # 例如 40 个梅尔频带
|
||||
hidden_dim = 64
|
||||
vocab_size = 10 # 用于演示的小词汇表
|
||||
T_enc = 30 # 编码器时间步
|
||||
T_dec = 8 # 解码器步数
|
||||
|
||||
params = init_params(key, input_dim, hidden_dim, vocab_size)
|
||||
|
||||
# 合成输入:随机梅尔类特征
|
||||
key, subkey = jax.random.split(key)
|
||||
x = jax.random.normal(subkey, (T_enc, input_dim))
|
||||
|
||||
# 编码
|
||||
enc_out = encode(params, x)
|
||||
|
||||
# 解码(使用随机目标的教师强制)
|
||||
key, subkey = jax.random.split(key)
|
||||
targets = jax.random.randint(subkey, (T_dec,), 0, vocab_size)
|
||||
|
||||
h = jnp.zeros(hidden_dim)
|
||||
all_logits = []
|
||||
all_attn = []
|
||||
|
||||
for t in range(T_dec):
|
||||
y_prev = jax.nn.one_hot(targets[t] if t > 0 else 0, vocab_size)
|
||||
h, logits, attn_w = decode_step(params, h, y_prev, enc_out)
|
||||
all_logits.append(logits)
|
||||
all_attn.append(attn_w)
|
||||
|
||||
all_attn = jnp.stack(all_attn) # (T_dec, T_enc)
|
||||
all_logits = jnp.stack(all_logits) # (T_dec, vocab_size)
|
||||
|
||||
# --- 可视化注意力权重 ---
|
||||
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
|
||||
|
||||
im = axes[0].imshow(all_attn, aspect='auto', cmap='Blues', origin='lower')
|
||||
axes[0].set_xlabel('编码器时间步')
|
||||
axes[0].set_ylabel('解码器步')
|
||||
axes[0].set_title('注意力权重(解码器 -> 编码器)')
|
||||
plt.colorbar(im, ax=axes[0])
|
||||
|
||||
# 显示每个解码步的预测标记分布
|
||||
im2 = axes[1].imshow(jax.nn.softmax(all_logits, axis=-1), aspect='auto',
|
||||
cmap='Oranges', origin='lower')
|
||||
axes[1].set_xlabel('词汇表索引')
|
||||
axes[1].set_ylabel('解码器步')
|
||||
axes[1].set_title('输出标记概率')
|
||||
plt.colorbar(im2, ax=axes[1])
|
||||
|
||||
plt.suptitle('最小化的基于注意力的 ASR 模型(未训练)')
|
||||
plt.tight_layout(); plt.show()
|
||||
```
|
||||
|
||||
3. 使用动态规划(编辑距离)从头计算词错误率(WER),并针对一个参考文本评估多个假设。可视化编辑距离矩阵。
|
||||
```python
|
||||
import jax.numpy as jnp
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
def compute_wer(reference, hypothesis):
|
||||
"""
|
||||
使用动态规划(词级别的 Levenshtein 距离)计算 WER。
|
||||
返回 WER、替换数、删除数、插入数和 DP 矩阵。
|
||||
"""
|
||||
ref_words = reference.split()
|
||||
hyp_words = hypothesis.split()
|
||||
N = len(ref_words)
|
||||
M = len(hyp_words)
|
||||
|
||||
# DP 矩阵:d[i][j] = ref[:i] 和 hyp[:j] 之间的编辑距离
|
||||
d = np.zeros((N + 1, M + 1), dtype=np.int32)
|
||||
# 回溯矩阵用于统计 S, D, I
|
||||
ops = np.zeros((N + 1, M + 1, 3), dtype=np.int32) # [sub, del, ins]
|
||||
|
||||
for i in range(N + 1):
|
||||
d[i][0] = i # 全部删除
|
||||
for j in range(M + 1):
|
||||
d[0][j] = j # 全部插入
|
||||
|
||||
for i in range(1, N + 1):
|
||||
for j in range(1, M + 1):
|
||||
if ref_words[i - 1] == hyp_words[j - 1]:
|
||||
sub_cost = d[i - 1][j - 1] # 匹配,无需编辑
|
||||
else:
|
||||
sub_cost = d[i - 1][j - 1] + 1 # 替换
|
||||
del_cost = d[i - 1][j] + 1 # 删除
|
||||
ins_cost = d[i][j - 1] + 1 # 插入
|
||||
|
||||
d[i][j] = min(sub_cost, del_cost, ins_cost)
|
||||
|
||||
# 回溯统计操作次数
|
||||
i, j = N, M
|
||||
S, D, I = 0, 0, 0
|
||||
while i > 0 or j > 0:
|
||||
if i > 0 and j > 0 and d[i][j] == d[i-1][j-1] and ref_words[i-1] == hyp_words[j-1]:
|
||||
i -= 1; j -= 1 # 正确
|
||||
elif i > 0 and j > 0 and d[i][j] == d[i-1][j-1] + 1:
|
||||
S += 1; i -= 1; j -= 1 # 替换
|
||||
elif i > 0 and d[i][j] == d[i-1][j] + 1:
|
||||
D += 1; i -= 1 # 删除
|
||||
elif j > 0 and d[i][j] == d[i][j-1] + 1:
|
||||
I += 1; j -= 1 # 插入
|
||||
else:
|
||||
break
|
||||
|
||||
wer = (S + D + I) / N if N > 0 else 0.0
|
||||
return wer, S, D, I, d
|
||||
|
||||
# --- 测试用例 ---
|
||||
reference = "the cat sat on the mat"
|
||||
hypotheses = [
|
||||
"the cat sat on the mat", # 完美
|
||||
"the cat sit on the mat", # 1 次替换
|
||||
"the cat on the mat", # 1 次删除
|
||||
"the big cat sat on the mat", # 1 次插入
|
||||
"a dog sat in a rug", # 多处错误
|
||||
]
|
||||
|
||||
print(f"参考文本: '{reference}'\n")
|
||||
print(f"{'假设':<40s} {'WER':>6s} {'S':>3s} {'D':>3s} {'I':>3s}")
|
||||
print("-" * 60)
|
||||
results = []
|
||||
for hyp in hypotheses:
|
||||
wer, S, D, I, dp = compute_wer(reference, hyp)
|
||||
results.append((hyp, wer, S, D, I, dp))
|
||||
print(f"'{hyp}':<40s} {wer:>6.1%} {S:>3d} {D:>3d} {I:>3d}")
|
||||
|
||||
# 可视化最差情况的 DP 矩阵
|
||||
worst = results[-1]
|
||||
hyp_words = worst[0].split()
|
||||
ref_words = reference.split()
|
||||
dp_matrix = worst[5]
|
||||
|
||||
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
|
||||
|
||||
# DP 矩阵
|
||||
im = axes[0].imshow(dp_matrix, cmap='YlOrRd', origin='upper')
|
||||
axes[0].set_xticks(range(len(hyp_words) + 1))
|
||||
axes[0].set_xticklabels([''] + hyp_words, rotation=45, ha='right', fontsize=9)
|
||||
axes[0].set_yticks(range(len(ref_words) + 1))
|
||||
axes[0].set_yticklabels([''] + ref_words, fontsize=9)
|
||||
axes[0].set_xlabel('假设词')
|
||||
axes[0].set_ylabel('参考词')
|
||||
axes[0].set_title(f'编辑距离矩阵\nWER = {worst[1]:.1%}')
|
||||
for i in range(dp_matrix.shape[0]):
|
||||
for j in range(dp_matrix.shape[1]):
|
||||
axes[0].text(j, i, str(dp_matrix[i, j]), ha='center', va='center', fontsize=8)
|
||||
plt.colorbar(im, ax=axes[0])
|
||||
|
||||
# WER 比较柱状图
|
||||
names = [f'Hyp {i+1}' for i in range(len(results))]
|
||||
wers = [r[1] * 100 for r in results]
|
||||
colors = ['#27ae60' if w == 0 else '#f39c12' if w < 30 else '#e74c3c' for w in wers]
|
||||
axes[1].barh(names, wers, color=colors)
|
||||
axes[1].set_xlabel('WER (%)')
|
||||
axes[1].set_title('词错误率比较')
|
||||
for i, (w, r) in enumerate(zip(wers, results)):
|
||||
axes[1].text(w + 1, i, f'{w:.0f}% (S={r[2]}, D={r[3]}, I={r[4]})',
|
||||
va='center', fontsize=9)
|
||||
axes[1].set_xlim(0, max(wers) * 1.4)
|
||||
|
||||
plt.tight_layout(); plt.show()
|
||||
```
|
||||
|
||||
4. 在对数梅尔频谱图上实现 SpecAugment(频率掩码和时间掩码),并可视化原始版本与增强版本。从合成信号生成频谱图。
|
||||
```python
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# --- 生成合成对数梅尔频谱图 ---
|
||||
key = jax.random.PRNGKey(42)
|
||||
fs = 16000
|
||||
duration = 2.0
|
||||
t = jnp.arange(0, duration, 1.0 / fs)
|
||||
|
||||
# 模拟语音:带谐波的啁啾信号
|
||||
f0 = 120.0
|
||||
x = sum(jnp.sin(2 * jnp.pi * f0 * k * t * (1 + 0.1 * t)) / k for k in range(1, 10))
|
||||
key, subkey = jax.random.split(key)
|
||||
x = x + 0.05 * jax.random.normal(subkey, t.shape)
|
||||
|
||||
# 计算对数梅尔频谱图(简化版)
|
||||
frame_len = 400 # 25 ms
|
||||
hop_len = 160 # 10 ms
|
||||
n_fft = 512
|
||||
n_mels = 80
|
||||
|
||||
n_frames = (len(x) - frame_len) // hop_len + 1
|
||||
hamming = 0.54 - 0.46 * jnp.cos(2 * jnp.pi * jnp.arange(frame_len) / (frame_len - 1))
|
||||
|
||||
frames = jnp.stack([x[i * hop_len : i * hop_len + frame_len] for i in range(n_frames)])
|
||||
windowed = frames * hamming
|
||||
spectra = jnp.abs(jnp.fft.rfft(windowed, n=n_fft)) ** 2
|
||||
|
||||
# 简单的梅尔滤波器组
|
||||
def hz_to_mel(f): return 2595 * jnp.log10(1 + f / 700)
|
||||
def mel_to_hz(m): return 700 * (10 ** (m / 2595) - 1)
|
||||
|
||||
mel_points = jnp.linspace(hz_to_mel(0), hz_to_mel(fs / 2), n_mels + 2)
|
||||
hz_pts = mel_to_hz(mel_points)
|
||||
bins = jnp.floor((n_fft + 1) * hz_pts / fs).astype(jnp.int32)
|
||||
|
||||
n_freqs = n_fft // 2 + 1
|
||||
fb = jnp.zeros((n_mels, n_freqs))
|
||||
for m in range(n_mels):
|
||||
lo, mid, hi = int(bins[m]), int(bins[m+1]), int(bins[m+2])
|
||||
for k in range(lo, mid):
|
||||
if mid != lo:
|
||||
fb = fb.at[m, k].set((k - lo) / (mid - lo))
|
||||
for k in range(mid, hi):
|
||||
if hi != mid:
|
||||
fb = fb.at[m, k].set((hi - k) / (hi - mid))
|
||||
|
||||
log_mel = jnp.log(spectra @ fb.T + 1e-10)
|
||||
|
||||
# --- SpecAugment ---
|
||||
def spec_augment(spec, key, n_freq_masks=2, freq_mask_width=15,
|
||||
n_time_masks=2, time_mask_width=25):
|
||||
"""应用 SpecAugment:频率掩码和时间掩码。"""
|
||||
augmented = spec.copy()
|
||||
T, F = spec.shape
|
||||
|
||||
# 频率掩码
|
||||
for _ in range(n_freq_masks):
|
||||
key, k1, k2 = jax.random.split(key, 3)
|
||||
f_width = jax.random.randint(k1, (), 1, freq_mask_width + 1)
|
||||
f_start = jax.random.randint(k2, (), 0, max(1, F - freq_mask_width))
|
||||
mask = (jnp.arange(F) >= f_start) & (jnp.arange(F) < f_start + f_width)
|
||||
augmented = jnp.where(mask[None, :], 0.0, augmented)
|
||||
|
||||
# 时间掩码
|
||||
for _ in range(n_time_masks):
|
||||
key, k1, k2 = jax.random.split(key, 3)
|
||||
t_width = jax.random.randint(k1, (), 1, time_mask_width + 1)
|
||||
t_start = jax.random.randint(k2, (), 0, max(1, T - time_mask_width))
|
||||
mask = (jnp.arange(T) >= t_start) & (jnp.arange(T) < t_start + t_width)
|
||||
augmented = jnp.where(mask[:, None], 0.0, augmented)
|
||||
|
||||
return augmented
|
||||
|
||||
key, subkey = jax.random.split(key)
|
||||
log_mel_aug = spec_augment(log_mel, subkey)
|
||||
|
||||
# --- 可视化 ---
|
||||
fig, axes = plt.subplots(2, 1, figsize=(14, 8))
|
||||
|
||||
im0 = axes[0].imshow(log_mel.T, aspect='auto', origin='lower', cmap='inferno',
|
||||
extent=[0, duration, 0, n_mels])
|
||||
axes[0].set_title('原始对数梅尔频谱图')
|
||||
axes[0].set_xlabel('时间 (s)'); axes[0].set_ylabel('梅尔频带')
|
||||
plt.colorbar(im0, ax=axes[0], label='对数能量')
|
||||
|
||||
im1 = axes[1].imshow(log_mel_aug.T, aspect='auto', origin='lower', cmap='inferno',
|
||||
extent=[0, duration, 0, n_mels])
|
||||
axes[1].set_title('SpecAugment 后(频率 + 时间掩码)')
|
||||
axes[1].set_xlabel('时间 (s)'); axes[1].set_ylabel('梅尔频带')
|
||||
plt.colorbar(im1, ax=axes[1], label='对数能量')
|
||||
|
||||
plt.tight_layout(); plt.show()
|
||||
```
|
||||
@@ -0,0 +1,711 @@
|
||||
# 语音合成与声音
|
||||
|
||||
*语音合成(Text-to-Speech Synthesis)逆向执行 ASR 流水线,从书面文本生成自然听感的音频。本文涵盖 TTS 流水线(文本规范化、G2P、声学模型、声码器)、Tacotron、WaveNet、HiFi-GAN、声音克隆、声音转换以及语音活动检测(VAD)。*
|
||||
|
||||
- 在文件 01 中,我们构建了信号处理工具包:波形、语谱图、梅尔滤波器组和 MFCC。在文件 02 中,我们将语音转换为文本。现在我们反方向操作:给定文本,合成自然听感的语音。这就是**语音合成(TTS)**,一个同样通向声音转换、声音克隆和语音活动检测的问题。
|
||||
|
||||
- 将 TTS 想象成一场舞台表演。剧本就是文本输入。导演(声学模型)决定每句台词应该如何发音——音高、时长、重音。管弦乐队(声码器)随后演奏乐谱,产生听众实际听到的声波。现代神经 TTS 用媲美人类说话者的演绎,取代了基于规则系统那种僵硬、机械的发音。
|
||||
|
||||

|
||||
|
||||
- **语音合成流水线** 标准 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 使用两种鉴别器类型。**多周期鉴别器(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,带**位置敏感注意力**,该注意力机制不仅基于编码器输出和解码器状态,还基于先前步骤累积的注意力权重来条件化。这防止了注意力跳过或重复词语的常见失败模式。
|
||||
|
||||

|
||||
|
||||
- 解码器步骤 $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 Labs,2025)是一个开放源码 TTS 模型系列(1B 和 3B 参数),构建在 VALL-E 开创的**编解码器语言模型**范式之上。Orpheus 更进一步,使用 LLM 骨干网络(微调的 Llama 3)直接生成 SNAC 音频编解码器令牌。其突出特点是类似人类的情感表达能力:它能够以卓越的自然度处理笑声、叹息、犹豫和情感韵律。Orpheus 可以通过在输入文本中使用 `[laugh]` 或 `[sigh]` 等标签进行提示,从而对副语言表达进行细粒度控制。
|
||||
|
||||
- **Dia**(Nari Labs,2025)是一个开放源码对话 TTS 模型,从单个文本转录生成逼真的多说话人对话。Dia 构建在 1.6B 参数的编码器-解码器 Transformer 之上,处理对话中的话轮转换、说话人特定声音和非语言线索(笑声、停顿)。它还支持从简短音频提示进行声音克隆,从而在对话上下文中实现零样本说话人生成。
|
||||
|
||||
- **Sesame CSM**(会话语音模型,2025)专注于自然的多人轮换会话语音。Sesame 不是为了优化朗读式 TTS,而是对真实对话的动态进行建模:反馈词("嗯哼")、打断、说话人之间的节奏变化和情感响应。该模型使用以对话上下文(文本和音频历史)为条件的 Transformer 骨干网络,生成的语音风格能适应对话的流程。
|
||||
|
||||
- **Fish Speech**(Fish Audio,2024)是一个开放源码 TTS 系统,使用双自回归架构:一个大语言模型从文本生成语义令牌,一个较小模型将这些转换为 VQGAN 声学令牌,再由声码器解码为波形。Fish Speech 支持从 10-15 秒参考音频进行零样本声音克隆,并实现适合实时应用的低延迟。其模块化设计允许独立替换组件(例如,不同的声码器)。
|
||||
|
||||
- **ChatTTS**(2024)是一个开放源码会话 TTS 模型,专为聊天机器人和虚拟助手等对话应用设计。它通过在文本输入中嵌入特殊令牌,生成自然、会话风格的语音,并对韵律特征(笑声、停顿、填充词)进行细粒度控制。ChatTTS 支持中英混合合成和多说话人生成。
|
||||
|
||||
- **Bark**(Suno,2023)是一个基于 Transformer 的开放源码模型,从文本提示生成语音、音乐和音效。它使用三个阶段的 Transformer 模型流水线(文本 → 语义令牌 → 粗声学令牌 → 细声学令牌),并支持声音克隆、多语言合成以及音乐和环境音等非语音音频。Bark 的通用性以可控性为代价——它不如专用 TTS 系统精确,但更灵活。
|
||||
|
||||
- **Parler-TTS**(Hugging Face,2024)采用**自然语言描述**方式进行声音控制:用户无需提供参考音频片段来控制风格,而是提供文本描述,例如"一位女性说话者,声音温暖、富有表现力,在安静的房间中。"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 需要将说话人身份与内容解耦。
|
||||
|
||||

|
||||
|
||||
- **说话人嵌入**(在文件 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 Team,2021)是生产环境中的事实标准神经 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()
|
||||
```
|
||||
@@ -0,0 +1,643 @@
|
||||
# 说话人与音频分析
|
||||
|
||||
*说话人与音频分析识别谁在说话、何时说话以及存在哪些非语言声音。本文涵盖说话人确认与识别、i向量、d向量、x向量、说话人日志、音频事件分类、音乐信息检索以及语音情感识别。*
|
||||
|
||||
- 在文件 01 中,我们构建了信号处理基础:语谱图、MFCC 和梅尔滤波器组。在文件 02 中,我们识别了所说的内容。现在我们要问:是谁说的、何时说的、以及音频中还在发生什么。说话人识别、说话人日志、音频分类和音乐分析都共享一条主线:学习能够为当前任务捕捉正确不变性的紧凑嵌入,这与第 06 章中的嵌入思想一脉相承。
|
||||
|
||||
- 可以把说话人识别想象成在电话中辨认朋友的声音。你不需要理解词汇;某种关于音色、语速和嗓音特质的东西对这个人来说是独一无二的。说话人识别系统学会从原始音频中提取这种"声纹",忽略说的是什么,专注于怎么说的。
|
||||
|
||||
- **说话人识别**是两类相关任务的总称:
|
||||
- **说话人确认**(SV):给定一个声明的身份和一段音频片段,判断说话人是否与其声称的身份一致。这是一个二元决策(接受或拒绝),是基于语音的身份验证技术("嘿 Siri,这是我的声音吗?")背后的核心原理。
|
||||
- **说话人识别**(SI):给定一段音频片段和一个已知说话人库,判断该片段由哪个说话人产生。这是一个多分类问题。
|
||||
|
||||

|
||||
|
||||
- 两种任务共享相同的底层表示:一个固定维度的**说话人嵌入**,它捕捉说话人的身份特征而与所说内容无关。区别仅在于决策阶段:确认比较两个嵌入,识别则在候选嵌入中找到最近邻。
|
||||
|
||||
- **余弦相似度**是比较说话人嵌入的标准度量。给定注册嵌入 $e$ 和测试嵌入 $t$:
|
||||
|
||||
$$s = \frac{e \cdot t}{\|e\| \, \|t\|}$$
|
||||
|
||||
- 阈值 $\theta$ 决定接受/拒绝决策:若 $s > \theta$,则接受。阈值在**错误接受率(FAR)**和**错误拒绝率(FRR)**之间权衡。**等错误率(EER)**,即 FAR = FRR 时的值,是标准评估指标。EER 越低表示性能越好。最先进的系统在标准基准(VoxCeleb)上可实现低于 1% 的 EER。
|
||||
|
||||
- **i向量**(Dehak 等人,2010)是深度学习之前主导性的说话人嵌入方法。其思想源于因子分析(第 02 章的矩阵分解和第 04 章的降维)。一个**通用背景模型(UBM)**——基于多样本说话人训练的大型 GMM——定义了一个超向量空间。每条语音的 GMM 超向量被投影到低维的**全可变性空间**:
|
||||
|
||||
$$M = m + Tw$$
|
||||
|
||||
- 其中 $M$ 是该语音的 GMM 超向量,$m$ 是 UBM 均值超向量,$T$ 是全可变性矩阵(从数据中学习得到),$w$ 是 i 向量,一个低维(通常为 400-600 维)表示,同时捕捉说话人变异和信道变异。
|
||||
|
||||
- 为了从 i 向量中去除信道变异,**概率线性判别分析(PLDA)**将 i 向量建模为说话人特定潜变量和信道特定潜变量之和。PLDA 为确认任务提供了一个有原则的对数似然比分数:
|
||||
|
||||
$$\text{score}(w_1, w_2) = \log \frac{P(w_1, w_2 \mid \text{同一说话人})}{P(w_1 \mid \text{说话人}_1) \, P(w_2 \mid \text{说话人}_2)}$$
|
||||
|
||||
- **d向量**(Variani 等人,2014)是第一个神经说话人嵌入。一个为说话人分类训练的 DNN 处理帧级特征,通过对整条语音中最后一层隐藏层激活值求平均,提取出固定维度的表示。虽然简单但有效,d向量证明了神经网络可以在没有 i 向量复杂统计机制的情况下学习到说话人判别性特征。
|
||||
|
||||
- **x向量**(Snyder 等人,2018)使用**时延神经网络(TDNN)**架构显著推进了神经说话人嵌入。TDNN 是具有特定上下文窗口的 1D 卷积,与文件 03 中 WaveNet 的扩张卷积有关,但应用于帧级特征而非原始波形样本。
|
||||
|
||||

|
||||
|
||||
- x向量架构包含三个阶段:
|
||||
- **帧级层**:一组 TDNN 层处理 MFCC(来自文件 01),时间上下文逐步扩大。每一层都有一个固定的上下文窗口(例如第一层为 $\{t-2, t-1, t, t+1, t+2\}$,后续层窗口更宽)。
|
||||
- **统计池化**:在帧级层之后,计算帧级输出在整个语音上的均值和标准差,产生一个与语音时长无关的固定维度向量:
|
||||
|
||||
```math
|
||||
\begin{aligned}
|
||||
\mu &= \frac{1}{T} \sum_{t=1}^{T} h_t \\
|
||||
\sigma &= \sqrt{\frac{1}{T} \sum_{t=1}^{T} (h_t - \mu)^2}
|
||||
\end{aligned}
|
||||
```
|
||||
|
||||
- 其中 $h_t$ 是时间 $t$ 的帧级输出。拼接 $[\mu; \sigma]$ 即为池化后的表示。
|
||||
- **段级层**:全连接层处理池化后的表示。第一个段级层的输出(softmax 之前)即为 x 向量嵌入。
|
||||
|
||||
- x向量使用说话人身份上的标准交叉熵损失进行训练。尽管是为分类任务训练的,但学习到的中间表示(x向量)能很好地泛化到未见过的说话人,因为网络学习的是提取说话人判别性特征,而非记忆特定说话人。
|
||||
|
||||
- **ECAPA-TDNN**(Desplanques 等人,2020)是目前最先进的基于 TDNN 的说话人识别架构。它在 x 向量基础上引入了三项改进:
|
||||
- **压缩激励(SE)模块**:通道注意力(来自第 08 章的 SENet),根据全局上下文重新加权特征通道,使模型能够强调与说话人相关的通道。
|
||||
- **Res2Net 风格的多尺度特征**:在每个 TDNN 模块内,通道被分成若干组,以层级方式处理,在多个时间分辨率上创建特征(类似于第 08 章的多尺度特征提取)。
|
||||
- **注意力统计池化**:不再使用等权平均,而是通过注意力机制为每一帧对池化统计量的贡献分配权重。包含更多说话人判别性内容的帧(如元音,承载更多说话人信息)获得更高的注意力权重:
|
||||
|
||||
$$\alpha_t = \frac{\exp(v^T f(h_t))}{\sum_{\tau} \exp(v^T f(h_\tau))}$$
|
||||
|
||||
- 其中 $f$ 是一个小型神经网络,$v$ 是一个学习到的注意力向量。注意力加权的均值和标准差变为 $\tilde{\mu} = \sum_t \alpha_t h_t$ 和 $\tilde{\sigma} = \sqrt{\sum_t \alpha_t (h_t - \tilde{\mu})^2}$。
|
||||
|
||||
- ECAPA-TDNN 通常使用 **AAM-Softmax**(附加角度间隔 Softmax)进行训练,它在分类损失中添加了角度间隔惩罚,将同一说话人的嵌入推得更近,不同说话人的嵌入在超球面上推得更远:
|
||||
|
||||
$$L = -\log \frac{e^{s \cos(\theta_{y_i} + m)}}{e^{s \cos(\theta_{y_i} + m)} + \sum_{j \neq y_i} e^{s \cos \theta_j}}$$
|
||||
|
||||
- 其中 $\theta_{y_i}$ 是嵌入与真实类别权重向量之间的夹角,$m$ 是间隔(通常为 0.2),$s$ 是缩放因子(通常为 30)。该损失函数来自人脸识别(第 08 章的 ArcFace),在说话人确认中非常有效。
|
||||
|
||||
- **说话人日志**回答了多方录音中"谁在什么时候说话"的问题。可以把这想象成给时间线上色:每种颜色代表一个不同的说话人,系统必须确定每个说话人何时活跃,包括重叠语音的情况。
|
||||
|
||||

|
||||
|
||||
- **基于聚类的说话人日志**是传统的流水线方法:
|
||||
- **分割**:将音频划分为短段(通常为 1-2 秒),使用滑动窗口或说话人变化检测。
|
||||
- **嵌入提取**:为每个片段提取说话人嵌入(x向量、ECAPA-TDNN)。
|
||||
- **聚类**:按说话人对片段进行分组。**凝聚层次聚类(AHC)**是标准方法:开始时每个片段自成一类,然后迭代合并两个最相似的类,直到满足停止条件(基于距离阈值或目标说话人数)。
|
||||
- **重分割**:使用基于维特比算法的重对齐来优化边界。
|
||||
|
||||
- 说话人数量通常事先未知,这使得该问题比标准聚类更困难。使用基于特征值阈值确定 $k$ 的谱聚类是另一种常见方法。
|
||||
|
||||
- **端到端神经说话人日志(EEND)**(Fujita 等人,2019)将说话人日志框架化为一个多标签分类问题。一个神经网络(通常是基于自注意力的模型,第 07 章的 transformer)将整段录音作为输入,为每一帧输出每个说话人的二元活动标签。这直接处理了重叠语音,而这是基于聚类方法的主要弱点。
|
||||
|
||||
- EEND 对 $S$ 个说话人在帧 $t$ 的输出为:
|
||||
|
||||
$$\hat{y}_{t,s} = \sigma(f_s(h_t))$$
|
||||
|
||||
- 其中 $h_t$ 是帧 $t$ 处的 transformer 输出,$f_s$ 是说话人 $s$ 的线性投影。训练损失是在说话人和帧上求和得到的二元交叉熵。一个关键挑战是说话人数量必须固定,或者使用可变输出架构(EEND-EDA 使用带吸引子的编码器-解码器)来处理。
|
||||
|
||||
- **置换不变训练(PIT)**用于处理说话人日志中的标签歧义问题:由于说话人没有固有顺序,需要对所有可能的说话人到输出分配计算损失,并取最小值(这与文件 05 中源分离使用的 PIT 相同)。
|
||||
|
||||
- **音频分类**为整段音频片段分配一个标签。与转录语音的 ASR(文件 02)不同,音频分类涵盖更广的范围:环境声音(警笛、雨声、狗吠)、音乐流派(摇滚、爵士、古典)以及一般音频事件。
|
||||
|
||||
- 标准方法遵循第 08 章的图像分类范式:将音频表示为语谱图(一个二维时间-频率图像),然后应用 CNN 或 transformer 分类器。这种谱图-图像方法利用了计算机视觉几十年来的进展。
|
||||
|
||||
- **环境声音分类(ESC)**使用 ESC-50(50 类,2000 个片段)和 UrbanSound8K 等数据集。典型架构是应用于对数梅尔语谱图的 CNN(第 06 章)。数据增强至关重要:时间拉伸、音高偏移、添加背景噪声以及 **SpecAugment**(文件 02 的掩码方法应用于语谱图)都能提升泛化能力。
|
||||
|
||||
- **音频事件检测**(声音事件检测,SED)是分类的时间维度对应任务:不仅仅要知道存在哪些事件,还要知道它们何时开始和结束。**AudioSet**(Gemmeke 等人,2017)是大规模基准,包含 527 个事件类别和超过 200 万个来自 YouTube 的 10 秒片段,每个片段都有弱标注(片段级标签,而非帧级)。
|
||||
|
||||
- **弱监督 SED** 必须从片段级标签学习帧级预测。标准方法使用 CNN 产生帧级类别概率,然后通过注意力池化聚合成片段级预测:
|
||||
|
||||
$$\hat{Y}_c = \sigma\left(\sum_t \alpha_{t,c} \cdot f_{t,c}\right)$$
|
||||
|
||||
- 其中 $f_{t,c}$ 是类别 $c$ 在时间 $t$ 的帧级 logit,$\alpha_{t,c}$ 是注意力权重。片段级预测 $\hat{Y}_c$ 根据片段级标签进行训练。
|
||||
|
||||
- **声学场景分类(ASC)**对整体环境进行分类:"机场"、"公园"、"地铁站"、"办公室"。这是一个整体性任务:模型必须捕捉一般的声学纹理而非特定事件。DCASE 挑战系列每年对 ASC 进行基准测试,获奖系统通常使用多分辨率语谱图上的 CNN 集成。
|
||||
|
||||
- **音频嵌入**是从大规模音频数据中学习到的通用表示,类似于可迁移到下游任务的词嵌入(第 07 章)或图像特征(第 08 章)。
|
||||
|
||||
- **VGGish**(Hershey 等人,2017)将 VGG 图像分类网络(第 08 章)适配到音频领域。它通过一个在 AudioSet 上预训练的类 VGG CNN 处理 0.96 秒的对数梅尔语谱图块,每块产生一个 128 维嵌入。VGGish 嵌入可作为下游任务的通用音频特征,类似于 ImageNet 预训练 CNN 提供视觉特征的方式。
|
||||
|
||||
- **PANNs**(预训练音频神经网络,Kong 等人,2020)是一系列 CNN 架构(CNN6、CNN10、CNN14),在完整的 AudioSet 上为音频标记任务训练。CNN14 使用最广泛,是一个 14 层 CNN,将对数梅尔语谱图作为输入,使用 $3 \times 3$ 卷积。PANNs 产生 2048 维嵌入,在多种音频任务上实现了最先进的迁移学习性能。
|
||||
|
||||
- **音频语谱图 Transformer(AST)**(Gong 等人,2021)将视觉 Transformer(ViT,第 08 章)架构直接应用于音频语谱图。语谱图被分割成 $16 \times 16$ 的块(就像 ViT 分割图像一样),每个块被线性投影为令牌嵌入,添加位置嵌入,然后由标准 Transformer 编码器(第 07 章)处理序列。[CLS] 令牌的输出用于分类。
|
||||
|
||||

|
||||
|
||||
- AST 受益于 **ImageNet 预训练**:由于语谱图是 2D 图像,AST 从 ImageNet 图像上预训练的 ViT 初始化,然后在音频上微调。这种跨模态迁移出奇地有效,因为两个域共享低级特征(边缘、纹理),并且位置嵌入可以插值以处理不同大小的语谱图。
|
||||
|
||||
- **HTS-AT**(Chen 等人,2022)使用分层 Swin Transformer 架构(第 08 章的移位窗口注意力)改进了 AST,在降低计算成本的同时通过多尺度特征提取提升了性能。
|
||||
|
||||
- **BEATs**(Chen 等人,2023)使用了一种音频特定的预训练策略:使用离散标记器进行迭代掩码预测(类似于文件 02 中 wav2vec 2.0 的方法,但应用于通用音频)。标记器逐步细化,创建越来越具有语义意义的离散音频令牌。
|
||||
|
||||
- **基于嵌入的说话人日志**结合了说话人嵌入与时序建模。像 Pyannote.audio 这样的现代系统使用三阶段流水线:(1) 检测说话人切换和重叠语音的神经分割模型,(2) 应用于每个检测到的片段的嵌入提取阶段(ECAPA-TDNN),以及 (3) 聚类以在整个录音中分配说话人身份。
|
||||
|
||||
- **音乐信息检索(MIR)**将音频分析应用于音乐。文件 01 中的谱图表示在这里尤其有用,因为音乐具有丰富的和声结构。
|
||||
|
||||
- **节拍跟踪**检测音乐的节奏脉冲。标准方法从语谱图计算**起始强度包络**(检测表示音符起始的能量增加),然后使用自相关或节拍图谱找到节奏,最后使用动态规划跟踪单个节拍位置,找到最能匹配起始包络同时保持稳定节奏的节拍时间序列。
|
||||
|
||||
- **和弦识别**识别随时间变化的和声内容。输入通常是**色度图**(也称为音高类别分布图):一个 12 维表示,将所有八度折叠在一起,显示 12 个音高类别(C、C#、D、…、B)中每个类别的能量。CNN 或 RNN(第 06 章)将每个时间帧分类到标准和弦标签之一(C 大调、A 小调、G7 等)。
|
||||
|
||||
- 色度图通过将每个频率区间映射到其音高类别,从 STFT(文件 01)计算得到:
|
||||
|
||||
$$\text{chroma}(p) = \sum_{k : \text{pitch}(k) \bmod 12 = p} |X(k)|^2$$
|
||||
|
||||
- 其中 $p \in \{0, 1, \ldots, 11\}$ 是音高类别,$\text{pitch}(k)$ 将频率区间 $k$ 映射到其 MIDI 音符编号。
|
||||
|
||||
- **源分离基础**(详见文件 05)将音乐录音分离为单独的乐器(人声、鼓、贝斯、其他)。这是混音、卡拉 OK 和音乐转录等 MIR 应用的核心。像 Demucs(文件 05)这样的模型在标准 MUSDB18 基准上达到了非常好的分离质量。
|
||||
|
||||
- **音乐标记**为歌曲分配标签(流派、情感、乐器、时代)。它本质上是应用于音乐的音频分类,使用相同的 CNN-语谱图方法。Million Song Dataset 和 MagnaTagATune 是标准基准。
|
||||
|
||||
- **音频指纹**从短片段中识别特定录音,即使存在噪声、混响或压缩伪影。经典系统是 Shazam,它对星座图(语谱图中的显著峰值)进行哈希处理。神经方法学习对声学退化具有不变性、同时对不同录音保持判别性的鲁棒嵌入,这与第 06 章和第 08 章中的不变特征学习一脉相承。
|
||||
|
||||
## 编程任务(使用 Colab 或笔记本)
|
||||
|
||||
- **任务 1:带统计池化的说话人嵌入提取。** 构建一个简单的 x向量风格模型,通过 TDNN 层和统计池化处理帧级特征以产生说话人嵌入。
|
||||
|
||||
```python
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import jax.random as jr
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# Simulate frame-level MFCC features for multiple speakers
|
||||
def generate_speaker_data(key, n_speakers=5, utterances_per_speaker=20,
|
||||
n_frames=100, n_features=40):
|
||||
"""Generate synthetic speaker data with speaker-dependent patterns."""
|
||||
keys = jr.split(key, 3)
|
||||
all_features = []
|
||||
all_labels = []
|
||||
|
||||
# Each speaker has a characteristic spectral pattern
|
||||
speaker_patterns = jr.normal(keys[0], (n_speakers, n_features)) * 0.5
|
||||
|
||||
for spk in range(n_speakers):
|
||||
for utt in range(utterances_per_speaker):
|
||||
k = jr.fold_in(keys[1], spk * utterances_per_speaker + utt)
|
||||
noise = jr.normal(k, (n_frames, n_features)) * 0.3
|
||||
features = speaker_patterns[spk][None, :] + noise
|
||||
all_features.append(features)
|
||||
all_labels.append(spk)
|
||||
|
||||
perm = jr.permutation(keys[2], len(all_features))
|
||||
features = jnp.stack(all_features)[perm]
|
||||
labels = jnp.array(all_labels)[perm]
|
||||
return features, labels
|
||||
|
||||
key = jr.PRNGKey(42)
|
||||
features, labels = generate_speaker_data(key)
|
||||
n_speakers = 5
|
||||
n_features = 40
|
||||
|
||||
# x-vector-style model
|
||||
def init_xvector(key, n_features=40, hidden=128, embed_dim=64, n_speakers=5):
|
||||
keys = jr.split(key, 8)
|
||||
params = {
|
||||
# TDNN layer 1: context [-2, 2]
|
||||
'tdnn1_w': jr.normal(keys[0], (5, n_features, hidden)) * jnp.sqrt(2.0 / (5 * n_features)),
|
||||
'tdnn1_b': jnp.zeros(hidden),
|
||||
# TDNN layer 2: context [-2, 2]
|
||||
'tdnn2_w': jr.normal(keys[1], (5, hidden, hidden)) * jnp.sqrt(2.0 / (5 * hidden)),
|
||||
'tdnn2_b': jnp.zeros(hidden),
|
||||
# TDNN layer 3: context [-3, 3]
|
||||
'tdnn3_w': jr.normal(keys[2], (7, hidden, hidden)) * jnp.sqrt(2.0 / (7 * hidden)),
|
||||
'tdnn3_b': jnp.zeros(hidden),
|
||||
# Segment-level layers (after pooling: 2*hidden -> embed_dim)
|
||||
'seg1_w': jr.normal(keys[3], (2 * hidden, embed_dim)) * jnp.sqrt(2.0 / (2 * hidden)),
|
||||
'seg1_b': jnp.zeros(embed_dim),
|
||||
# Classification head
|
||||
'cls_w': jr.normal(keys[4], (embed_dim, n_speakers)) * jnp.sqrt(2.0 / embed_dim),
|
||||
'cls_b': jnp.zeros(n_speakers),
|
||||
}
|
||||
return params
|
||||
|
||||
def xvector_forward(params, x, return_embedding=False):
|
||||
"""x: (batch, frames, features) -> logits or embeddings."""
|
||||
# TDNN layers (1D convolutions)
|
||||
h = jax.lax.conv_general_dilated(
|
||||
x.transpose(0, 2, 1), params['tdnn1_w'].transpose(2, 1, 0),
|
||||
window_strides=(1,), padding='SAME'
|
||||
).transpose(0, 2, 1) + params['tdnn1_b']
|
||||
h = jax.nn.relu(h)
|
||||
|
||||
h = jax.lax.conv_general_dilated(
|
||||
h.transpose(0, 2, 1), params['tdnn2_w'].transpose(2, 1, 0),
|
||||
window_strides=(1,), padding='SAME'
|
||||
).transpose(0, 2, 1) + params['tdnn2_b']
|
||||
h = jax.nn.relu(h)
|
||||
|
||||
h = jax.lax.conv_general_dilated(
|
||||
h.transpose(0, 2, 1), params['tdnn3_w'].transpose(2, 1, 0),
|
||||
window_strides=(1,), padding='SAME'
|
||||
).transpose(0, 2, 1) + params['tdnn3_b']
|
||||
h = jax.nn.relu(h)
|
||||
|
||||
# Statistics pooling: mean and std over time
|
||||
mu = jnp.mean(h, axis=1)
|
||||
sigma = jnp.std(h, axis=1)
|
||||
pooled = jnp.concatenate([mu, sigma], axis=-1)
|
||||
|
||||
# Segment-level layer -> embedding
|
||||
embedding = jax.nn.relu(pooled @ params['seg1_w'] + params['seg1_b'])
|
||||
|
||||
if return_embedding:
|
||||
return embedding
|
||||
|
||||
# Classification
|
||||
logits = embedding @ params['cls_w'] + params['cls_b']
|
||||
return logits
|
||||
|
||||
def cross_entropy_loss(params, features, labels):
|
||||
logits = xvector_forward(params, features)
|
||||
one_hot = jax.nn.one_hot(labels, n_speakers)
|
||||
log_probs = jax.nn.log_softmax(logits)
|
||||
return -jnp.mean(jnp.sum(one_hot * log_probs, axis=-1))
|
||||
|
||||
grad_fn = jax.jit(jax.value_and_grad(cross_entropy_loss))
|
||||
|
||||
# Train
|
||||
params = init_xvector(jr.PRNGKey(0))
|
||||
lr = 1e-3
|
||||
losses = []
|
||||
|
||||
for epoch in range(300):
|
||||
loss_val, grads = grad_fn(params, features, labels)
|
||||
params = jax.tree.map(lambda p, g: p - lr * g, params, grads)
|
||||
losses.append(float(loss_val))
|
||||
|
||||
# Extract embeddings and visualise with t-SNE-style 2D projection (using PCA)
|
||||
embeddings = xvector_forward(params, features, return_embedding=True)
|
||||
|
||||
# Simple PCA to 2D
|
||||
emb_centered = embeddings - jnp.mean(embeddings, axis=0)
|
||||
_, _, Vt = jnp.linalg.svd(emb_centered, full_matrices=False)
|
||||
proj_2d = emb_centered @ Vt[:2].T
|
||||
|
||||
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
|
||||
|
||||
axes[0].plot(losses, color='#3498db', linewidth=1.5)
|
||||
axes[0].set_xlabel('Epoch')
|
||||
axes[0].set_ylabel('Cross-Entropy Loss')
|
||||
axes[0].set_title('Speaker Classification Training')
|
||||
axes[0].set_yscale('log')
|
||||
|
||||
colors = ['#3498db', '#e74c3c', '#27ae60', '#f39c12', '#9b59b6']
|
||||
for spk in range(n_speakers):
|
||||
mask = labels == spk
|
||||
axes[1].scatter(proj_2d[mask, 0], proj_2d[mask, 1], c=colors[spk],
|
||||
label=f'Speaker {spk}', alpha=0.7, s=30)
|
||||
axes[1].set_xlabel('PC 1')
|
||||
axes[1].set_ylabel('PC 2')
|
||||
axes[1].set_title('Speaker Embeddings (PCA projection)')
|
||||
axes[1].legend()
|
||||
|
||||
plt.tight_layout()
|
||||
plt.show()
|
||||
|
||||
# Verification demo: cosine similarity
|
||||
emb_norm = embeddings / jnp.linalg.norm(embeddings, axis=-1, keepdims=True)
|
||||
sim_matrix = emb_norm @ emb_norm.T
|
||||
print(f"Embedding shape: {embeddings.shape}")
|
||||
print(f"Avg same-speaker similarity: {jnp.mean(sim_matrix[labels[:, None] == labels[None, :]]):.4f}")
|
||||
print(f"Avg diff-speaker similarity: {jnp.mean(sim_matrix[labels[:, None] != labels[None, :]]):.4f}")
|
||||
```
|
||||
|
||||
- **任务 2:基于余弦相似度评分的说话人确认。** 给定预计算的说话人嵌入,实现一个计算 EER(等错误率)并绘制 DET 曲线的确认系统。
|
||||
|
||||
```python
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import jax.random as jr
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
def generate_verification_pairs(key, n_speakers=20, dim=64, n_pairs=2000):
|
||||
"""Generate speaker embeddings and verification trial pairs."""
|
||||
keys = jr.split(key, 5)
|
||||
|
||||
# Speaker centroids with some variance
|
||||
centroids = jr.normal(keys[0], (n_speakers, dim))
|
||||
centroids = centroids / jnp.linalg.norm(centroids, axis=-1, keepdims=True)
|
||||
|
||||
# Generate enrollment and test embeddings with intra-speaker variance
|
||||
enroll_embs = []
|
||||
test_embs = []
|
||||
trial_labels = [] # 1 = same speaker (target), 0 = different (impostor)
|
||||
|
||||
for i in range(n_pairs):
|
||||
k1, k2, k3 = jr.split(jr.fold_in(keys[1], i), 3)
|
||||
is_target = jr.bernoulli(k1).astype(int)
|
||||
|
||||
spk1 = jr.randint(k2, (), 0, n_speakers)
|
||||
emb1 = centroids[spk1] + jr.normal(jr.fold_in(k3, 0), (dim,)) * 0.15
|
||||
|
||||
if is_target:
|
||||
spk2 = spk1
|
||||
else:
|
||||
spk2 = (spk1 + jr.randint(jr.fold_in(k3, 1), (), 1, n_speakers)) % n_speakers
|
||||
|
||||
emb2 = centroids[spk2] + jr.normal(jr.fold_in(k3, 2), (dim,)) * 0.15
|
||||
|
||||
enroll_embs.append(emb1)
|
||||
test_embs.append(emb2)
|
||||
trial_labels.append(int(is_target))
|
||||
|
||||
return (jnp.stack(enroll_embs), jnp.stack(test_embs),
|
||||
jnp.array(trial_labels))
|
||||
|
||||
key = jr.PRNGKey(42)
|
||||
enroll, test, labels = generate_verification_pairs(key)
|
||||
|
||||
# Compute cosine similarity scores
|
||||
enroll_norm = enroll / jnp.linalg.norm(enroll, axis=-1, keepdims=True)
|
||||
test_norm = test / jnp.linalg.norm(test, axis=-1, keepdims=True)
|
||||
scores = jnp.sum(enroll_norm * test_norm, axis=-1)
|
||||
|
||||
# Compute FAR and FRR at various thresholds
|
||||
thresholds = jnp.linspace(-1.0, 1.0, 500)
|
||||
|
||||
target_scores = scores[labels == 1]
|
||||
impostor_scores = scores[labels == 0]
|
||||
|
||||
fars = []
|
||||
frrs = []
|
||||
for thresh in thresholds:
|
||||
far = jnp.mean(impostor_scores >= thresh) # false accepts
|
||||
frr = jnp.mean(target_scores < thresh) # false rejects
|
||||
fars.append(float(far))
|
||||
frrs.append(float(frr))
|
||||
|
||||
fars = jnp.array(fars)
|
||||
frrs = jnp.array(frrs)
|
||||
|
||||
# Find EER: where FAR ≈ FRR
|
||||
eer_idx = jnp.argmin(jnp.abs(fars - frrs))
|
||||
eer = float((fars[eer_idx] + frrs[eer_idx]) / 2)
|
||||
eer_threshold = float(thresholds[eer_idx])
|
||||
|
||||
print(f"Equal Error Rate (EER): {eer:.4f} ({eer*100:.2f}%)")
|
||||
print(f"EER threshold: {eer_threshold:.4f}")
|
||||
|
||||
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
|
||||
|
||||
# Score distributions
|
||||
bins = jnp.linspace(-0.5, 1.0, 60)
|
||||
axes[0].hist(target_scores, bins=bins, alpha=0.6, color='#27ae60',
|
||||
label='Target (same speaker)', density=True)
|
||||
axes[0].hist(impostor_scores, bins=bins, alpha=0.6, color='#e74c3c',
|
||||
label='Impostor (different speaker)', density=True)
|
||||
axes[0].axvline(eer_threshold, color='#f39c12', linestyle='--', linewidth=2,
|
||||
label=f'EER threshold = {eer_threshold:.3f}')
|
||||
axes[0].set_xlabel('Cosine Similarity Score')
|
||||
axes[0].set_ylabel('Density')
|
||||
axes[0].set_title('Score Distributions')
|
||||
axes[0].legend()
|
||||
|
||||
# FAR vs FRR
|
||||
axes[1].plot(thresholds, fars, color='#e74c3c', linewidth=2, label='FAR')
|
||||
axes[1].plot(thresholds, frrs, color='#3498db', linewidth=2, label='FRR')
|
||||
axes[1].axvline(eer_threshold, color='#f39c12', linestyle='--', linewidth=1.5)
|
||||
axes[1].scatter([eer_threshold], [eer], color='#f39c12', s=100, zorder=5,
|
||||
label=f'EER = {eer:.4f}')
|
||||
axes[1].set_xlabel('Threshold')
|
||||
axes[1].set_ylabel('Error Rate')
|
||||
axes[1].set_title('FAR and FRR vs Threshold')
|
||||
axes[1].legend()
|
||||
|
||||
# DET curve (FAR vs FRR)
|
||||
axes[2].plot(fars, frrs, color='#9b59b6', linewidth=2)
|
||||
axes[2].plot([0, 1], [0, 1], 'k--', alpha=0.3)
|
||||
axes[2].scatter([eer], [eer], color='#f39c12', s=100, zorder=5,
|
||||
label=f'EER = {eer:.4f}')
|
||||
axes[2].set_xlabel('False Acceptance Rate')
|
||||
axes[2].set_ylabel('False Rejection Rate')
|
||||
axes[2].set_title('DET Curve')
|
||||
axes[2].set_xlim([0, 0.5])
|
||||
axes[2].set_ylim([0, 0.5])
|
||||
axes[2].legend()
|
||||
axes[2].set_aspect('equal')
|
||||
|
||||
plt.tight_layout()
|
||||
plt.show()
|
||||
```
|
||||
|
||||
- **任务 3:音频语谱图块嵌入(AST 风格)。** 实现音频语谱图 Transformer 的块提取和嵌入层,可视化语谱图如何被令牌化。
|
||||
|
||||
```python
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import jax.random as jr
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# Generate a synthetic spectrogram (harmonic structure + noise)
|
||||
def generate_spectrogram(key, n_time=128, n_freq=128):
|
||||
"""Create a synthetic spectrogram with harmonic patterns."""
|
||||
k1, k2 = jr.split(key)
|
||||
spec = jr.normal(k1, (n_time, n_freq)) * 0.1
|
||||
|
||||
# Add harmonic bands (simulating speech formants)
|
||||
for f0 in [15, 30, 45, 70]:
|
||||
width = 3
|
||||
envelope = jnp.exp(-0.5 * ((jnp.arange(n_freq) - f0) / width) ** 2)
|
||||
time_mod = 0.5 + 0.5 * jnp.sin(2 * jnp.pi * jnp.arange(n_time) / 40)
|
||||
spec += jnp.outer(time_mod, envelope)
|
||||
|
||||
return jnp.clip(spec, 0, None)
|
||||
|
||||
key = jr.PRNGKey(42)
|
||||
spectrogram = generate_spectrogram(key)
|
||||
n_time, n_freq = spectrogram.shape
|
||||
|
||||
# Patch extraction parameters
|
||||
patch_h = 16 # time
|
||||
patch_w = 16 # frequency
|
||||
stride_h = 16
|
||||
stride_w = 16
|
||||
embed_dim = 192 # ViT-Small dimension
|
||||
|
||||
n_patches_h = n_time // stride_h
|
||||
n_patches_w = n_freq // stride_w
|
||||
n_patches = n_patches_h * n_patches_w
|
||||
|
||||
print(f"Spectrogram: {n_time} x {n_freq}")
|
||||
print(f"Patch size: {patch_h} x {patch_w}")
|
||||
print(f"Number of patches: {n_patches_h} x {n_patches_w} = {n_patches}")
|
||||
|
||||
# Extract patches
|
||||
def extract_patches(spec, patch_h, patch_w, stride_h, stride_w):
|
||||
"""Extract non-overlapping patches from spectrogram."""
|
||||
patches = []
|
||||
positions = []
|
||||
for i in range(0, spec.shape[0] - patch_h + 1, stride_h):
|
||||
for j in range(0, spec.shape[1] - patch_w + 1, stride_w):
|
||||
patch = spec[i:i+patch_h, j:j+patch_w]
|
||||
patches.append(patch.flatten())
|
||||
positions.append((i, j))
|
||||
return jnp.stack(patches), positions
|
||||
|
||||
patches, positions = extract_patches(spectrogram, patch_h, patch_w, stride_h, stride_w)
|
||||
print(f"Patches shape: {patches.shape}") # (n_patches, patch_h * patch_w)
|
||||
|
||||
# Linear projection (patch embedding)
|
||||
patch_dim = patch_h * patch_w
|
||||
k1, k2 = jr.split(jr.PRNGKey(0))
|
||||
W_embed = jr.normal(k1, (patch_dim, embed_dim)) * jnp.sqrt(2.0 / patch_dim)
|
||||
b_embed = jnp.zeros(embed_dim)
|
||||
|
||||
# Learnable positional embeddings
|
||||
pos_embed = jr.normal(k2, (n_patches + 1, embed_dim)) * 0.02 # +1 for CLS
|
||||
|
||||
# CLS token
|
||||
cls_token = jnp.zeros((1, embed_dim))
|
||||
|
||||
# Forward pass
|
||||
patch_tokens = patches @ W_embed + b_embed # (n_patches, embed_dim)
|
||||
tokens = jnp.concatenate([cls_token, patch_tokens], axis=0) # (n_patches+1, embed_dim)
|
||||
tokens = tokens + pos_embed # Add positional embeddings
|
||||
|
||||
print(f"Token sequence shape: {tokens.shape}")
|
||||
print(f"Each token has dimension: {embed_dim}")
|
||||
|
||||
# Visualisation
|
||||
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
|
||||
|
||||
# Original spectrogram with patch grid
|
||||
axes[0, 0].imshow(spectrogram.T, aspect='auto', origin='lower', cmap='magma')
|
||||
for i in range(0, n_time + 1, stride_h):
|
||||
axes[0, 0].axvline(i - 0.5, color='white', linewidth=0.5, alpha=0.5)
|
||||
for j in range(0, n_freq + 1, stride_w):
|
||||
axes[0, 0].axhline(j - 0.5, color='white', linewidth=0.5, alpha=0.5)
|
||||
axes[0, 0].set_title(f'Spectrogram with {patch_h}x{patch_w} Patch Grid')
|
||||
axes[0, 0].set_xlabel('Time frame')
|
||||
axes[0, 0].set_ylabel('Frequency bin')
|
||||
|
||||
# Individual patches visualised
|
||||
n_show = min(16, n_patches)
|
||||
patch_grid = patches[:n_show].reshape(n_show, patch_h, patch_w)
|
||||
combined = jnp.concatenate([patch_grid[i] for i in range(min(8, n_show))], axis=1)
|
||||
axes[0, 1].imshow(combined.T, aspect='auto', origin='lower', cmap='magma')
|
||||
axes[0, 1].set_title(f'First {min(8, n_show)} Patches (concatenated)')
|
||||
axes[0, 1].set_xlabel('Patch index (horizontal)')
|
||||
axes[0, 1].set_ylabel('Frequency within patch')
|
||||
|
||||
# Token embeddings similarity matrix
|
||||
token_norms = tokens / jnp.linalg.norm(tokens, axis=-1, keepdims=True)
|
||||
sim = token_norms @ token_norms.T
|
||||
im = axes[1, 0].imshow(sim, cmap='RdBu_r', vmin=-1, vmax=1)
|
||||
axes[1, 0].set_title('Token Similarity Matrix (cosine)')
|
||||
axes[1, 0].set_xlabel('Token index')
|
||||
axes[1, 0].set_ylabel('Token index')
|
||||
plt.colorbar(im, ax=axes[1, 0], fraction=0.046)
|
||||
|
||||
# Positional embedding similarity
|
||||
pos_norms = pos_embed / jnp.linalg.norm(pos_embed, axis=-1, keepdims=True)
|
||||
pos_sim = pos_norms @ pos_norms.T
|
||||
im2 = axes[1, 1].imshow(pos_sim, cmap='RdBu_r', vmin=-1, vmax=1)
|
||||
axes[1, 1].set_title('Positional Embedding Similarity')
|
||||
axes[1, 1].set_xlabel('Position index')
|
||||
axes[1, 1].set_ylabel('Position index')
|
||||
plt.colorbar(im2, ax=axes[1, 1], fraction=0.046)
|
||||
|
||||
plt.tight_layout()
|
||||
plt.show()
|
||||
```
|
||||
|
||||
- **任务 4:用于和弦分析的简单色度图计算。** 从合成和声信号计算并可视化色度图,展示音乐信息检索中使用的音高类别折叠方法。
|
||||
|
||||
```python
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# Generate a synthetic musical signal: C major chord -> G major chord
|
||||
sr = 16000
|
||||
duration = 2.0
|
||||
t = jnp.linspace(0, duration, int(sr * duration))
|
||||
|
||||
# C major (C4=261.6, E4=329.6, G4=392.0) for first half
|
||||
# G major (G3=196.0, B3=246.9, D4=293.7) for second half
|
||||
half = len(t) // 2
|
||||
|
||||
c_major = (0.5 * jnp.sin(2 * jnp.pi * 261.63 * t[:half]) +
|
||||
0.4 * jnp.sin(2 * jnp.pi * 329.63 * t[:half]) +
|
||||
0.3 * jnp.sin(2 * jnp.pi * 392.00 * t[:half]))
|
||||
|
||||
g_major = (0.5 * jnp.sin(2 * jnp.pi * 196.00 * t[:half]) +
|
||||
0.4 * jnp.sin(2 * jnp.pi * 246.94 * t[:half]) +
|
||||
0.3 * jnp.sin(2 * jnp.pi * 293.66 * t[:half]))
|
||||
|
||||
signal = jnp.concatenate([c_major, g_major])
|
||||
|
||||
# Compute STFT
|
||||
n_fft = 4096 # high resolution for pitch accuracy
|
||||
hop_length = 512
|
||||
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)
|
||||
|
||||
S = stft(signal, n_fft, hop_length, window)
|
||||
power_spec = jnp.abs(S) ** 2
|
||||
freqs = jnp.fft.rfftfreq(n_fft, 1.0 / sr)
|
||||
|
||||
# Compute chromagram by mapping frequency bins to pitch classes
|
||||
# MIDI note number from frequency: 69 + 12 * log2(f / 440)
|
||||
note_names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||||
|
||||
def freq_to_chroma(freq):
|
||||
"""Map frequency to pitch class (0-11). Returns -1 for freq <= 0."""
|
||||
midi = 69 + 12 * jnp.log2(jnp.clip(freq, 1e-10, None) / 440.0)
|
||||
return jnp.round(midi).astype(int) % 12
|
||||
|
||||
# Build chromagram: sum power spectrum energy for each pitch class
|
||||
chromagram = jnp.zeros((power_spec.shape[0], 12))
|
||||
valid_freqs = freqs[1:] # skip DC
|
||||
valid_power = power_spec[:, 1:]
|
||||
|
||||
for p in range(12):
|
||||
# Find frequency bins belonging to this pitch class
|
||||
chroma_bins = freq_to_chroma(valid_freqs)
|
||||
mask = (chroma_bins == p).astype(jnp.float32)
|
||||
chromagram = chromagram.at[:, p].set(
|
||||
jnp.sum(valid_power * mask[None, :], axis=1)
|
||||
)
|
||||
|
||||
# Normalise each frame
|
||||
chromagram = chromagram / (jnp.max(chromagram, axis=1, keepdims=True) + 1e-8)
|
||||
|
||||
# Visualisation
|
||||
fig, axes = plt.subplots(3, 1, figsize=(14, 10))
|
||||
|
||||
# Waveform
|
||||
axes[0].plot(t[:3000], signal[:3000], color='#3498db', linewidth=0.5,
|
||||
label='C major')
|
||||
axes[0].plot(t[half:half+3000], signal[half:half+3000], color='#e74c3c',
|
||||
linewidth=0.5, label='G major')
|
||||
axes[0].set_title('Waveform: C major → G major')
|
||||
axes[0].set_ylabel('Amplitude')
|
||||
axes[0].set_xlabel('Time (s)')
|
||||
axes[0].legend()
|
||||
|
||||
# Spectrogram (log scale)
|
||||
time_axis = jnp.arange(power_spec.shape[0]) * hop_length / sr
|
||||
axes[1].imshow(jnp.log1p(power_spec[:, :500].T), aspect='auto', origin='lower',
|
||||
cmap='magma', extent=[0, time_axis[-1], 0, freqs[500]])
|
||||
axes[1].set_title('Power Spectrogram')
|
||||
axes[1].set_ylabel('Frequency (Hz)')
|
||||
axes[1].set_xlabel('Time (s)')
|
||||
|
||||
# Chromagram
|
||||
im = axes[2].imshow(chromagram.T, aspect='auto', origin='lower', cmap='YlOrRd',
|
||||
extent=[0, time_axis[-1], -0.5, 11.5])
|
||||
axes[2].set_yticks(range(12))
|
||||
axes[2].set_yticklabels(note_names)
|
||||
axes[2].set_title('Chromagram (pitch class energy over time)')
|
||||
axes[2].set_ylabel('Pitch class')
|
||||
axes[2].set_xlabel('Time (s)')
|
||||
plt.colorbar(im, ax=axes[2], fraction=0.046, label='Normalised energy')
|
||||
|
||||
# Mark expected active pitch classes
|
||||
mid_frame = chromagram.shape[0] // 2
|
||||
print(f"C major region - expected: C, E, G")
|
||||
print(f" Chroma values: {dict(zip(note_names, [f'{v:.2f}' for v in chromagram[mid_frame//2]]))}")
|
||||
print(f"G major region - expected: G, B, D")
|
||||
print(f" Chroma values: {dict(zip(note_names, [f'{v:.2f}' for v in chromagram[mid_frame + mid_frame//2]]))}")
|
||||
|
||||
plt.tight_layout()
|
||||
plt.show()
|
||||
```
|
||||
@@ -0,0 +1,804 @@
|
||||
# 源分离与降噪
|
||||
|
||||
*源分离与降噪从混合音频中恢复单个信号;即计算层面的"鸡尾酒会问题"。本文涵盖ICA、NMF、时频掩蔽、波束成形、深度学习分离网络(Conv-TasNet、SepFormer)、语音增强以及自适应降噪。*
|
||||
|
||||
- 想象一下你站在一个拥挤的鸡尾酒会上。数十人同时在交谈,音乐在播放,酒杯在碰撞,但你却能专注于一段对话并清晰地跟上它。这种非凡的能力被称为**鸡尾酒会问题**(Cherry, 1953),人类听觉系统可以毫不费力地做到,但机器却觉得异常困难。本文涵盖了尝试解决这一问题的算法:分离混合音频源、消除不必要的噪声以及在不利条件下增强语音。
|
||||
|
||||
- 文件01中的信号处理基础(STFT、语谱图、滤波器组)支撑了这里的每一种方法。第02章中的矩阵分解技术(NMF、ICA、SVD)提供了经典工具集。第06章中的深度学习架构(CNN、RNN、注意力机制)以及第04/05章中的概率论则为现代方法提供了理论基础。
|
||||
|
||||

|
||||
|
||||
- **问题形式化**:在一个或多个麦克风处观测到混合信号 $x(t)$。在最简单的情况下,混合信号是 $C$ 个源信号的和:
|
||||
|
||||
$$x(t) = \sum_{c=1}^{C} s_c(t) + n(t)$$
|
||||
|
||||
- 其中 $s_c(t)$ 是第 $c$ 个源信号,$n(t)$ 是背景噪声。目标是从 $x(t)$ 中恢复出各个 $s_c(t)$。在单麦克风情况下,这是一个严重欠定的问题:一个方程,$C$ 个未知数。需要额外的假设(统计独立性、频谱结构、学习先验)才能使问题变得可解。
|
||||
|
||||
- 在频域中(通过文件01中的STFT),混合信号变为:
|
||||
|
||||
$$X(t, f) = \sum_{c=1}^{C} S_c(t, f) + N(t, f)$$
|
||||
|
||||
- 许多分离方法在时频域中通过为每个源估计一个**掩蔽** $M_c(t, f) \in [0, 1]$ 来工作,然后通过 $\hat{S}_c(t, f) = M_c(t, f) \cdot X(t, f)$ 恢复源信号。**理想二值掩蔽(IBM)** 设置 $M_c(t, f) = 1$ 如果源 $c$ 在该时频单元中占主导,否则为0。**理想比率掩蔽(IRM)** 是其软版本:
|
||||
|
||||
$$\text{IRM}_c(t, f) = \frac{|S_c(t, f)|^2}{\sum_{j=1}^{C} |S_j(t, f)|^2}$$
|
||||
|
||||
- **独立成分分析(ICA)** 是麦克风数量等于或超过源数量时的经典方法。ICA(第02章)寻找一个线性解混矩阵 $W$,使得 $\hat{s} = Wx$,其中恢复的源 $\hat{s}$ 在统计上最大限度地独立。关键假设是源信号是非高斯且独立的,这对于语音和音乐通常是成立的。
|
||||
|
||||
- 对于多麦克风瞬时混叠模型 $x = As$(其中 $A$ 是混叠矩阵),ICA 通过最大化输出的非高斯性(FastICA 使用负熵)或最小化互信息来恢复 $W \approx A^{-1}$。ICA 在受控环境中表现良好,但当混叠涉及卷积(房间混响)、源数量超过麦克风数量或独立性假设被违反时则会失败。
|
||||
|
||||
- **非负矩阵分解(NMF)** 将幅度语谱图 $V \in \mathbb{R}_+^{F \times T}$ 分解为两个非负矩阵的乘积(第02章):
|
||||
|
||||
$$V \approx WH$$
|
||||
|
||||
- 其中 $W \in \mathbb{R}_+^{F \times K}$ 是包含 $K$ 个频谱基向量的字典,$H \in \mathbb{R}_+^{K \times T}$ 包含随时间变化的激活系数。非负约束具有物理动机:幅度是非负的,且声音是加性组合的。
|
||||
|
||||
- 对于源分离,NMF 为每个源学习独立的字典:$W_{\text{语音}}$ 捕捉语音的频谱模式(共振峰结构),而 $W_{\text{噪声}}$ 捕捉噪声模式。混合信号被分解为 $V \approx W_{\text{语音}} H_{\text{语音}} + W_{\text{噪声}} H_{\text{噪声}}$,每个源通过掩蔽来恢复。NMF 使用乘法更新规则进行最小化,代价函数可以是 Frobenius 范数或 KL 散度:
|
||||
|
||||
```math
|
||||
\begin{aligned}
|
||||
\text{Frobenius:} \quad D_F(V \| WH) &= \|V - WH\|_F^2 \\
|
||||
\text{KL:} \quad D_{KL}(V \| WH) &= \sum_{f,t} \left[ V_{ft} \log \frac{V_{ft}}{(WH)_{ft}} - V_{ft} + (WH)_{ft} \right]
|
||||
\end{aligned}
|
||||
```
|
||||
|
||||
- **波束成形**利用麦克风阵列的空间信息。当一个源信号以不同的延迟到达不同的麦克风(由于空间排列)时,这些延迟可以用来增强来自某个方向的信号,同时抑制其他方向的信号。
|
||||
|
||||

|
||||
|
||||
- **延迟求和波束成形**是最简单的方法。如果目标源相对于阵列的角度为 $\theta$,则在麦克风 $m$ 处的时间延迟为 $\tau_m(\theta) = d_m \sin \theta / c$,其中 $d_m$ 是麦克风位置,$c$ 是声速。波束成形器输出将麦克风信号对齐并求和:
|
||||
|
||||
$$y(t) = \frac{1}{M} \sum_{m=1}^{M} x_m(t - \tau_m(\theta))$$
|
||||
|
||||
- 来自目标方向的信号相干相加,而来自其他方向的信号非相干相加,从而实现空间滤波。阵列的几何形状决定了空间分辨率:更大的阵列产生更窄的波束。
|
||||
|
||||
- **最小方差无失真响应(MVDR)** 波束成形优化权重,以最小化总输出功率,同时保证目标方向无失真地通过:
|
||||
|
||||
```math
|
||||
\begin{aligned}
|
||||
\min_{\mathbf{w}} \quad & \mathbf{w}^H \Phi_{nn} \mathbf{w} \\
|
||||
\text{subject to} \quad & \mathbf{w}^H \mathbf{d}(\theta) = 1
|
||||
\end{aligned}
|
||||
```
|
||||
|
||||
- 其中 $\Phi_{nn}$ 是噪声空间协方差矩阵,$\mathbf{d}(\theta)$ 是方向 $\theta$ 的导向向量。闭式解为:
|
||||
|
||||
$$\mathbf{w}_{\text{MVDR}} = \frac{\Phi_{nn}^{-1} \mathbf{d}(\theta)}{\mathbf{d}(\theta)^H \Phi_{nn}^{-1} \mathbf{d}(\theta)}$$
|
||||
|
||||
- MVDR 通过使用估计的噪声协方差自适应地适应噪声环境,比延迟求和提供更好的干扰抑制能力。它广泛用于助听器、智能音箱和远程会议系统。
|
||||
|
||||
- **深度学习用于源分离**显著提升了性能,特别是在经典方法难以处理的单麦克风情况下。一般范式是:编码混合信号,通过神经网络估计掩蔽或源表示,然后解码以恢复各个源。
|
||||
|
||||
- **深度聚类**(Hershey 等,2016)将每个时频单元嵌入到一个高维空间中,使得属于同一源的单元彼此靠近,而来自不同源的单元则远离。一个双向 LSTM(第06章)将每个时频单元 $(t, f)$ 映射为一个嵌入向量 $v_{t,f} \in \mathbb{R}^D$。训练目标为:
|
||||
|
||||
$$\mathcal{L} = \|VV^T - YY^T\|_F^2$$
|
||||
|
||||
- 其中 $V$ 是嵌入矩阵,$Y$ 是源分配的单热矩阵。乘积 $VV^T$ 是一个亲和矩阵(两个单元的嵌入有多相似),而 $YY^T$ 是理想的亲和度(若属于同一源则为1,否则为0)。推理时,对嵌入进行 K-means 聚类产生二值掩蔽。
|
||||
|
||||
- **Conv-TasNet**(Luo 和 Mesgarani,2019)完全在时域中操作,绕过了 STFT。它包含三个组件:
|
||||
|
||||

|
||||
|
||||
- **编码器**:一个一维卷积将混合波形的短片段映射为潜在表示。对于混合信号 $x \in \mathbb{R}^T$,编码器输出为 $w = \text{ReLU}(U \ast x) \in \mathbb{R}^{N \times L}$,其中 $U$ 是一个可学习的基(类似于 STFT 基但从数据中学习),$N$ 是基函数的数量,$L$ 是片段数。编码器核大小和步长(通常为2ms和1ms)决定了时间分辨率。
|
||||
|
||||
- **分离器**:一个**时域卷积网络(TCN)**处理编码后的混合信号并输出 $C$ 个掩蔽。TCN 堆叠了扩张一维深度可分离卷积(来自第08章的高效卷积),这些卷积以指数增长的扩张因子 $1, 2, 4, \ldots, 2^{B-1}$ 排列成块,重复 $R$ 次。这提供了非常大的感受野,同时保持计算高效。
|
||||
|
||||
- **解码器**:一个转置一维卷积(使用可学习基 $V$)将每个掩蔽后的表示转换回时域:$\hat{s}_c = V^T (M_c \odot w)$。
|
||||
|
||||
- Conv-TasNet 显著优于基于语谱图的方法,因为学习到的编码器-解码器基可以捕捉 STFT 幅度所丢弃的信息(特别是相位)。
|
||||
|
||||
- **双路径 RNN(DPRNN)**(Luo 等,2020)解决了分离中的长序列建模问题。DPRNN 不是用单个 RNN 或 TCN 处理整个编码序列,而是将序列分割成重叠的块,并沿着两条路径应用 RNN:**块内**路径(对每个块内的局部模式建模)和**块间**路径(对跨块的全局模式建模)。这使 RNN 序列长度从 $L$ 降低到每个维度上的 $\sqrt{L}$:
|
||||
|
||||
```math
|
||||
\begin{aligned}
|
||||
\text{块内:} \quad & h_{k,n}^{\text{块内}} = \text{BiLSTM}_{\text{块内}}(z_{k,n}) \\
|
||||
\text{块间:} \quad & h_{k,n}^{\text{块间}} = \text{BiLSTM}_{\text{块间}}(h_{k,n}^{\text{块内}})
|
||||
\end{aligned}
|
||||
```
|
||||
|
||||
- 其中 $k$ 索引块,$n$ 索引块内的位置。块内 LSTM 对固定 $k$ 的各 $n$ 处理;块间 LSTM 对固定 $n$ 的各 $k$ 处理。
|
||||
|
||||
- **SepFormer**(Subakan 等,2021)用 Transformer(第07章)替换了双路径框架中的 RNN。块内 Transformer 通过自注意力捕捉局部依赖关系,块间 Transformer 捕捉全局依赖关系。多头注意力能够建模长程依赖关系而不会出现梯度消失问题(第06章),这使得 SepFormer 对于长录音特别有效。SepFormer 在 WSJ0-2mix 基准上达到了最先进的结果。
|
||||
|
||||
- **置换不变训练(PIT)** 解决了监督式源分离中的一个基本问题:标签分配歧义。如果网络有两个输出(对应两个说话人),哪个输出应该对应哪个说话人?没有自然的排序。PIT 计算所有可能分配的损失并取最小值:
|
||||
|
||||
$$\mathcal{L}_{\text{PIT}} = \min_{\pi \in \mathcal{P}} \sum_{c=1}^{C} \ell(\hat{s}_{\pi(c)}, s_c)$$
|
||||
|
||||
- 其中 $\mathcal{P}$ 是 $\{1, \ldots, C\}$ 的所有排列集合,$\ell$ 是每个源的损失(通常是尺度不变信号失真比 SI-SDR)。对于 $C = 2$ 个源只有2种排列;对于 $C = 3$ 有6种。对于更大的 $C$,可以使用匈牙利算法高效计算。
|
||||
|
||||
- **尺度不变信号失真比(SI-SDR)** 是源分离的标准评估指标:
|
||||
|
||||
```math
|
||||
\begin{aligned}
|
||||
s_{\text{target}} &= \frac{\langle \hat{s}, s \rangle}{\|s\|^2} s \\
|
||||
e_{\text{noise}} &= \hat{s} - s_{\text{target}} \\
|
||||
\text{SI-SDR} &= 10 \log_{10} \frac{\|s_{\text{target}}\|^2}{\|e_{\text{noise}}\|^2}
|
||||
\end{aligned}
|
||||
```
|
||||
|
||||
- 其中 $\hat{s}$ 是估计的源,$s$ 是真实值。SI-SDR 对估计的总体尺度不变,这是期望的特性,因为绝对音量不如分离质量重要。较高的 SI-SDR(以 dB 为单位)更好。最先进的系统在 WSJ0-2mix 上实现了约 20-22 dB 的 SI-SDR 改进。
|
||||
|
||||
- **音乐源分离**将音乐录音分离成声部:人声、鼓、贝斯和其他乐器。这实现了卡拉OK(去除人声)、重新混音(调整乐器电平)和转录(一次分析一种乐器)等应用。
|
||||
|
||||
- **Open-Unmix**(Stoter 等,2019)是一个参考基线,使用三层双向 LSTM 在幅度 STFT 域中为每个源预测软掩蔽。它使用专用模型独立处理每个源。Open-Unmix 虽简单但有效,在 MUSDB18 上建立了可重复的基准。
|
||||
|
||||
- **Demucs**(Defossez 等,2019;2021年更新为 Hybrid Demucs)使用直接在波形上操作的 U-Net 架构(第08章)。编码器通过步长卷积压缩混合信号,解码器通过转置卷积和跳跃连接将其扩展回来,每个源有各自的解码器头。**Hybrid Demucs** 结合了时域和频域处理:编码器具有并行的时域和 STFT 分支,其特征在解码器之前融合。这同时捕捉了精细的时间细节和频谱结构。
|
||||
|
||||
- Demucs 在 MUSDB18 上达到了最先进的分离质量,特别是人声分离方面。其 U-Net 架构让人联想到第08章中的图像分割架构,将分离问题视为一种"音频分割"形式。
|
||||
|
||||
- **主动降噪(ANC)** 通过生成一个与噪声相消干涉的反噪声信号来减少不需要的声音。想象一下降噪耳机:麦克风拾取环境噪声,ANC 系统生成一个反相版本,混合信号(噪声 + 反噪声)理想情况下抵消为静音。
|
||||
|
||||
- 物理原理很简单:如果噪声是 $n(t)$,在空间同一点生成 $-n(t)$ 则产生静音:$n(t) + (-n(t)) = 0$。挑战在于反噪声必须在时间、幅度和相位上精确对齐。即使很小的误差也会产生残留噪声或伪影。
|
||||
|
||||
- **前馈式 ANC** 使用一个参考麦克风,在噪声到达听者之前拾取噪声。系统有时间处理噪声并生成反噪声。参考信号通过一个自适应滤波器,其输出在误差麦克风(靠近听者)处从噪声中减去。这适用于可预测的宽带噪声(引擎嗡嗡声、风扇噪声)。
|
||||
|
||||
- **反馈式 ANC** 仅使用听者耳边的误差麦克风。系统从残余信号(听者实际听到的)中估计噪声并调整反噪声。反馈式 ANC 更简单(不需要参考麦克风),但带宽有限且可能变得不稳定。
|
||||
|
||||
- **自适应滤波**是 ANC 背后的数学引擎。滤波器系数必须不断适应变化的噪声环境。最常用的算法是**最小均方(LMS)**滤波器。
|
||||
|
||||

|
||||
|
||||
- **LMS 算法**:一个具有系数 $\mathbf{w} = [w_0, w_1, \ldots, w_{L-1}]^T$ 的 FIR 滤波器处理参考信号 $\mathbf{x}(n) = [x(n), x(n-1), \ldots, x(n-L+1)]^T$。输出为 $y(n) = \mathbf{w}^T \mathbf{x}(n)$,误差为 $e(n) = d(n) - y(n)$(其中 $d(n)$ 是期望/主信号),权重更新为:
|
||||
|
||||
$$\mathbf{w}(n+1) = \mathbf{w}(n) + \mu \, e(n) \, \mathbf{x}(n)$$
|
||||
|
||||
- 其中 $\mu$ 是步长(学习率)。这是对均方误差 $E[e^2(n)]$ 的一个随机梯度下降步骤,使用瞬时梯度估计 $-2 e(n) \mathbf{x}(n)$ 代替真实梯度(第03章的梯度下降和第06章的 SGD)。
|
||||
|
||||
- 步长 $\mu$ 控制收敛速度与稳态误差之间的权衡。过大则滤波器振荡或发散;过小则自适应速度迟缓。稳定条件为 $0 < \mu < 2 / (\lambda_{\max})$,其中 $\lambda_{\max}$ 是输入自相关矩阵 $R = E[\mathbf{x}\mathbf{x}^T]$ 的最大特征值。
|
||||
|
||||
- **归一化 LMS(NLMS)** 通过输入功率对步长进行归一化,使收敛与信号电平无关:
|
||||
|
||||
$$\mathbf{w}(n+1) = \mathbf{w}(n) + \frac{\mu}{\|\mathbf{x}(n)\|^2 + \epsilon} \, e(n) \, \mathbf{x}(n)$$
|
||||
|
||||
- 其中 $\epsilon$ 是一个小的正则化常数,以防止除零。NLMS 比 LMS 更可靠地收敛,因为有效步长自适应地适应输入功率。
|
||||
|
||||
- **递归最小二乘(RLS)** 是一种收敛更快的替代方法,它最小化加权最小二乘代价 $\sum_{k=1}^{n} \lambda^{n-k} e^2(k)$,其中 $\lambda \in (0, 1]$ 是遗忘因子。RLS 维护逆自相关矩阵的估计并递归更新,以每个样本 $O(L^2)$ 的计算成本(相对于 LMS 的 $O(L)$)实现最优收敛。
|
||||
|
||||
- **降噪与语音增强**旨在提高嘈杂录音中的语音质量和可懂度。与源分离(分离不同的源)不同,语音增强专门针对语音加噪声的情况,从带噪观测中恢复干净的语音。
|
||||
|
||||
- **谱减法**是最简单的方法。在纯噪声帧(由文件03中的 VAD 检测)期间,估计噪声频谱 $|\hat{N}(f)|^2$。然后将其从每个帧中减去:
|
||||
|
||||
$$|\hat{S}(f)|^2 = \max(|X(f)|^2 - \alpha |\hat{N}(f)|^2, \beta |X(f)|^2)$$
|
||||
|
||||
- 其中 $\alpha$ 是过减因子(通常为1-4,激进的减法去除更多噪声但引入更多伪影),$\beta$ 是频谱地板,防止出现负值并减少"音乐噪声"伪影(听起来像随机音符的孤立音调残留)。
|
||||
|
||||
- **维纳滤波**提供了干净语音频谱的最小均方误差估计:
|
||||
|
||||
$$\hat{S}(t, f) = \frac{|S(t,f)|^2}{|S(t,f)|^2 + |N(t,f)|^2} \cdot X(t, f) = G(t, f) \cdot X(t, f)$$
|
||||
|
||||
- 维纳增益 $G(t, f) = \text{SNR}(t, f) / (1 + \text{SNR}(t, f))$ 的范围从0(纯噪声)到1(纯语音),作为一个软掩蔽。挑战在于估计语音和噪声的功率谱。**先验 SNR** $\xi(t, f) = |S(t,f)|^2 / |N(t,f)|^2$ 使用"决策导向"方法估计:当前帧估计与前一帧维纳滤波输出的平滑组合。
|
||||
|
||||
- **神经语音增强**使用深度学习来估计掩蔽(如维纳增益)或直接估计干净语谱图。架构从简单的前馈网络到 U-Net(第08章)、CRN(卷积递归网络)和 Transformer。
|
||||
|
||||
- **DCCRN**(深度复数卷积递归网络)在复数 STFT(幅度和相位)上操作,使用自然处理实部和虚部的复数值卷积。这避免了仅幅度方法所困扰的相位估计问题。
|
||||
|
||||
- **FullSubNet** 使用双路径架构,包含一个全频带模型(捕捉全局频谱模式)和一个子频带模型(捕捉局部谐波细节)。全频带模型处理整个频谱,而子频带模型处理以每个频率单元为中心的窄频带。它们的输出被组合用于最终的掩蔽估计。
|
||||
|
||||
- **DNS(深度噪声抑制)挑战赛**由微软每年举办,对语音增强系统进行基准测试。获胜者通常使用大规模训练,包含多种噪声类型、数据增强(以各种 SNR 添加噪声、混响、编解码器伪影)以及支持实时处理的架构。
|
||||
|
||||
- **回声消除**在双向通信中去除声学回声。当你在电话通话中时,远端说话人的声音通过你的扬声器播放,在房间内反弹,并被你的麦克风拾取,产生远端说话人听到的回声。**声学回声消除(AEC)** 对从扬声器到麦克风的声学路径进行建模并减去预测的回声。
|
||||
|
||||
- 声学路径被建模为一个自适应 FIR 滤波器(使用 LMS 或 NLMS),以远端信号为输入。滤波器对房间脉冲响应进行建模,包括直达路径、早期反射和晚期混响。房间脉冲响应可能长达数百毫秒,需要数千个抽头的滤波器。
|
||||
|
||||
- **双讲检测**对 AEC 至关重要:当近端和远端说话人同时说话时,自适应滤波器必须冻结(停止更新),以防止其抵消近端说话人的声音。双讲检测器将误差信号的能量与远端信号能量进行比较;无法用远端信号解释的误差能量突然增加表明存在近端语音。
|
||||
|
||||
- 远端信号 $x(n)$ 与麦克风信号 $d(n)$ 之间的**归一化互相关**提供了一个双讲指示符:
|
||||
|
||||
$$\xi(n) = \frac{|\sum_{k=0}^{L-1} x(n-k) d(n-k)|}{\sqrt{\sum_{k} x^2(n-k)} \sqrt{\sum_{k} d^2(n-k)}}$$
|
||||
|
||||
- 在单讲期间(仅远端),$\xi$ 较高,因为 $d$ 主要是 $x$ 的回声。在双讲期间,$\xi$ 下降,因为近端语音与 $x$ 不相关。
|
||||
|
||||
- 现代 AEC 系统将自适应滤波与神经网络相结合:自适应滤波器提供初始回声估计,神经网络(类似于上述语音增强模型)清理残余回声并处理线性滤波器无法捕捉的非线性(扬声器失真)。
|
||||
|
||||
- **分离与增强的评估指标**:
|
||||
- **SI-SDR**(如上定义):源分离的标准指标。
|
||||
- **SDR**(信号失真比):来自 BSS Eval,衡量包括伪影和干扰在内的整体分离质量。
|
||||
- **PESQ**(语音质量感知评估):ITU 标准,预测主观质量分数。范围:-0.5 至 4.5。
|
||||
- **STOI**(短时客观可懂度):预测语音可懂度。范围:0 至 1。
|
||||
- **DNSMOS**:微软的深度噪声抑制 MOS 预测器,一个训练用于预测人类 MOS 分数的神经网络,无需干净的参考音频。
|
||||
|
||||
## 编程任务(使用 CoLab 或 notebook)
|
||||
|
||||
- **任务 1:用于源分离的独立成分分析。** 实现 FastICA 来分离两个混合音频源,演示确定情况(源与麦克风数量相等)下的经典鸡尾酒会解决方案。
|
||||
|
||||
```python
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import jax.random as jr
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# 生成两个源信号
|
||||
sr = 8000
|
||||
duration = 1.0
|
||||
t = jnp.linspace(0, duration, int(sr * duration))
|
||||
|
||||
# 源 1:正弦波(类似音调)
|
||||
s1 = jnp.sin(2 * jnp.pi * 440 * t) + 0.3 * jnp.sin(2 * jnp.pi * 880 * t)
|
||||
|
||||
# 源 2:锯齿波(丰富的谐波)
|
||||
s2 = 2 * (t * 200 % 1) - 1 # 200 Hz 锯齿波
|
||||
|
||||
# 归一化源信号
|
||||
s1 = s1 / jnp.max(jnp.abs(s1))
|
||||
s2 = s2 / jnp.max(jnp.abs(s2))
|
||||
sources = jnp.stack([s1, s2]) # (2, T)
|
||||
|
||||
# 混叠矩阵(算法未知)
|
||||
A = jnp.array([[0.8, 0.4],
|
||||
[0.3, 0.9]])
|
||||
mixtures = A @ sources # (2, T)
|
||||
|
||||
# FastICA 实现
|
||||
def whiten(X):
|
||||
"""数据中心化与白化。"""
|
||||
X_centered = X - jnp.mean(X, axis=1, keepdims=True)
|
||||
cov = (X_centered @ X_centered.T) / X_centered.shape[1]
|
||||
eigvals, eigvecs = jnp.linalg.eigh(cov)
|
||||
D_inv_sqrt = jnp.diag(1.0 / jnp.sqrt(eigvals + 1e-8))
|
||||
whitening = D_inv_sqrt @ eigvecs.T
|
||||
return whitening @ X_centered, whitening
|
||||
|
||||
def fastica(X, n_components=2, max_iter=200, tol=1e-6):
|
||||
"""使用 tanh 非线性的 FastICA(负熵近似)。"""
|
||||
X_white, whitening = whiten(X)
|
||||
n, T = X_white.shape
|
||||
|
||||
key = jr.PRNGKey(42)
|
||||
W = jr.normal(key, (n_components, n))
|
||||
# 正交化 W
|
||||
U, _, Vt = jnp.linalg.svd(W, full_matrices=False)
|
||||
W = U @ Vt
|
||||
|
||||
for iteration in range(max_iter):
|
||||
W_old = W.copy()
|
||||
|
||||
# 对每个分量
|
||||
for i in range(n_components):
|
||||
w = W[i]
|
||||
# w^T X_white: (T,)
|
||||
wx = w @ X_white # (T,)
|
||||
|
||||
# g(u) = tanh(u), g'(u) = 1 - tanh^2(u)
|
||||
g_wx = jnp.tanh(wx)
|
||||
g_prime_wx = 1 - g_wx ** 2
|
||||
|
||||
# Newton 更新: w_new = E[X * g(w^T X)] - E[g'(w^T X)] * w
|
||||
w_new = jnp.mean(X_white * g_wx[None, :], axis=1) - \
|
||||
jnp.mean(g_prime_wx) * w
|
||||
|
||||
# 与之前的分量去相关(消去法)
|
||||
for j in range(i):
|
||||
w_new = w_new - jnp.dot(w_new, W[j]) * W[j]
|
||||
|
||||
w_new = w_new / jnp.linalg.norm(w_new)
|
||||
W = W.at[i].set(w_new)
|
||||
|
||||
# 检查收敛
|
||||
convergence = jnp.min(jnp.abs(jnp.diag(W @ W_old.T)))
|
||||
if convergence > 1 - tol:
|
||||
print(f"FastICA 在 {iteration + 1} 次迭代后收敛")
|
||||
break
|
||||
|
||||
# 解混矩阵
|
||||
unmixing = W @ whitening
|
||||
recovered = unmixing @ X
|
||||
return recovered, unmixing
|
||||
|
||||
recovered, W_unmix = fastica(mixtures)
|
||||
|
||||
# 修复符号歧义(ICA 可能翻转符号)
|
||||
for i in range(2):
|
||||
if jnp.corrcoef(recovered[i], sources[i])[0, 1] < -0.5:
|
||||
recovered = recovered.at[i].set(-recovered[i])
|
||||
|
||||
# 如果源被交换,修复排列
|
||||
corr_00 = jnp.abs(jnp.corrcoef(recovered[0], sources[0])[0, 1])
|
||||
corr_01 = jnp.abs(jnp.corrcoef(recovered[0], sources[1])[0, 1])
|
||||
if corr_01 > corr_00:
|
||||
recovered = recovered[::-1]
|
||||
|
||||
# 归一化以便显示
|
||||
recovered = recovered / jnp.max(jnp.abs(recovered), axis=1, keepdims=True)
|
||||
|
||||
fig, axes = plt.subplots(3, 2, figsize=(14, 9))
|
||||
|
||||
axes[0, 0].plot(t[:1000], s1[:1000], color='#3498db', linewidth=0.8)
|
||||
axes[0, 0].set_title('源信号 1(原始)')
|
||||
axes[0, 0].set_ylabel('幅度')
|
||||
|
||||
axes[0, 1].plot(t[:1000], s2[:1000], color='#e74c3c', linewidth=0.8)
|
||||
axes[0, 1].set_title('源信号 2(原始)')
|
||||
|
||||
axes[1, 0].plot(t[:1000], mixtures[0, :1000], color='#9b59b6', linewidth=0.8)
|
||||
axes[1, 0].set_title('混合信号 1(麦克风 1)')
|
||||
axes[1, 0].set_ylabel('幅度')
|
||||
|
||||
axes[1, 1].plot(t[:1000], mixtures[1, :1000], color='#9b59b6', linewidth=0.8)
|
||||
axes[1, 1].set_title('混合信号 2(麦克风 2)')
|
||||
|
||||
axes[2, 0].plot(t[:1000], recovered[0, :1000], color='#27ae60', linewidth=0.8)
|
||||
axes[2, 0].set_title('恢复的源信号 1(FastICA)')
|
||||
axes[2, 0].set_ylabel('幅度')
|
||||
axes[2, 0].set_xlabel('时间 (s)')
|
||||
|
||||
axes[2, 1].plot(t[:1000], recovered[1, :1000], color='#f39c12', linewidth=0.8)
|
||||
axes[2, 1].set_title('恢复的源信号 2(FastICA)')
|
||||
axes[2, 1].set_xlabel('时间 (s)')
|
||||
|
||||
plt.tight_layout()
|
||||
plt.show()
|
||||
|
||||
# 报告与原始信号的相关性
|
||||
for i in range(2):
|
||||
corr = jnp.corrcoef(recovered[i], sources[i])[0, 1]
|
||||
print(f"源 {i+1} 恢复相关性: {corr:.4f}")
|
||||
```
|
||||
|
||||
- **任务 2:基于 NMF 的语谱图源分离。** 使用非负矩阵分解(第02章)将语谱图分离为两个分量,演示 NMF 如何为每个源学习频谱字典。
|
||||
|
||||
```python
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import jax.random as jr
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# 生成两个具有不同频谱特征的信号
|
||||
sr = 8000
|
||||
duration = 1.0
|
||||
t = jnp.linspace(0, duration, int(sr * duration))
|
||||
|
||||
# 源 1:低频谐波(模拟贝斯)
|
||||
src1 = (jnp.sin(2 * jnp.pi * 100 * t) +
|
||||
0.5 * jnp.sin(2 * jnp.pi * 200 * t) +
|
||||
0.3 * jnp.sin(2 * jnp.pi * 300 * t))
|
||||
|
||||
# 源 2:高频谐波(模拟长笛)
|
||||
src2 = (jnp.sin(2 * jnp.pi * 800 * t) +
|
||||
0.4 * jnp.sin(2 * jnp.pi * 1600 * t))
|
||||
|
||||
# 时变幅度(源在不同时间激活)
|
||||
env1 = jnp.where(t < 0.5, 1.0, 0.3)
|
||||
env2 = jnp.where(t > 0.3, 1.0, 0.2)
|
||||
src1 = src1 * env1
|
||||
src2 = src2 * env2
|
||||
|
||||
mixture = src1 + src2
|
||||
|
||||
# 计算幅度语谱图(STFT)
|
||||
n_fft = 512
|
||||
hop = 128
|
||||
window = jnp.hanning(n_fft)
|
||||
|
||||
def compute_stft(signal, n_fft, hop, window):
|
||||
n_frames = 1 + (len(signal) - n_fft) // hop
|
||||
frames = jnp.stack([
|
||||
signal[i * hop : i * hop + n_fft] * window
|
||||
for i in range(n_frames)
|
||||
])
|
||||
return jnp.fft.rfft(frames, n=n_fft)
|
||||
|
||||
S_mix = compute_stft(mixture, n_fft, hop, window)
|
||||
V = jnp.abs(S_mix).T # (F, T) - 频率 x 时间
|
||||
phase = jnp.angle(S_mix).T
|
||||
|
||||
F, T = V.shape
|
||||
print(f"语谱图形状: {F} 个频率 bin x {T} 个时间帧")
|
||||
|
||||
# NMF: V ≈ WH 使用乘法更新规则
|
||||
def nmf(V, K, n_iter=200, key=jr.PRNGKey(0)):
|
||||
"""使用 Frobenius 范数的非负矩阵分解。"""
|
||||
k1, k2 = jr.split(key)
|
||||
W = jnp.abs(jr.normal(k1, (F, K))) * 0.1 + 0.01 # (F, K)
|
||||
H = jnp.abs(jr.normal(k2, (K, T))) * 0.1 + 0.01 # (K, T)
|
||||
|
||||
costs = []
|
||||
for i in range(n_iter):
|
||||
# H 的乘法更新
|
||||
WtV = W.T @ V
|
||||
WtWH = W.T @ W @ H + 1e-8
|
||||
H = H * (WtV / WtWH)
|
||||
|
||||
# W 的乘法更新
|
||||
VHt = V @ H.T
|
||||
WHHt = W @ H @ H.T + 1e-8
|
||||
W = W * (VHt / WHHt)
|
||||
|
||||
cost = jnp.sum((V - W @ H) ** 2)
|
||||
costs.append(float(cost))
|
||||
|
||||
return W, H, costs
|
||||
|
||||
# 运行 K=2 个分量的 NMF
|
||||
K = 2
|
||||
W, H, costs = nmf(V, K, n_iter=300)
|
||||
|
||||
# 使用软掩蔽重建每个源
|
||||
V_hat = W @ H
|
||||
mask1 = (W[:, 0:1] @ H[0:1, :]) / (V_hat + 1e-8)
|
||||
mask2 = (W[:, 1:2] @ H[1:2, :]) / (V_hat + 1e-8)
|
||||
|
||||
V_src1 = mask1 * V
|
||||
V_src2 = mask2 * V
|
||||
|
||||
# 可视化
|
||||
fig, axes = plt.subplots(3, 2, figsize=(14, 10))
|
||||
|
||||
# 混合信号语谱图
|
||||
axes[0, 0].imshow(jnp.log1p(V), aspect='auto', origin='lower', cmap='magma')
|
||||
axes[0, 0].set_title('混合信号语谱图 |X|')
|
||||
axes[0, 0].set_ylabel('频率 bin')
|
||||
|
||||
# NMF 收敛
|
||||
axes[0, 1].plot(costs, color='#3498db', linewidth=1.5)
|
||||
axes[0, 1].set_title('NMF 收敛曲线')
|
||||
axes[0, 1].set_xlabel('迭代次数')
|
||||
axes[0, 1].set_ylabel('Frobenius 代价')
|
||||
axes[0, 1].set_yscale('log')
|
||||
|
||||
# 频谱基向量 W
|
||||
freq_hz = jnp.arange(F) * sr / n_fft
|
||||
axes[1, 0].plot(freq_hz, W[:, 0], color='#27ae60', linewidth=1.5,
|
||||
label='基 1(低频)')
|
||||
axes[1, 0].plot(freq_hz, W[:, 1], color='#e74c3c', linewidth=1.5,
|
||||
label='基 2(高频)')
|
||||
axes[1, 0].set_title('学习到的频谱基 W')
|
||||
axes[1, 0].set_xlabel('频率 (Hz)')
|
||||
axes[1, 0].set_ylabel('幅度')
|
||||
axes[1, 0].legend()
|
||||
|
||||
# 时域激活 H
|
||||
time_s = jnp.arange(T) * hop / sr
|
||||
axes[1, 1].plot(time_s, H[0], color='#27ae60', linewidth=1.5,
|
||||
label='激活 1')
|
||||
axes[1, 1].plot(time_s, H[1], color='#e74c3c', linewidth=1.5,
|
||||
label='激活 2')
|
||||
axes[1, 1].set_title('时域激活 H')
|
||||
axes[1, 1].set_xlabel('时间 (s)')
|
||||
axes[1, 1].set_ylabel('激活值')
|
||||
axes[1, 1].legend()
|
||||
|
||||
# 分离后的语谱图
|
||||
axes[2, 0].imshow(jnp.log1p(V_src1), aspect='auto', origin='lower', cmap='magma')
|
||||
axes[2, 0].set_title('分离后的源信号 1(低频)')
|
||||
axes[2, 0].set_ylabel('频率 bin')
|
||||
axes[2, 0].set_xlabel('时间帧')
|
||||
|
||||
axes[2, 1].imshow(jnp.log1p(V_src2), aspect='auto', origin='lower', cmap='magma')
|
||||
axes[2, 1].set_title('分离后的源信号 2(高频)')
|
||||
axes[2, 1].set_xlabel('时间帧')
|
||||
|
||||
plt.tight_layout()
|
||||
plt.show()
|
||||
|
||||
print(f"重建误差: {jnp.sum((V - W @ H)**2):.2f}")
|
||||
print(f"NMF 学习到的频谱基能够捕捉每个源的频率特征。")
|
||||
```
|
||||
|
||||
- **任务 3:用于降噪的 LMS 自适应滤波器。** 实现 LMS 和 NLMS 算法用于回声/降噪,展示收敛行为及步长的影响。
|
||||
|
||||
```python
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import jax.random as jr
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# 模拟回声消除场景
|
||||
# 远端信号 -> 房间脉冲响应 -> 麦克风处的回声
|
||||
# 近端语音是我们希望保留的目标信号
|
||||
|
||||
sr = 8000
|
||||
duration = 2.0
|
||||
n_samples = int(sr * duration)
|
||||
key = jr.PRNGKey(42)
|
||||
keys = jr.split(key, 5)
|
||||
|
||||
# 远端信号(参考):随机的类语音信号
|
||||
far_end = jr.normal(keys[0], (n_samples,)) * 0.5
|
||||
|
||||
# 房间脉冲响应(算法未知)
|
||||
rir_length = 64
|
||||
rir = jnp.zeros(rir_length)
|
||||
rir = rir.at[0].set(0.8) # 直达路径
|
||||
rir = rir.at[5].set(0.3) # 早期反射
|
||||
rir = rir.at[12].set(-0.2) # 反射
|
||||
rir = rir.at[25].set(0.1) # 晚期反射
|
||||
rir = rir.at[40].set(-0.05)
|
||||
|
||||
# 回声:远端信号与 RIR 的卷积
|
||||
echo = jnp.convolve(far_end, rir)[:n_samples]
|
||||
|
||||
# 近端语音(在信号的一部分中活跃)
|
||||
near_end = jnp.zeros(n_samples)
|
||||
start, end = n_samples // 3, 2 * n_samples // 3
|
||||
near_speech = 0.3 * jnp.sin(
|
||||
2 * jnp.pi * 300 * jnp.linspace(0, (end - start) / sr, end - start)
|
||||
)
|
||||
near_end = near_end.at[start:end].set(near_speech)
|
||||
|
||||
# 麦克风信号:回声 + 近端 + 噪声
|
||||
noise = jr.normal(keys[1], (n_samples,)) * 0.01
|
||||
mic_signal = echo + near_end + noise
|
||||
|
||||
# LMS 自适应滤波器
|
||||
def lms_filter(reference, desired, filter_length, mu):
|
||||
"""标准 LMS 自适应滤波器。"""
|
||||
n = len(reference)
|
||||
w = jnp.zeros(filter_length)
|
||||
output = jnp.zeros(n)
|
||||
error = jnp.zeros(n)
|
||||
w_history = []
|
||||
|
||||
for i in range(filter_length, n):
|
||||
x = reference[max(0, i-filter_length+1):i+1][::-1]
|
||||
|
||||
y = jnp.dot(w, x)
|
||||
e = desired[i] - y
|
||||
w = w + mu * e * x
|
||||
|
||||
output = output.at[i].set(y)
|
||||
error = error.at[i].set(e)
|
||||
|
||||
if i % 500 == 0:
|
||||
w_history.append(w.copy())
|
||||
|
||||
return output, error, w_history
|
||||
|
||||
# NLMS 自适应滤波器
|
||||
def nlms_filter(reference, desired, filter_length, mu, eps=1e-6):
|
||||
"""归一化 LMS 自适应滤波器。"""
|
||||
n = len(reference)
|
||||
w = jnp.zeros(filter_length)
|
||||
output = jnp.zeros(n)
|
||||
error = jnp.zeros(n)
|
||||
|
||||
for i in range(filter_length, n):
|
||||
x = reference[max(0, i-filter_length+1):i+1][::-1]
|
||||
|
||||
y = jnp.dot(w, x)
|
||||
e = desired[i] - y
|
||||
norm_factor = jnp.dot(x, x) + eps
|
||||
w = w + (mu / norm_factor) * e * x
|
||||
|
||||
output = output.at[i].set(y)
|
||||
error = error.at[i].set(e)
|
||||
|
||||
return output, error
|
||||
|
||||
# 使用不同步长运行 LMS
|
||||
filter_len = 64
|
||||
mu_values = [0.001, 0.01, 0.05]
|
||||
colors_mu = ['#3498db', '#e74c3c', '#27ae60']
|
||||
|
||||
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
|
||||
|
||||
# 原始信号
|
||||
t = jnp.arange(n_samples) / sr
|
||||
axes[0, 0].plot(t, mic_signal, color='#9b59b6', linewidth=0.5, alpha=0.7,
|
||||
label='麦克风(回声 + 近端)')
|
||||
axes[0, 0].plot(t, echo, color='#e74c3c', linewidth=0.5, alpha=0.7,
|
||||
label='回声(待消除)')
|
||||
axes[0, 0].plot(t, near_end, color='#27ae60', linewidth=0.8,
|
||||
label='近端语音(需保留)')
|
||||
axes[0, 0].set_title('信号分量')
|
||||
axes[0, 0].set_xlabel('时间 (s)')
|
||||
axes[0, 0].set_ylabel('幅度')
|
||||
axes[0, 0].legend(fontsize=8)
|
||||
|
||||
# 不同步长下的 LMS 收敛
|
||||
for mu, color in zip(mu_values, colors_mu):
|
||||
_, err, _ = lms_filter(far_end, mic_signal, filter_len, mu)
|
||||
# 平滑后的平方误差
|
||||
sq_err = err ** 2
|
||||
window_size = 200
|
||||
smoothed = jnp.convolve(sq_err, jnp.ones(window_size)/window_size,
|
||||
mode='valid')
|
||||
axes[0, 1].plot(smoothed, color=color, linewidth=1.2,
|
||||
label=f'mu={mu}')
|
||||
|
||||
axes[0, 1].set_title('LMS 收敛曲线(平滑 MSE)')
|
||||
axes[0, 1].set_xlabel('样本')
|
||||
axes[0, 1].set_ylabel('平方误差')
|
||||
axes[0, 1].set_yscale('log')
|
||||
axes[0, 1].legend()
|
||||
|
||||
# 最佳 LMS 结果
|
||||
_, err_lms, w_hist = lms_filter(far_end, mic_signal, filter_len, 0.01)
|
||||
axes[1, 0].plot(t, mic_signal, color='#9b59b6', linewidth=0.5, alpha=0.4,
|
||||
label='消除前')
|
||||
axes[1, 0].plot(t, err_lms, color='#3498db', linewidth=0.5, alpha=0.8,
|
||||
label='LMS 消除后')
|
||||
axes[1, 0].plot(t, near_end, color='#27ae60', linewidth=0.8, alpha=0.5,
|
||||
label='真实近端')
|
||||
axes[1, 0].set_title('LMS 回声消除结果 (mu=0.01)')
|
||||
axes[1, 0].set_xlabel('时间 (s)')
|
||||
axes[1, 0].set_ylabel('幅度')
|
||||
axes[1, 0].legend(fontsize=8)
|
||||
|
||||
# NLMS 结果
|
||||
_, err_nlms = nlms_filter(far_end, mic_signal, filter_len, 0.5)
|
||||
axes[1, 1].plot(t, mic_signal, color='#9b59b6', linewidth=0.5, alpha=0.4,
|
||||
label='消除前')
|
||||
axes[1, 1].plot(t, err_nlms, color='#f39c12', linewidth=0.5, alpha=0.8,
|
||||
label='NLMS 消除后')
|
||||
axes[1, 1].plot(t, near_end, color='#27ae60', linewidth=0.8, alpha=0.5,
|
||||
label='真实近端')
|
||||
axes[1, 1].set_title('NLMS 回声消除结果 (mu=0.5)')
|
||||
axes[1, 1].set_xlabel('时间 (s)')
|
||||
axes[1, 1].set_ylabel('幅度')
|
||||
axes[1, 1].legend(fontsize=8)
|
||||
|
||||
plt.tight_layout()
|
||||
plt.show()
|
||||
|
||||
# 测量回声衰减
|
||||
echo_power = jnp.mean(echo ** 2)
|
||||
lms_residual = jnp.mean(err_lms[n_samples//2:] ** 2) # 收敛后
|
||||
nlms_residual = jnp.mean(err_nlms[n_samples//2:] ** 2)
|
||||
print(f"回声功率: {10*jnp.log10(echo_power):.1f} dB")
|
||||
print(f"LMS 残差: {10*jnp.log10(lms_residual):.1f} dB "
|
||||
f"(ERLE: {10*jnp.log10(echo_power/lms_residual):.1f} dB)")
|
||||
print(f"NLMS 残差: {10*jnp.log10(nlms_residual):.1f} dB "
|
||||
f"(ERLE: {10*jnp.log10(echo_power/nlms_residual):.1f} dB)")
|
||||
```
|
||||
|
||||
- **任务 4:用于语音增强的时频掩蔽。** 实现一个简单的频谱掩蔽方法(理想比率掩蔽),并将其与谱减法进行比较,在合成的带噪语音信号上可视化分离质量。
|
||||
|
||||
```python
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
import jax.random as jr
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
# 创建合成的"语音"和"噪声"信号
|
||||
sr = 8000
|
||||
duration = 2.0
|
||||
t = jnp.linspace(0, duration, int(sr * duration))
|
||||
|
||||
# 语音:具有时变幅度的谐波序列(模拟语音)
|
||||
speech = jnp.zeros_like(t)
|
||||
for f0 in [150, 300, 450, 600, 900]:
|
||||
amp_env = 0.5 + 0.5 * jnp.sin(2 * jnp.pi * 2.0 * t) # 2 Hz 调制
|
||||
speech = speech + (0.5 / (f0/150)) * amp_env * jnp.sin(2 * jnp.pi * f0 * t)
|
||||
speech = speech / jnp.max(jnp.abs(speech))
|
||||
|
||||
# 噪声:限带噪声
|
||||
key = jr.PRNGKey(42)
|
||||
noise_raw = jr.normal(key, t.shape) * 0.4
|
||||
|
||||
# 在给定 SNR 下混合
|
||||
snr_db = 5.0
|
||||
speech_power = jnp.mean(speech ** 2)
|
||||
noise_power = jnp.mean(noise_raw ** 2)
|
||||
noise_scale = jnp.sqrt(speech_power / (noise_power * 10 ** (snr_db / 10)))
|
||||
noise = noise_raw * noise_scale
|
||||
mixture = speech + noise
|
||||
|
||||
# STFT
|
||||
n_fft = 512
|
||||
hop = 128
|
||||
window = jnp.hanning(n_fft)
|
||||
|
||||
def stft(signal, n_fft, hop, window):
|
||||
n_frames = 1 + (len(signal) - n_fft) // hop
|
||||
frames = jnp.stack([
|
||||
signal[i * hop : i * hop + n_fft] * window
|
||||
for i in range(n_frames)
|
||||
])
|
||||
return jnp.fft.rfft(frames, n=n_fft)
|
||||
|
||||
def istft(S, hop, window, length):
|
||||
n_fft = (S.shape[1] - 1) * 2
|
||||
n_frames = S.shape[0]
|
||||
frames = jnp.fft.irfft(S, n=n_fft) * window[None, :]
|
||||
output = jnp.zeros(length)
|
||||
window_sum = jnp.zeros(length)
|
||||
for i in range(n_frames):
|
||||
start = i * hop
|
||||
end = start + n_fft
|
||||
if end <= length:
|
||||
output = output.at[start:end].add(frames[i])
|
||||
window_sum = window_sum.at[start:end].add(window ** 2)
|
||||
window_sum = jnp.maximum(window_sum, 1e-8)
|
||||
return output / window_sum
|
||||
|
||||
S_speech = stft(speech, n_fft, hop, window)
|
||||
S_noise = stft(noise, n_fft, hop, window)
|
||||
S_mix = stft(mixture, n_fft, hop, window)
|
||||
|
||||
mag_speech = jnp.abs(S_speech)
|
||||
mag_noise = jnp.abs(S_noise)
|
||||
mag_mix = jnp.abs(S_mix)
|
||||
phase_mix = jnp.angle(S_mix)
|
||||
|
||||
# 方法 1:理想比率掩蔽(oracle - 理论上限)
|
||||
irm = mag_speech ** 2 / (mag_speech ** 2 + mag_noise ** 2 + 1e-8)
|
||||
S_irm = (irm * mag_mix) * jnp.exp(1j * phase_mix)
|
||||
enhanced_irm = istft(S_irm, hop, window, len(mixture))
|
||||
|
||||
# 方法 2:谱减法
|
||||
# 从前 0.2s 估计噪声(假设为静音段)
|
||||
noise_frames = int(0.2 * sr / hop)
|
||||
noise_est = jnp.mean(mag_mix[:noise_frames] ** 2, axis=0, keepdims=True)
|
||||
alpha = 2.0 # 过减因子
|
||||
beta = 0.02 # 频谱地板
|
||||
mag_sub = jnp.maximum(mag_mix ** 2 - alpha * noise_est, beta * mag_mix ** 2)
|
||||
mag_sub = jnp.sqrt(mag_sub)
|
||||
S_sub = mag_sub * jnp.exp(1j * phase_mix)
|
||||
enhanced_sub = istft(S_sub, hop, window, len(mixture))
|
||||
|
||||
# 方法 3:维纳滤波器
|
||||
snr_est = mag_mix ** 2 / (noise_est + 1e-8)
|
||||
wiener_gain = snr_est / (1 + snr_est)
|
||||
S_wiener = (wiener_gain * mag_mix) * jnp.exp(1j * phase_mix)
|
||||
enhanced_wiener = istft(S_wiener, hop, window, len(mixture))
|
||||
|
||||
# 计算每种方法的 SI-SDR
|
||||
def si_sdr(estimate, reference):
|
||||
"""尺度不变信号失真比。"""
|
||||
ref = reference[:len(estimate)]
|
||||
est = estimate[:len(reference)]
|
||||
s_target = (jnp.dot(est, ref) / (jnp.dot(ref, ref) + 1e-8)) * ref
|
||||
e_noise = est - s_target
|
||||
return 10 * jnp.log10(jnp.dot(s_target, s_target) /
|
||||
(jnp.dot(e_noise, e_noise) + 1e-8))
|
||||
|
||||
si_sdr_mix = si_sdr(mixture, speech)
|
||||
si_sdr_irm_val = si_sdr(enhanced_irm, speech)
|
||||
si_sdr_sub_val = si_sdr(enhanced_sub, speech)
|
||||
si_sdr_wiener_val = si_sdr(enhanced_wiener, speech)
|
||||
|
||||
# 可视化
|
||||
fig, axes = plt.subplots(3, 2, figsize=(14, 12))
|
||||
|
||||
# 语谱图
|
||||
axes[0, 0].imshow(jnp.log1p(mag_speech.T), aspect='auto', origin='lower',
|
||||
cmap='magma')
|
||||
axes[0, 0].set_title('干净语音语谱图')
|
||||
axes[0, 0].set_ylabel('频率 bin')
|
||||
|
||||
axes[0, 1].imshow(jnp.log1p(mag_mix.T), aspect='auto', origin='lower',
|
||||
cmap='magma')
|
||||
axes[0, 1].set_title(f'带噪混合 ({snr_db:.0f} dB SNR)')
|
||||
|
||||
# 掩蔽
|
||||
axes[1, 0].imshow(irm.T, aspect='auto', origin='lower', cmap='RdYlGn')
|
||||
axes[1, 0].set_title('理想比率掩蔽(Oracle)')
|
||||
axes[1, 0].set_ylabel('频率 bin')
|
||||
|
||||
axes[1, 1].imshow(wiener_gain.T, aspect='auto', origin='lower', cmap='RdYlGn',
|
||||
vmin=0, vmax=1)
|
||||
axes[1, 1].set_title('估计的维纳增益')
|
||||
|
||||
# 增强后的波形对比
|
||||
n_show = 3000
|
||||
axes[2, 0].plot(t[:n_show], speech[:n_show], color='#27ae60', linewidth=0.8,
|
||||
alpha=0.5, label='干净')
|
||||
axes[2, 0].plot(t[:n_show], mixture[:n_show], color='#e74c3c', linewidth=0.5,
|
||||
alpha=0.4, label='带噪')
|
||||
axes[2, 0].plot(t[:n_show], enhanced_irm[:n_show], color='#3498db',
|
||||
linewidth=0.8, label='IRM 增强')
|
||||
axes[2, 0].set_title('波形对比(IRM)')
|
||||
axes[2, 0].set_xlabel('时间 (s)')
|
||||
axes[2, 0].set_ylabel('幅度')
|
||||
axes[2, 0].legend(fontsize=8)
|
||||
|
||||
# SI-SDR 柱状图
|
||||
methods = ['混合信号', '谱减法', '维纳滤波器', '理想比率掩蔽']
|
||||
sdr_values = [float(si_sdr_mix), float(si_sdr_sub_val),
|
||||
float(si_sdr_wiener_val), float(si_sdr_irm_val)]
|
||||
bar_colors = ['#e74c3c', '#f39c12', '#9b59b6', '#27ae60']
|
||||
bars = axes[2, 1].bar(methods, sdr_values, color=bar_colors, alpha=0.8)
|
||||
axes[2, 1].set_ylabel('SI-SDR (dB)')
|
||||
axes[2, 1].set_title('增强质量对比')
|
||||
for bar, val in zip(bars, sdr_values):
|
||||
axes[2, 1].text(bar.get_x() + bar.get_width()/2., bar.get_height() + 0.3,
|
||||
f'{val:.1f}', ha='center', fontsize=10)
|
||||
axes[2, 1].axhline(0, color='gray', linestyle='--', linewidth=0.8)
|
||||
|
||||
plt.tight_layout()
|
||||
plt.show()
|
||||
|
||||
print(f"SI-SDR(带噪混合): {si_sdr_mix:.2f} dB")
|
||||
print(f"SI-SDR(谱减法): {si_sdr_sub_val:.2f} dB")
|
||||
print(f"SI-SDR(维纳滤波器): {si_sdr_wiener_val:.2f} dB")
|
||||
print(f"SI-SDR(理想比率掩蔽): {si_sdr_irm_val:.2f} dB(oracle 理论上限)")
|
||||
```
|
||||
Reference in New Issue
Block a user