最近在做一个音频指纹相关算法的项目,深入研究了一下这个领域的一些方案并进行了优化和改进, 感觉还是很有意思。
Introduction
音频指纹检索: 从已有的音频库(anchor)中检索出给定音频(target),可能也需要标出 target 位于 anchor 的相对位置 (起始时间戳)
音频指纹检索任务,通常用于 music/bgm 领域,来检索是否使用了侵权的音乐片段。
我们工作中遇到的其实是一个流式的使用场景: 给定一段音频作为anchor,在实时音频流(target)中,检测首次出现指定音频片段的起始时间点。
和通用任务相比,会面临一些额外的挑战:
- 检测准确率要求较高,精度要求 100ms 以内,延时不超过 10s
- 可能存在噪声干扰/信号编码损失,target 和 anchor 不一定完全对应。
- 实时音频流为输入,可能存在一定概率的丢帧和乱序 (直播场景)
下面会重点介绍一下音频指纹的相关原理和改进方案。
已有成熟的方案
调研了目前已有的成熟方案
- shazam
- dejavu, 包括找到了其python 版本的实现,并在此基础上进行了适当简化,作为我们算法的 baseline
- itspoma
- nn-finger_print, 神经网络的方案,例如: NEURAL AUDIO FINGERPRINT FOR HIGH-SPECIFIC AUDIO RETRIEVAL BASED ON CONTRASTIVE LEARNING
原理上并不复杂,大体上无非就是,提取finger/搜索/排序等几个步骤, 核心是鲁棒性和计算效率的平衡。
改动方案
这里侧重讲下本算法在 dejavu 基础上的改进。
编码、采样与频谱提取
虽然大部分音乐的采样率都是 44100 Hz,但我们统一将音频降采样到 8000 Hz,这样我们只需要专注该case, 不需要去考虑类似”测试音频采样率小于anchor音频”的情况。事实上,8kHz 的采样率对于该问题足够了。
和大多数音频问题一样,我们选择在频谱上处理该问题。我们使用 fft 对每个小窗进行频谱提取, 但我们不会像其他语音问题那样,选择fbank or MFCC。我们选择 1024 和 80 作为帧长和帧移。 前者但原因是为了更快进行傅立叶变换,后者是希望能够在较细粒度上捕捉到频谱特征,从而获得更高的准确率。
指纹提取
对于音频来说,我们和 dejavu 一样,选取了频谱上出现最大值的位置(而非最大值本身)作为音频指纹特征。 但我们采样了另一种方式来处理数据。
dejavu 使用 scipy 寻找频谱上的 2D peaks,合适的mask选取,使得相邻peaks之间的间距不会太近。同时, dejavu 抹掉了绝对值过低的peaks。下图是一个例子,可以看出某些静音段可能没有对应的peaks。

获得peaks后,dejavu 选择距离不太远的一组 pairs,保存两个点的freq和时间轴上相对距离作为 fingerprint。 如下图每条黑线两端的点,组成一个 fingerprint,
例如 (318, 119), (350, 119) -> (318, 350, 0) or (350, 119), (330, 142) -> (350, 330, 23)

