说话人与音频分析


文档摘要

说话人与音频分析 说话人与音频分析识别谁在说话、何时说话,以及存在哪些非语音声音。本文件涵盖说话人确认与辨认、i-vector、d-vector、x-vector、说话人分离、音频事件分类、音乐信息检索以及语音情感识别。 在第 1 个文件中我们构建了信号处理基础:频谱图、MFCC 和梅尔滤波器组。在第 2 个文件中我们识别了说了什么。现在我们问是谁说的、何时说的,以及音频中还在发生什么。说话人识别、说话人分离、音频分类和音乐分析共享一条共同主线:学习紧凑的嵌入,以捕捉手头任务所需的正确不变性,呼应第 6 章中的嵌入思想。 可以把识别说话人想象成在电话里认出朋友的声音。你不需要听懂词句;关于音色、节奏和声音品质的某些东西对那个人来说是独一无二的。

说话人与音频分析

说话人与音频分析识别谁在说话、何时说话,以及存在哪些非语音声音。本文件涵盖说话人确认与辨认、i-vector、d-vector、x-vector、说话人分离、音频事件分类、音乐信息检索以及语音情感识别。

  • 在第 1 个文件中我们构建了信号处理基础:频谱图、MFCC 和梅尔滤波器组。在第 2 个文件中我们识别了说了什么。现在我们问是谁说的、何时说的,以及音频中还在发生什么。说话人识别、说话人分离、音频分类和音乐分析共享一条共同主线:学习紧凑的嵌入,以捕捉手头任务所需的正确不变性,呼应第 6 章中的嵌入思想。

  • 可以把识别说话人想象成在电话里认出朋友的声音。你不需要听懂词句;关于音色、节奏和声音品质的某些东西对那个人来说是独一无二的。说话人识别系统学习从原始音频中精确提取这种"声纹",忽略说了什么,专注于怎么说的。

  • 说话人识别是两个相关任务的统称术语:

    • 说话人确认(Speaker Verification,SV):给定一个声称的身份和一段音频片段,判断说话人是否就是其声称的那个人。这是一个二元决策(接受或拒绝),是语音身份认证背后的技术("嘿 Siri,这是我的声音吗?")。
    • 说话人辨认(Speaker Identification,SI):给定一段音频片段和一组已知说话人画廊,判断是哪个说话人产生了该片段。这是一个多类分类问题。

说话人确认:注册音频被嵌入,测试音频被嵌入,计算嵌入之间的余弦相似度,阈值决定接受或拒绝

  • 两个任务共享同一个底层表示:一个固定维度的说话人嵌入,无论说什么都能捕捉说话人身份。区别只在于决策阶段:确认比较两个嵌入,辨认在候选中找最近的嵌入。

  • 余弦相似度是比较说话人嵌入的标准度量。给定注册嵌入 e 和测试嵌入 t

s = \frac{e \cdot t}{\|e\| \, \|t\|}
  • 阈值 \theta 决定接受/拒绝决策:如果 s > \theta,接受。该阈值在错误接受率(False Acceptance Rate,FAR)和错误拒绝率(False Rejection Rate,FRR)之间权衡。等错误率(Equal Error Rate,EER),即 FAR = FRR 处的点,是标准评估指标。EER 越低性能越好。最优系统在标准基准(VoxCeleb)上达到低于 1% 的 EER。

  • i-vector(Dehak 等,2010)曾是深度学习之前占主导地位的说话人嵌入。其思想来自因子分析(第 2 章的矩阵分解和第 4 章的降维)。一个通用背景模型(Universal Background Model,UBM),一个在多样化说话人上训练的大型 GMM,定义了一个超向量空间。每个话语的 GMM 超向量被投影到一个低维的总变差空间

M = m + Tw
  • 其中 M 是该话语的 GMM 超向量,m 是 UBM 均值超向量,T 是总变差矩阵(从数据中学习),w 是 i-vector,一个低维(通常 400-600)表示,同时捕捉说话人和通道变差。

  • 为了从 i-vector 中去除通道变差,概率线性判别分析(Probabilistic Linear Discriminant Analysis,PLDA)把 i-vector 建模为说话人特定和通道特定潜变量之和。PLDA 为确认提供一个有原则的对数似然比得分:

