音频指纹原理与优化

2021/12/02 语音信号处理 共 6535 字,约 19 分钟 Read:

最近在做一个音频指纹相关算法的项目,深入研究了一下这个领域的一些方案并进行了优化和改进, 感觉还是很有意思。

Introduction

音频指纹检索: 从已有的音频库(anchor)中检索出给定音频(target),可能也需要标出 target 位于 anchor 的相对位置 (起始时间戳)

音频指纹检索任务,通常用于 music/bgm 领域,来检索是否使用了侵权的音乐片段。

我们工作中遇到的其实是一个流式的使用场景: 给定一段音频作为anchor,在实时音频流(target)中,检测首次出现指定音频片段的起始时间点。

和通用任务相比,会面临一些额外的挑战:

  • 检测准确率要求较高,精度要求 100ms 以内,延时不超过 10s
  • 可能存在噪声干扰/信号编码损失,target 和 anchor 不一定完全对应。
  • 实时音频流为输入,可能存在一定概率的丢帧和乱序 (直播场景)

下面会重点介绍一下音频指纹的相关原理和改进方案。

已有成熟的方案

调研了目前已有的成熟方案

原理上并不复杂,大体上无非就是,提取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,使用不同的压缩质量,结果几乎无影响。

结论

大体上来看,音频指纹检测,不算一个很难的问题。 但这个过程中涉及了音频算法上很多细节的处理,和工程层面性能与效果权衡开销的考虑。

关于投票的想法,自认为还是很巧妙。不过,不确定是不是由于论文读的少导致的。

这个项目中,关于快速检索的方案,自己造了一个简易的轮子,虽然舍不得还是最后用向量检索库替换掉了。 很多时候,闭门造车还是要尽量避免,拓宽知识广度还是很有必要。

Search

    Table of Contents

    本站总访问量