最近看到一篇老外写的博客,简单介绍了shazam的工作原理。图非常好,所以就把它翻译成中文,希望对搞听歌识曲的人有帮助。
你可能遇到这样的场景:在酒吧或者餐厅听到你非常熟悉的歌,也许你曾经听过无数次,并且被歌曲忧伤的旋律深深打动。久违之后的邂逅让你依然心动,所以想再次欣赏这首歌,但是却突然不记得名字了!明明就在嘴边,但就是说不出来!这时如果你手机上装有音乐识别软件,那么问题就很容易解决了。你只需要打开软件录一段音乐就好了,然后识别软件就会告诉你歌名,之后你就可以无限畅听直到厌烦为止。
移动技术和音频信号处理技术的发展,使算法工程师有能力设计出音频识别软件。最出名的音频识别app之一是Shazam。如果你有某首歌的一个20s片段,交给shazam之后,shazam会首先提取指纹,然后查询数据库,最后利用其精准的识别算法返回歌名。
shazam是如何工作的呢?其算法核心由Avery Li-Chung Wang发表于03年。在本文中会回顾一下shazam算法的基础和流程。
声音究竟为何物?难道是一种看不见摸不着但是却可以进入我们耳朵的神秘物质?
这当然是holy shit了。从本质上来说,声音是机械波在介质(空气或者水)中的振动。当振动传到我们耳朵特别是鼓膜时,就会进一步通过微小的软骨传到内耳的毛细胞。毛细胞会产生电磁脉冲将信号最终传递到大脑的听觉神经。
录音设备通过模仿人耳的工作原理将声波转换成电子信号。空气中的声音是连续的波形信号,麦克风会将其转化成模拟的连续电压信号。但是该连续的模拟信号在数字世界中用处不大,需要转换成离散的信号,便于存储。我们往往通过捕获特定时刻信号的幅值来将信号数字化。转换需要对输入的模拟信号进行量化,这不可避免地会引入少量错误。所以,为了避免单次转换带来的误差,我们会利用一个模数转换器对一段很小的信号进行多次转换—这个过程也即常说的采样。
奈奎斯特-香农采样定理告诉我们,为了能捕获人类能听到的声音频率,我们的采样速率必须是人类听觉范围的两倍。人类能听到的声音频率范围大约在20Hz到20000Hz之间,所以在录制音频的时候采样率大多是44100Hz。这是大多数标准MPEG-1 的采样率。44100这个值最初来源于索尼,因为它可以允许音频在修改过的视频设备上以25帧(PAL)或者30帧( NTSC)每秒进行录制,而且也覆盖了专业录音设备的20000Hz带宽。所以当你在选择录音的频率时,选择44100Hz就好了。
录音不是什么难事。目前的声卡都有一个模数转换器,所以我们需要做的就是选择自己擅长的编程语言和一个合适的处理库,然后设置采样率、声道数和采样位数就可以开始录音了。如果用java来实现,代码可能会像下面这样:
private AudioFormat getFormat() { float sampleRate = 44100; int sampleSizeInBits = 16; int channels = 1; //mono boolean signed = true; //Indicates whether the data is signed or unsigned boolean bigEndian = true; //Indicates whether the audio data is stored in big-endian or little-endian order return new AudioFormat(sampleRate, sampleSizeInBits, channels, signed, bigEndian); } final AudioFormat format = getFormat(); //Fill AudioFormat with the settings DataLine.Info info = new DataLine.Info(TargetDataLine.class, format); final TargetDataLine line = (TargetDataLine) AudioSystem.getLine(info); line.open(format); line.start();在上述代码中,我们通过TargetDataLine来读取音频数据,读取的代码可能如下(代码中的running变量是一个全局变量,由其他线程控制,例如GUI界面中的STOP按钮):
OutputStream out = new ByteArrayOutputStream(); running = true; try { while (running) { int count = line.read(buffer, 0, buffer.length); if (count > 0) { out.write(buffer, 0, count); } } out.close(); } catch (IOException e) { System.err.println("I/O problems: " + e); System.exit(-1); }
上述字节数组中保存的是时域信号,时域信号表示信号幅值随时间的变化(不过时域信号包含的有用信息比较少)。早在十八世纪初,傅里叶就有了一个惊人发现:任何时域上的信号都可以等价为多个(也可能是无穷多个)简单正弦信号的叠加,每个正弦信号都有不同的频率、幅值和相位。这一系列的正弦函数集合被称为傅里叶级数。
换句话说,我们可以用给定频率、幅值和相位的多个正弦信号构成任意的时域信号。将信号用一系列简单正弦信号表示的方法称为信号的频域表示。从某种角度来说,频域表示可以看做时域信号的指纹或者签名,它给我们提供了一种用静态数据表示动态信号的方法。
下面的动画展示了1Hz square波是如何由多个正弦波叠加构成的。上图展示了时域信号,下图展示了正弦波的频域表示。
来自于:René Schwarz
从频域角度分析信号可以极大地简化问题。在数字信号处理的世界中,频域信号分析更为方便,我们可以通过分析频域来判断某个频率的正弦信号是否存在。在这之后,还可以对信号进行某些频率的过滤,幅值调节,或者基频识别等等。
为了将信号由时域变换到频域,我们需要一个变换工具,这个工具叫做离散傅里叶变换(DFT)。DFT是一种对离散信号进行傅里叶变换的数学工具,它将等间隔采样的信号转换成具有等间隔频率的正弦信号幅值(幅值是用复数表示的)。
计算DFT的方法是FFT,目前最常用的FFT实现是 Cooley–Tukey algorithm。该算法通过分治策略解决DFT问题。直接解决DFT需要O(n2)的复杂度,但是Cooley-Tukey算法采用分治策略后DFT问题的复杂度降到O(n logn)。目前有很多FFT的库,下面列举了一些:
下面的代码展示了如何利用java进行FFT(FFT的输入是复数,理解复数和三角函数的关系需要了解 Euler’s formula):
public static Complex[] fft(Complex[] x) { int N = x.length; // fft of even terms Complex[] even = new Complex[N / 2]; for (int k = 0; k < N / 2; k++) { even[k] = x[2 * k]; } Complex[] q = fft(even); // fft of odd terms Complex[] odd = even; // reuse the array for (int k = 0; k < N / 2; k++) { odd[k] = x[2 * k + 1]; } Complex[] r = fft(odd); // combine Complex[] y = new Complex[N]; for (int k = 0; k < N / 2; k++) { double kth = -2 * k * Math.PI / N; Complex wk = new Complex(Math.cos(kth), Math.sin(kth)); y[k] = q[k].plus(wk.times(r[k])); y[k + N / 2] = q[k].minus(wk.times(r[k])); } return y; }
下图展示了信号进行FFT前后的变化:
FFT的一个很大缺陷是我们丢失了原始信号的时间信息(虽然理论上我们可以获得时间信息,但是代价非常大)。例如,对一个3分钟长的音乐来说,我们傅里叶变换之后看到的只是一系列频率和频率的幅值,而无法知道这些频率在音乐的什么位置出现。但是这些位置信息非常重要,因为正是这些频率位置决定了这首歌!
所以我们需要引入另一项技术:滑动窗口。滑动窗口只对一块原始信号进行傅里叶变换。数据块的大小可以通过多种方式确定。例如,我们录制了一段音乐,双声道,16-bit精度,44100Hz采样。这时1s的数据大小为44100*2byte*2声道≈176kB。如果选择4kB当作数据块大小,则每秒钟我们需要对44块数据进行傅里叶变换。这样的切分密度足以应对大多数需求。
下面对分块的数据进行傅里叶变换:
byte audio [] = out.toByteArray() int totalSize = audio.length int sampledChunkSize = totalSize/chunkSize; Complex[][] result = ComplexMatrix[sampledChunkSize][]; for(int j = 0;i < sampledChunkSize; j++) { Complex[chunkSize] complexArray; for(int i = 0; i < chunkSize; i++) { complexArray[i] = Complex(audio[(j*chunkSize)+i], 0); } result[j] = FFT.fft(complexArray); }
代码的内层循环将采样数据放入一个复数数组中(虚部为0),外层循环遍历每一块数据,并进行FFT变换。
当我们对每一帧音频信号进行傅里叶变换之后,就可以开始构造音频指纹了,这是shazam整个系统中最核心的部分。构造指纹最大的挑战在于怎样从众多频率中选出区分度最大的来。直观上来说,选择具有最大幅值的频率(峰值)较为靠谱。
令人失望的一点是,幅值较大的频率跨度可能很广,从低音C(32.70Hz)到高音C(4186.01Hz)都可能出现。为了避免分析整个频谱,我们通常将频谱分成多个子带,从每个子带中选择一个频率峰值。在CreatingShazam in Java博客中,作者选择了如下几个子带:低音子带为30 Hz - 40 Hz, 40 Hz - 80 Hz 和80 Hz - 120 Hz (贝司吉他等乐器的基频会出现低音子带),中音和高音子带分别为120 Hz - 180 Hz 和180 Hz - 300Hz(人声和大部分其他乐器的基频出现在这两个子带)。每个子带的最大频率就构成了这一帧信号的签名,而这个签名又是整首歌指纹的一部分。
public final int[] RANGE = new int[] { 40, 80, 120, 180, 300 }; // find out in which range is frequency public int getIndex(int freq) { int i = 0; while (RANGE[i] < freq) i++; return i; } // result is complex matrix obtained in previous step for (int t = 0; t < result.length; t++) { for (int freq = 40; freq < 300 ; freq++) { // Get the magnitude: double mag = Math.log(results[t][freq].abs() + 1); // Find out which range we are in: int index = getIndex(freq); // Save the highest magnitude and corresponding frequency: if (mag > highscores[t][index]) { points[t][index] = freq; } } // form hash tag long h = hash(points[t][0], points[t][1], points[t][2], points[t][3]); } private static final int FUZ_FACTOR = 2; private long hash(long p1, long p2, long p3, long p4) { return (p4 - (p4 % FUZ_FACTOR)) * 100000000 + (p3 - (p3 % FUZ_FACTOR)) * 100000 + (p2 - (p2 % FUZ_FACTOR)) * 100 + (p1 - (p1 % FUZ_FACTOR)); }
在构造指纹的过程中,我们要特别注意一点:用户所处的环境会非常复杂,所以录制的音频质量差异很大。这就需要我们的算法抗噪能力非常强,有必要在算法中引入模糊化操作。模糊化操作非常重要,其直接影响最终的检索质量。
为了查找方便,指纹通常会作为散列表的键值,键值指向的部分包括该指纹在音乐中出现的时间和该音乐ID。下面是一个例子:
Hash Tag |
Time in Seconds |
Song |
30 51 99 121 195 |
53.52 |
Song A by artist A |
33 56 92 151 185 |
12.32 |
Song B by artist B |
39 26 89 141 251 |
15.34 |
Song C by artist C |
32 67 100 128 270 |
78.43 |
Song D by artist D |
30 51 99 121 195 |
10.89 |
Song E by artist E |
34 57 95 111 200 |
54.52 |
Song A by artist A |
34 41 93 161 202 |
11.89 |
Song E by artist E |
如果对一个很大的音乐库都执行上面的指纹提取操作,我们就可以构造一个该音乐库对应的指纹库。
为了识别正在播放的音乐,我们用手机录制一段,然后按照上面的步骤提取指纹就可以从指纹库中查找音乐名了。
在查找指纹库的过程中,不可避免地会遇到一个指纹在多首歌中出现的问题,也即两首原始音乐不同时刻提取的指纹相同。虽然我们可以通过多个指纹的匹配来缩小要匹配的音乐范围,但是不足以少到只保留一首歌。这时就需要利用指纹库中的另一个特征:每个指纹出现的时间。
我们录制的音乐片段可能从整首音乐的任意位置开始,所以我们不能直接比较两个时间戳。但是,随着匹配的指纹越来越多,我们可以分析匹配指纹的相对时间。例如,在查找上面给定的指纹库的过程中,我们发现指纹30 51 99 121 195在音乐A和E中出现。过了1s,我们又匹配上另一个指纹34 57 95 111 200,这个指纹在A中出现,而且和前一个指纹的时间差距也是1s。所以有很大可能,我们目前正在听的歌就是A。
// Class that represents specific moment in a song private class DataPoint { private int time; private int songId; public DataPoint(int songId, int time) { this.songId = songId; this.time = time; } public int getTime() { return time; } public int getSongId() { return songId; } }
假设i1和i2分别表示录制音乐的两个时刻,j1和j2表示指纹库中原始音乐的两个时刻。两个指纹匹配的条件必须满足两条:
利用相对时间来匹配指纹可以允许用户从任意位置录制歌曲。在前面我们也提到,用户录制的片段质量差异很大,所以很难出现片段中提取的指纹都和库中的指纹匹配。录制的片段会引入大量的错误匹配,所以我们不可能通过排除的方法找到正确的音乐。比较可靠的方式是对相对时间进行排序,然后选择top1作为正确结果。
下图是听歌识曲自顶向下的架构图:
在上面的架构中,指纹库会非常庞大,所以我们需要将指纹库做得具有扩展性。由于存储的数据没有特别的依赖关系,所以NoSQL是一个非常好的选择。
作者巴拉巴拉说了一大堆,主要是说shazam不光能搜歌,我们还可以拓展它的应用领域。一个很容易想到的点是用它来识别音乐是否存在剽窃。