\text{score}(w_1, w_2) = \log \frac{P(w_1, w_2 \mid \text{same speaker})}{P(w_1 \mid \text{speaker}_1) \, P(w_2 \mid \text{speaker}_2)}
  • d-vector(Variani 等,2014)是第一个神经说话人嵌入。一个在帧级特征上为说话人分类训练的 DNN,通过对一个话语中所有帧的最后一层隐藏层激活取平均,提取出固定维表示。简单但有效,d-vector 证明了神经网络能够在没有 i-vector 复杂统计机制的情况下学习具有说话人区分性的特征。

  • x-vector(Snyder 等,2018)使用时延神经网络(Time Delay Neural Network,TDNN)架构显著推进了神经说话人嵌入。TDNN 是具有特定上下文窗口的一维卷积,与第 3 个文件 WaveNet 的膨胀卷积相关,但作用于帧级特征而非原始波形样本。

x-vector 架构:TDNN 层以越来越宽的上下文处理帧级特征,统计池化跨时间聚合,全连接层产生说话人嵌入

  • x-vector 架构有三个阶段:
    • 帧级层:一堆 TDNN 层以逐渐变宽的时间上下文处理 MFCC(来自第 1 个文件)。每一层看到固定上下文窗口(例如第一层为 \{t-2, t-1, t, t+1, t+2\},后续层更宽)。
    • 统计池化:在帧级层之后,对整个话语的帧级输出计算均值和标准差,产生一个与话语长度无关的固定维向量:
\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-vector 嵌入。
  • x-vector 用标准的交叉熵损失在说话人身份上训练。尽管是为分类训练的,学习到的中间表示(x-vector)对未见过的说话人也能很好泛化,因为网络学会了提取具有说话人区分性的特征,而不是记忆特定说话人。

  • ECAPA-TDNN(Desplanques 等,2020)是当前最先进的基于 TDNN 的说话人识别架构。它相对 x-vector 引入了三项改进:

    • 挤压-激励(SE)块:通道注意力(来自第 8 章的 SENet),基于全局上下文重新加权特征通道,使模型能够强调与说话人相关的通道。
    • Res2Net 风格的多尺度特征:在每个 TDNN 块内,通道被分成若干组,以层次方式处理,在多个时间分辨率上创建特征(类似第 8 章的多尺度特征提取)。
    • 注意力统计池化:不是等权平均,而是一个注意力机制对每帧在池化统计中的贡献进行加权。具有更多说话人区分性内容的帧(如元音,承载更多说话人信息)获得更高的注意力权重:
\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,Additive Angular Margin 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)。这种损失来自人脸识别(第 8 章的 ArcFace),对说话人确认非常有效。

  • 说话人分离回答多人录音中"谁在何时说话"。可以把它想象成给时间轴上色:每种颜色代表一个不同的说话人,系统必须判断每个说话人何时活跃,包括重叠的语音。

说话人分立:音频时间轴被分段并标注说话人身份,显示轮流发言和重叠区域

  • 基于聚类的说话人分离是传统的流水线方法:

    • 分段:使用滑动窗口或说话人变化检测把音频分成短段(通常 1-2 秒)。
    • 嵌入提取:为每一段提取一个说话人嵌入(x-vector、ECAPA-TDNN)。
    • 聚类:按说话人把段分组。凝聚层次聚类(Agglomerative Hierarchical Clustering,AHC)是标准做法:从每段自成一类开始,然后迭代地合并两个最相似的类,直到满足停止准则(基于距离阈值或目标说话人数)。
    • 重分段:使用基于维特比的重新对齐来细化边界。
  • 说话人的数量通常是先验未知的,这使得该问题比标准聚类更难。另一种常用方法是用特征值阈值确定 k 的谱聚类。

  • 端到端神经说话人分立(End-to-End Neural Diarisation,EEND)(Fujita 等,2019)把说话人分立构架为一个多标签分类问题。一个神经网络(通常是基于自注意力的模型,第 7 章的 Transformer)把整个录音作为输入,并为每帧的每个说话人输出一个二元活动标签。这直接处理了重叠语音,而这是基于聚类方法的主要弱点。

  • S 个说话人在帧 t 处的 EEND 输出为:

