Creating Shazam in Java
By royvanrijn On June 1, 2010
翻译:windviki@gmail.com 2010/8/30
几天之前,我偶然看到一篇文章: How Shazam Works
这让我对 shazam 这样的程序是如何工作的产生了兴趣,更重要的一点是,(我
想知道)用 java 实现类似的程序会有多难呢?
关于 Shazam
Shazam 是一款可以用来分析和配对音乐的程序。在手机上安装之后,拿着麦克
风朝着音乐聆听大概 20-30 秒钟,它就能告诉你这是首什么歌曲。
我第一次用它的时候,它给了我一种魔法般的感觉。“它怎么做到的?”——甚
至直到今天,在使用了如此久之后,我仍然有这种奇妙的感觉。
如果我们能自己写个东西,带来同样的感觉,这不是相当牛逼吗?于是,这就成
了我上个周末的目标。
聆听!
第一步,取得音乐样本以供分析。我们首先要在 java 程序里面通过麦克风录入
声音。这是我还没用 java 做过的事情,所以并不清楚这会有多困难。
但是最终证明这非常简单:
final AudioFormat format = getFormat(); //Fill AudioFormat with the
wanted settings. 用想要的设置值来填充 AudioFormat 结构
DataLine.Info info = new DataLine.Info(TargetDataLine.class, format);
final TargetDataLine line = (TargetDataLine)
AudioSystem.getLine(info);
line.open(format);
line.start();
现在我们可以从 TargetDataLine 中读取数据,就像从一个普通的 InputStream
中读取那样:
// In another thread I start: 另外开启一个线程开始做以下工作
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);
}
用这样的办法,很容易打开麦克风并录下所有的声音。我使用的 AudioFormat
如下:
private AudioFormat getFormat() {
float sampleRate = 44100;
int sampleSizeInBits = 8;
int channels = 1; //mono 单声道
boolean signed = true;
boolean bigEndian = true;
return new AudioFormat(sampleRate, sampleSizeInBits, channels,
signed, bigEndian);
}
这样,我们在 ByteArrayOutputStream 中存储好了录音数据,很不错!第一步完
成。
麦克风数据
接下来的挑战是分析数据。我将接收到的数据放到比特数组中,得到一个很长的
数字列表,如下:
0
0
1
2
4
7
6
3
-1
-2
-4
-2
-5
-7
-8
(etc)
额,这玩意儿是声音么?
为知道这组数据能不能被可视化,我将它放到 Open Office 里生成一个线状图:
哈,当然!这种样子看上去就像是声音了。这和你在 windows 录音机里面看到的
例子很像。
这样的数据其实叫做时域。但是这堆数字现在对我们来说根本没用。如果你读了
上面提到的 Shazam 的文章,你会发现他们用到了频谱分析,而不是直接使用时
域数据。
所以下一个大问题是:怎么把现在的数据转换到频谱分析数据呢?
离散傅里叶变换
为了转换当前数据成可用数据,我们应该执行一种称作离散傅里叶变换的操作。
这将把数据从时域转换为频域。
但有一个问题,如果你把数据转换到了频域,会丢失每块数据的时间信息。所以
你可以知道声音的所有的频率级别,但是你不知道他们对应于什么时候出现。
为解决这个问题,我们需要一个“滑动窗口”。我们每次取一块数据(我的例子
里取的是 4096 字节),然后只转换这一块的信息。然后我们可以得到这 4096
字节数据里出现的所有频率信息。
实现它
为了不纠结于傅里叶变换这个概念,我 google 了一下,找到了一段快速傅里叶
变换的代码(FFT)。在处理这一块数据的时候,进行如下调用:
byte audio[] = out.toByteArray();
final int totalSize = audio.length;
int amountPossible = totalSize/Harvester.CHUNK_SIZE;
//When turning into frequency domain we'll need complex numbers: 转换
到频域,我们需要复数数组
Complex[][] results = new Complex[amountPossible][];
//For all the chunks: 遍历所有数据块
for(int times = 0;times < amountPossible; times++) {
Complex[] complex = new Complex[Harvester.CHUNK_SIZE];
for(int i = 0;i < Harvester.CHUNK_SIZE;i++) {
//Put the time domain data into a complex number with
imaginary part as 0: 将时域数据放到复数数组里,复数的虚部设置为 0
complex[i] = new
Complex(audio[(times*Harvester.CHUNK_SIZE)+i], 0);
}
//Perform FFT analysis on the chunk: 对这块数据进行快速傅里叶变
换
}
results[times] = FFT.fft(complex);
//Done! 搞定
现在我们拥有一个二维数组,存储了所有块的复数数组。这个数组包含了所有的
频率信息。为将数据可视化,我决定实现一个完整的频谱分析器(仅仅为了确认
我得到了正确的数字)。
为了展示数据,我把这些都放在一起:
for(int i = 0; i < results.length; i++) {
int freq = 1;
for(int line = 1; line < size; line++) {
// To get the magnitude of the sound at a given frequency
slice
// get the abs() from the complex number.
// In this case I use Math.log to get a more managable
number (used for color)
// 为了得到一个频率段内的声音强度,取复数的绝对值即可。
这里我用了 Math 库的 log 函数来得到一个更好管理的数字(为了频谱图里的颜
色值)
double magnitude =
Math.log(results[i][freq].abs()+1);
// The more blue in the color the more intensity for a given
frequency point:
// 颜色值里的蓝色分量越多,则给定的频率点的声音强度越
高
g2d.setColor(new
Color(0,(int)magnitude*10,(int)magnitude*20));
// Fill: 填充
g2d.fillRect(i*blockSizeX,
(size-line)*blockSizeY,blockSizeX,blockSizeY);
// I used a improviced logarithmic scale and normal
scale: 用了一个改进的对数比例和正常比例
if (logModeEnabled && (Math.log10(line) *
Math.log10(line)) > 1) {
freq += (int) (Math.log10(line) *
Math.log10(line));
} else {
freq++;
}
}
}
介绍 Aphex Twin
这好像有点跑题了,不过我仍要告诉你一个叫做 Aphex Twin (Richard David
James)的电子音乐家。他制作很疯狂的电子音乐,但是有些歌曲有一些很有趣的
特点。比如他的大作, Windowlicker 里有一幅频谱图像。如果你把这个音乐的
频谱图拿来观察,你会发现它看上去像是一个不错的漩涡。另外一首歌,
Mathematical Equation 则显示了 Twin 的脸!更多信息可以参见: Bastwood –
Aphex Twin’s face.
现在我们用我的频谱分析器分析这歌曲,可以得到如下的结果:
不够完美,但是这看上去就是 Twin 的脸!
确定关键点
Shazam 算法的下一个步骤是确定歌曲里的一些关键点。将这些关键点哈希,然
后在他们 800 万首歌曲的数据库里面尝试进行匹配。这将会很快完成,因为哈希
表的查找速度是 O(1)。这也充分解释了 Shazam 令人吃惊的性能。
由于我想在一个周末内解决所有的问题(很杯具,这个是我的最大期限了,之后
我有一个新的项目需要做),我尽可能的保持我的算法简单。让我吃惊的是它能
正常工作。
针对频谱分析结果里的每一行,我取了特定(频率)范围内的最大声音强度值。
在例子里是: 40-80, 80-120, 120-180, 180-300。
//For every line of data: 遍历每一行数据
for (int freq = LOWER_LIMIT; freq < UPPER_LIMIT-1; freq++) {
//Get the magnitude: 得到声音强度
double mag = Math.log(results[freq].abs() + 1);
//Find out which range we are in: 找出是在哪一个频率范围内
int index = getIndex(freq);
//Save the highest magnitude and corresponding frequency: 保存
最大的强度值和对应的频率
if (mag > highscores[index]) {
highscores[index] = mag;
recordPoints[index] = freq;
}
}
//Write the points to a file: 把点值写入文件
for (int i = 0; i < AMOUNT_OF_POINTS; i++) {
fw.append(recordPoints[i] + "\t");
}
fw.append("\n");
// ... snip ...
public static final int[] RANGE = new int[] {40,80,120,180,
UPPER_LIMIT+1};
//Find out in which range 找出在哪个范围内
public static int getIndex(int freq) {
int i = 0;
while(RANGE[i] < freq) i++;
return i;
}
}
我们现在再录制一首歌,得到一张如下的数字列表:
33 56 99 121 195
30 41 84 146 199
33 51 99 133 183
33 47 94 137 193
32 41 106 161 191
33 76 95 123 185
40 68 110 134 232
30 62 88 125 194
34 57 83 121 182
34 42 89 123 182
33 56 99 121 195
30 41 84 146 199
33 51 99 133 183
33 47 94 137 193
32 41 106 161 191
33 76 95 123 185
如果我录一首歌,将其可视化,看上去会是这样:
(所有红点都是关键点)
索引我自己的歌曲
现 在算法已经就绪,我决定索引我的 3000 首歌曲。可以直接打开 mp3 文件然后
转换到正确的格式,用我们处理麦克风数据一样的方式进行读取,而不必再使用
麦 克风来录音。转换立体声音乐为单声道是一个我期待中的小把戏,具体的例
子可以在网上找得到(这里贴出的话代码太多了点)。我不得不改变了一些取样。
匹配!