相对的,我们的方案更简单。对每帧的频谱进行子带划分,提取每个子带最大值出现位置。 例如我们将1024维的频谱划分成6个子带:0~100,100~200,200~300,300~400,400~500,500~1024, 每帧的指纹便是一个6 维向量。例如 [5,123,201,370,423,599]
注: shazam 也用了类似的逻辑,只是子带划分的方法有所不同。
更新: 后来我们发现,根据子带进行划分,可能会对子带边缘的指纹点过于敏感。例如,上例中指纹201位于子带边缘, 一个微小绕道使得指纹点频率下移,会同时影响到两个子带对应的指纹点。我们采用了自适应方式来划分子带。 发现性能不变的情况下,鲁棒性有少量提升。
该算法如下:
int max_freq = freq.size();
std::vector<int> finger_print;
int margin = 25; // half subband, small at low-freq and large at high-freq
int start(0), end(margin * 2);
int j = 0;
while (j < 6) {
if (j == 5) end = max_freq; // forced stop to make sure get 6 fp
if (j >= 2) margin = 50; // change margin at high-freq
int idx;
freq.segment(start, end - start).maxCoeff(&idx); // using soft boundary
idx = idx + start;
finger_print.push_back(idx);
start = idx + margin; // update start and end
end = start + margin * 2;
j = start < max_freq ? j+1 : 6; // stop loop
}
指纹比对
dejavu 方法及其他一些开源的方法,将识别音频使用同样的方法提取指纹,并与指纹库进行比对。 选择匹配最多的指纹特征。如果指纹中储存了时间轴绝对信息,也可以根据此获得待识别片段在anchor音频上的时间戳信息。
该方法有一个很大的问题:每个指纹包含信息太少极容易造成触发。如果指纹包含信息太多,又很容易匹配不上,造成漏检。 噪声or音频重放采集会显著加剧漏检的概率。
我们采样了一种”投票”的机制,以期望获得更好的鲁棒性。我们获得 target 音频每帧的指纹,将其和每个 anchor 中每一帧进行匹配, 这里我们设计了一种模糊匹配的方式,来代替精确相等匹配。同一帧可能匹配到 anchor 里的多个帧。 例如下图,
frame6 -> anchor1 frame1(a1fr1)
frame7 -> a1fr2
frame8 -> a1fr4
frame9 -> a1fr4, a1fr5, a2fr3