\hat{y}_{t,s} = \sigma(f_s(h_t))
  • 其中 h_t 是帧 t 处的 Transformer 输出,f_s 是说话人 s 的线性投影。训练损失是在所有说话人和帧上求和的二元交叉熵。一个关键挑战是,说话人的数量必须是固定的,或者用可变输出的架构来处理(EEND-EDA 使用带吸引子的编码器-解码器)。

  • 置换不变训练(Permutation Invariant Training,PIT)用于说话人分立,处理标签分配歧义问题:由于说话人没有内在的顺序,损失对所有可能的说话人到输出的分配都计算并取最小值(这与第 5 个文件中讲到的源分离中使用的 PIT 相同)。

  • 音频分类为整个音频片段分配一个标签。与转录语音的 ASR(第 2 个文件)不同,音频分类覆盖更广的范围:环境声(警笛、雨声、狗叫)、音乐流派(摇滚、爵士、古典)以及一般音频事件。

  • 标准方法遵循第 8 章的图像分类范式:把音频表示为频谱图(一个二维时频图像),然后应用 CNN 或 Transformer 分类器。这种谱图像方法利用了计算机视觉领域数十年的进展。

  • 环境声分类(Environmental Sound Classification,ESC)使用诸如 ESC-50(50 类,2000 个片段)和 UrbanSound8K 等数据集。典型架构是应用于对数梅尔频谱图的 CNN(第 6 章)。数据增强至关重要:时间拉伸、音高平移、添加背景噪声以及 SpecAugment(第 2 个文件中应用于频谱图的掩码方法)都能提升泛化能力。

  • 音频事件检测(Sound Event Detection,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} 是时刻 tc 的帧级 logit,\alpha_{t,c} 是注意力权重。片段级预测 \hat{Y}_c 与片段级标签进行训练。

  • 声学场景分类(Acoustic Scene Classification,ASC)对整体环境进行分类:"机场"、"公园"、"地铁站"、"办公室"。这是一个整体性任务:模型必须捕捉一般的声学纹理而非特定事件。DCASE 挑战赛系列每年对 ASC 进行基准测试,获胜系统通常在多分辨率频谱图上使用 CNN 集成。

  • 音频嵌入是从大规模音频数据中学习的通用表示,类似于词嵌入(第 7 章)或图像特征(第 8 章),可迁移到下游任务。

  • VGGish(Hershey 等,2017)把 VGG 图像分类网络(第 8 章)适配到音频。它通过一个在 AudioSet 上预训练的类 VGG CNN 处理 0.96 秒的对数梅尔频谱图块,每个块产生一个 128 维嵌入。VGGish 嵌入作为下游任务的通用音频特征,类似于 ImageNet 预训练的 CNN 提供视觉特征。

  • PANNs(Pre-trained Audio Neural Networks,Kong 等,2020)是一系列在完整 AudioSet 上为音频打标签训练的 CNN 架构(CNN6、CNN10、CNN14)。使用最广泛的 CNN14 是一个 14 层 CNN,使用 3 \times 3 卷积作用于对数梅尔频谱图。PANNs 产生 2048 维嵌入,在多样化的音频任务上实现了最优的迁移学习。

  • 音频频谱图 Transformer(Audio Spectrogram Transformer,AST)(Gong 等,2021)把视觉 Transformer(ViT,第 8 章)架构直接应用于音频频谱图。频谱图被切成 16 \times 16 的块(就像 ViT 切分图像),每个块线性投影为一个 token 嵌入,加入位置嵌入,然后由标准 Transformer 编码器(第 7 章)处理序列。使用 [CLS] token 的输出进行分类。