每个匹配帧,都可以反推一个时间开始的位置。对多个时间结果进行投票,结果最多的即为最终结果。 例如,之前的例子下,匹配音频为anchor1,shift 为5帧,即anchor1的第0帧和target的第5帧重合。
frame6 -> a1fr1 -> anchor1 shift5(a1sh5)
frame7 -> a1fr2 -> a1sh5
frame8 -> a1fr4 -> a1sh4
frame9 -> a1fr4, a1fr5, a2fr3 -> a1sh5, a1sh4, a2sh6个投票落在一个非常广的
voting:
a1fr4: 2, a1fr5: 3, a2sh6:1
实验中观察发现,对于大部分case,我们观察匹配的anchor帧下面,每帧对应的票数呈正态分布 (正态分布的顶点横坐标即为anchor-target的偏移值),而非匹配的anchor帧下面,每帧票数呈均匀分布。 这意味着,大多数噪声,带来的影响只会使正态分布的曲线变缓,而不会改变峰值位置。 因此,该算法基于大量数据(这也是我们为什么选择10ms作为帧移的原因)产生的投票结果可以做到更鲁棒的原因。
存储与搜索
一个成熟的指纹算法,势必要考虑到海量 anchor 带来的存储问题,以及在大规模数据中搜索的时间复杂度。
开始的时候,我的思路是使用了 hash 来进行存储指纹和比对。花了不少时间来设计一套高效的查找算法。 对于每帧上的 6 个特征点,我们将其任意3个组成一个指纹(fp ),总计20个。将其存入 hash 表中,每次新音频进来,对每帧上的 20个fp与hash表进行比对,匹配最多的帧即为目标帧。 虽然每次查找fp时间复杂度都是O(1),但因为可能会存在大量的一组fp对应多个结果的情况。 将这些结果合并可能还需要额外耗时,因此整个算法时间复杂度上依然有较大的提升空间。
后来,和同事得意聊了一下,他建议直接使用向量检索中 ANN 算法 (备注 ANN 是 approximate nearest neighbor,而不是 Artificial Neural Network)来替代手写的 hash 算法。 上网简单调研了之后,选择了 Mariland David 教授撰写的函数库 http://www.cs.umd.edu/~mount/ANN/ 来使用。 集成很容易,效果也很好。比我自己手写的算法快了10倍左右。
当然还有更快的库,例如FaceBook的 Faiss 和 [Milvus] (https://github.com/op-hunter/milvus), 打算后面需要时候可以再替换。
Streaming input
观察这个算法的过程,会发现所有的操作,都是针对单一帧完成的,并不涉及不同帧之间的相互关系。 这使得我们很容易将该算法改成 streaming-mode 模式,每次接收当前帧进行判断。
在很多场景,我们希望尽量早的获得结果。例如,对于某个anchor-target,两者在第5s位置发生匹配。 显然在第6s返回结果,会比第10s返回结果更好。为了能够更早的返回结果,我们使用了一个 trick: 不要等正态曲线分布完全形成再出结果,而是人为手工设计一个阈值,当某帧投票值超过该阈值时,立即返回当前结果。
实际实现时,会遇到另一个工程层面的问题是,当 target 音频非常非常长的时候,可能会导致整个投票落在一个非常广的区间上, 造成计算资源的浪费。我们采用了滑动窗,滑动窗以外的结果我们不予考虑,这样大幅度提高了计算效率。在 cpp 实现中, 借助优先队列,还可以对性能做进一步提升。
丢帧和乱序
丢帧和乱序可能是只在流场景下可能会遇到的问题。因为算法只针对单一帧进行操作,所以并不需要前后帧的相对位置关系, 只需输入当前帧的特征和顺序id即可。因此,算法天然对乱序具备鲁棒性。对于丢帧,只需有足够多的有效帧存在即可。
一个极端的case,一个音频流中每三帧随机丢掉其中两帧,dejavu 的帧相对位置关系大多数会被破坏从而导致失效, 但我们的算法不会。
在实际的项目中,我们算法在这方面的鲁棒性得以验证。
离线场景下准确率/性能测试
因为手边没有特别合适的测试数据,我暂且使用了 fma 数据集 进行测试。
这里 large 集有大概 10k 条 music,每条 30 s。将数据集中每条数据增加3db的随机噪声作为 query
准确率对比 Ours Vs dejavu
我们采用了项目 https://github.com/mimbres/neural-audio-fp 中提到的数据 Dataset-mini v1.1 (11.2 GB) 进行测试。 测试样本一共有486条,每条样本 30s 作为 anchor, 选择前5s作为 待检测音频。测试结果如下
- hit rate: 检测成功率
- detect time: 检测所用时间
hit rate | ours | dejavu |
---|---|---|
486 items | 0.94 | 0.64 |
看上去,我们方法的准确性更高,但是耗时显著更长(这可能也是接下来一个亟待解决的问题)
备注: dejavu 的结果是基于开源项目 https://github.com/worldveil/dejavu 进行改写 在保持结构和参数不动的前提下,使用python dict代替了数据库的基本操作。改写并不能一定确保效果上无损失, 但和论文 NEURAL AUDIO FINGERPRINT 中对 dejavu 引用结果大体一致。
大批量性能测试
我们测试了两个不同规模的数据集效果如下:
hit rate 我们只计算 top1,计算方式和论文 NEURAL AUDIO FINGERPRINT FOR HIGH-SPECIFIC AUDIO RETRIEVAL BASED ON CONTRASTIVE LEARNING 保持一致
AnchorNum [s] | add_anchor time[s] | search time[s] | hit rate |
---|---|---|---|
500 item x 30s | 70 | 0.1 | 1.0 |
10k item x 30s | 900 | 1.0 | 0.9985 |
整体上来看,离线情况下我们模型效果还是比较令人满意的。
流式场景下的测试
我们在这里复述其中两个 case 样例的结果:
- demo2: anchor 为伴奏原声,待检测音频流为星盛典晚会的线上音频,这里认为音频经过播放-采集-传输后已经有了一定程度的失真。
- demo3: anchor 为待检测音频中手工截断的bgm,待检测音频条件同 demo2,但音频内容不同。
看上去 demo2 比 demo3 更难一点。毕竟 anchor 和 target 非同源。对音频进行了加噪(Gauss)处理后,测试如下:
noise[db] | demo2[sp/dt] | demo3[sp/dt] |
---|---|---|
0 db | 228.9/9.2 | 319.9/5.1 |
6 db | 228.9/11.1 | 319.9/5.3 |
20 db | 228.9/10.0 | 319.9/5.1 |
start point/sp: anchor 音频开始的时间 [s]
delay time/dt: 检测到结果距离anchor开始时间的间隔 [s]
备注:实际对齐点位于228.9~229.0 和 319.5~320.0 区间。
鲁棒性测试
为了应对直播场景,我们模拟了各种实际场景来进行鲁棒性相关测试。
误触发测试
为了测试误触发情况,我们在每个 anchor or target 前随机添加 asr 数据 300s, 测试这个随机数据是否会造成算法的误触发。
结论: 目前测试中,没有遇到任何误触发的 badcase,也不存在检测时间点错误的情况。 即所有 badcase 都是由于漏检造成的。
不同噪声音量
这里我们使用分贝值 db 衡量噪声强度。0db 表示噪声强度和人声强度相等,10db 表示噪声强度强于人声10分贝。
我们测试了噪声强度 -40db ~ 20db 的情况。
不同噪声类型
这里我们测试了几种不同的噪声类型
- speech 普通人声
- bgm 音乐背景
- song 主播唱歌,可能有bgm也可能没有
- huya noise 虎牙自建内部噪声集,包括直播中常见噪声类型
- other 开源数据,包括白噪声/咖啡馆噪声/车辆噪声等常见噪声类型
测试中我们会对噪声类型和音量随机遍历,叠加 1~3 种不同噪声,对算法进行测试。
漏检的case也可以分成两种:
A,能够检测出峰值,峰值小于阈值但显著大于其他帧。可能可以通过更精细的调整阈值来规避。
B,完全不能够检测出峰值。
会针对这两种情况分别考虑。
一些结论:
- 0db左右的噪声是能检测出的极限值,-10/-5db可能会相对比较保险
- 噪声种类对结果有影响,影响结果的大小依次为:bgm > song > speech > other noise, bgm 是对识别最大的干扰种类。
- 每隔一段时间取一个小片段作为 anchor,并且使用更多 anchor 片段可以提高系统的鲁棒性。
噪声连续时间
目前算法保证了只要存在足够长度的 anchor 没有被强噪声污染,即可正确检测出结果。 如下图所示,只要虚线之内的长度不太短即可。因此,该算法对非平稳噪声较为鲁棒。

实验证明,目前算法对于 noise(20db) 时间占比 80% 的情况下,依然可以给出正确结果。
混响
目前测过了一些case,包括实际场景录制的效果。测试结果表明,混响下算法的鲁棒性还有待提高。 两个case:
- data/demo5/test_reverb_case1, Successful
- data/demo5/test_reverb_case3, Fails
采样率 or 编解码
我们测试了不同的采样率
采样率压缩测试: 测试了wav/mp3等音频格式,我们测试了44100/22050/16000/8000的音频,结果无差别。 实时上,只要 target 音频采样率不低于 anchor 采样率即可。使用不同的采样率算法(即使是最简单的近邻插值),对结果也没有影响。
比特率压缩测试: 使用 sox 压缩mp3音频为 32kps/128kps,使用不同的压缩质量,结果几乎无影响。
结论
大体上来看,音频指纹检测,不算一个很难的问题。 但这个过程中涉及了音频算法上很多细节的处理,和工程层面性能与效果权衡开销的考虑。
关于投票的想法,自认为还是很巧妙。不过,不确定是不是由于论文读的少导致的。
这个项目中,关于快速检索的方案,自己造了一个简易的轮子,虽然舍不得还是最后用向量检索库替换掉了。 很多时候,闭门造车还是要尽量避免,拓宽知识广度还是很有必要。