音频频谱图 Transformer:梅尔频谱图被分成块,每个块被展平并线性投影为一个 token,加入位置嵌入,Transformer 编码器通过 CLS token 产生分类输出

  • AST 受益于 ImageNet 预训练:由于频谱图是二维图像,AST 从在 ImageNet 图像上预训练的 ViT 初始化,然后在音频上微调。这种跨模态迁移出奇地有效,因为两个域共享低级特征(边缘、纹理),并且位置嵌入可以插值以处理不同的频谱图大小。

  • HTS-AT(Chen 等,2022)用层次化 Swin Transformer 架构(第 8 章的移位窗口注意力)改进了 AST,通过多尺度特征提取降低计算成本同时提升性能。

  • BEATs(Chen 等,2023)使用一种音频特有的预训练策略:带离散分词器的迭代掩码预测(类似于第 2 个文件 wav2vec 2.0 的方法,但应用于一般音频)。分词器被逐步细化,产生越来越具语义的离散音频 token。

  • 基于嵌入的说话人分立结合了说话人嵌入与时间建模。诸如 Pyannote.audio 等现代系统使用三阶段流水线:(1) 一个神经分段模型检测说话人轮替和重叠语音,(2) 一个嵌入提取阶段(ECAPA-TDNN)应用于每个检测到的段,(3) 聚类以在整个录音中分配说话人身份。

  • 音乐信息检索(Music Information Retrieval,MIR)把音频分析应用于音乐。第 1 个文件中的频谱表示在这里特别有用,因为音乐有丰富的谐波结构。

  • 节拍跟踪检测音乐的节奏脉冲。标准方法从频谱图计算一个起音强度包络(检测标志音符起音的能量增加),然后用自相关或时间图找到节拍速度,最后用动态规划跟踪各个节拍位置,找到既最佳匹配起音包络又保持一致速度的节拍时间序列。

  • 和弦识别识别随时间变化的和声内容。输入通常是一个色度图(chromagram,也称音高类轮廓):一个 12 维表示,把所有八度折叠在一起,显示 12 个音高类(C、C#、D、...、B)中每一个的能量。一个 CNN 或 RNN(第 6 章)把每个时间帧分类为标准和弦标签之一(C 大调、A 小调、G7 等)。

  • 色度图从 STFT(第 1 个文件)计算,把每个频率 bin 映射到其音高类:

\text{chroma}(p) = \sum_{k : \text{pitch}(k) \bmod 12 = p} |X(k)|^2
  • 其中 p \in \{0, 1, \ldots, 11\} 是音高类,\text{pitch}(k) 把频率 bin k 映射到其 MIDI 音符号。

  • 源分离基础(在第 5 个文件中详述)把音乐录音分离成单个乐器(人声、鼓、贝斯、其他)。这对混音、卡拉 OK 和音乐转录等 MIR 应用是核心的。诸如 Demucs(第 5 个文件)等模型在标准 MUSDB18 基准上实现了相当好的分离质量。

  • 音乐标签为歌曲分配标签(流派、情绪、乐器、年代)。它本质上是应用于音乐的音频分类,使用相同的频谱图上 CNN 方法。百万歌曲数据集和 MagnaTagATune 是标准基准。

  • 音频指纹从短片段识别一个特定录音,即使存在噪声、混响或压缩伪影。经典系统是 Shazam,它对星座点(频谱图中的显著峰值)进行哈希。神经方法学习对声学退化保持不变但在不同录音之间仍具有区分性的鲁棒嵌入,呼应第 6 章和第 8 章的不变特征学习。

编程练习(使用 CoLab 或 notebook)

  • 任务 1:带统计池化的说话人嵌入提取。 构建一个简单的 x-vector 风格模型,通过 TDNN 层和统计池化处理帧级特征以产生说话人嵌入。
import jax import jax.numpy as jnp import jax.random as jr import matplotlib.pyplot as plt # 模拟多个说话人的帧级 MFCC 特征 def generate_speaker_data(key, n_speakers=5, utterances_per_speaker=20, n_frames=100, n_features=40): """生成带有说话人相关模式的合成说话人数据。""" keys = jr.split(key, 3) all_features = [] all_labels = [] # 每个说话人有一个特征性的频谱模式 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 风格模型 def init_xvector(key, n_features=40, hidden=128, embed_dim=64, n_speakers=5): keys = jr.split(key, 8) params = { # TDNN 层 1:上下文 [-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 层 2:上下文 [-2, 2] 'tdnn2_w': jr.normal(keys[1], (5, hidden, hidden)) * jnp.sqrt(2.0 / (5 * hidden)), 'tdnn2_b': jnp.zeros(hidden), # TDNN 层 3:上下文 [-3, 3] 'tdnn3_w': jr.normal(keys[2], (7, hidden, hidden)) * jnp.sqrt(2.0 / (7 * hidden)), 'tdnn3_b': jnp.zeros(hidden), # 段级层(池化后: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), # 分类头 '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 或 embeddings。""" # TDNN 层(一维卷积) 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) # 统计池化:对时间取均值和标准差 mu = jnp.mean(h, axis=1) sigma = jnp.std(h, axis=1) pooled = jnp.concatenate([mu, sigma], axis=-1) # 段级层 -> 嵌入 embedding = jax.nn.relu(pooled @ params['seg1_w'] + params['seg1_b']) if return_embedding: return embedding # 分类 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)) # 训练 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)) # 提取嵌入并用 t-SNE 风格的 2D 投影(使用 PCA)可视化 embeddings = xvector_forward(params, features, return_embedding=True) # 简单 PCA 降到 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() # 确认演示:余弦相似度 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 曲线的确认系统。
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): """生成说话人嵌入和确认试验对。""" keys = jr.split(key, 5) # 带有一些方差的说话人质心 centroids = jr.normal(keys[0], (n_speakers, dim)) centroids = centroids / jnp.linalg.norm(centroids, axis=-1, keepdims=True) # 生成注册和测试嵌入,带有说话人内方差 enroll_embs = [] test_embs = [] trial_labels = [] # 1 = 同一说话人(目标),0 = 不同(冒充者) 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) # 计算余弦相似度得分 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) # 在各种阈值下计算 FAR 和 FRR 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) # 错误接受 frr = jnp.mean(target_scores < thresh) # 错误拒绝 fars.append(float(far)) frrs.append(float(frr)) fars = jnp.array(fars) frrs = jnp.array(frrs) # 找 EER: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)) # 得分分布 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 对 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 曲线(FAR 对 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 的块提取和嵌入层,可视化频谱图如何被分词。
import jax import jax.numpy as jnp import jax.random as jr import matplotlib.pyplot as plt # 生成合成频谱图(谐波结构 + 噪声) def generate_spectrogram(key, n_time=128, n_freq=128): """创建带谐波模式的合成频谱图。""" k1, k2 = jr.split(key) spec = jr.normal(k1, (n_time, n_freq)) * 0.1 # 添加谐波带(模拟语音共振峰) 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_h = 16 # 时间 patch_w = 16 # 频率 stride_h = 16 stride_w = 16 embed_dim = 192 # ViT-Small 维度 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}") # 提取块 def extract_patches(spec, patch_h, patch_w, stride_h, stride_w): """从频谱图提取不重叠的块。""" 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) # 线性投影(块嵌入) 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) # 可学习的位置嵌入 pos_embed = jr.normal(k2, (n_patches + 1, embed_dim)) * 0.02 # +1 给 CLS # CLS token cls_token = jnp.zeros((1, embed_dim)) # 前向传播 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 # 添加位置嵌入 print(f"Token sequence shape: {tokens.shape}") print(f"Each token has dimension: {embed_dim}") # 可视化 fig, axes = plt.subplots(2, 2, figsize=(14, 10)) # 带块网格的原始频谱图 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') # 单个块可视化 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 嵌入相似度矩阵 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) # 位置嵌入相似度 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:用于和弦分析的简单色度图计算。 从合成谐波信号计算并可视化色度图,演示音乐信息检索中使用的音高类折叠。
import jax import jax.numpy as jnp import matplotlib.pyplot as plt # 生成合成音乐信号:C 大调和弦 -> G 大调和弦 sr = 16000 duration = 2.0 t = jnp.linspace(0, duration, int(sr * duration)) # 前半段 C 大调(C4=261.6, E4=329.6, G4=392.0) # 后半段 G 大调(G3=196.0, B3=246.9, D4=293.7) 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]) # 计算 STFT n_fft = 4096 # 高分辨率以保证音高精度 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) # 通过把频率 bin 映射到音高类来计算色度图 # 从频率得到 MIDI 音符号:69 + 12 * log2(f / 440) note_names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] def freq_to_chroma(freq): """把频率映射到音高类(0-11)。对 freq <= 0 返回 -1。""" midi = 69 + 12 * jnp.log2(jnp.clip(freq, 1e-10, None) / 440.0) return jnp.round(midi).astype(int) % 12 # 构建色度图:对每个音高类累加功率谱能量 chromagram = jnp.zeros((power_spec.shape[0], 12)) valid_freqs = freqs[1:] # 跳过直流分量 valid_power = power_spec[:, 1:] for p in range(12): # 找到属于该音高类的频率 bin 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) ) # 每帧归一化 chromagram = chromagram / (jnp.max(chromagram, axis=1, keepdims=True) + 1e-8) # 可视化 fig, axes = plt.subplots(3, 1, figsize=(14, 10)) # 波形 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() # 频谱图(对数刻度) 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)') # 色度图 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') # 标注预期的活跃音高类 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()

作者与出处
原作者: HenryNdubuaku
来源:HenryNdubuaku
许可证:Apache-2.0
整理: 灏天文库整理
由灏天文库结构化整理,提供目录导航、全文检索与在线阅读,便于系统化学习
发布者: 作者: HenryNdubuaku 转发
评论区 (0)
U