• 用java实现Shazam 译文


    翻译: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中存储好了录音数据,很不错!第一步完成。

    麦克风数据

    接下来的挑战是分析数据。我将接收到的数据放到比特数组中,得到一个很长的数字列表,如下:









    -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文件然后转换到正确的格式,用我们处理麦克风数据一样的方式进行读取,而不必再使用麦克风来录音。转换立体声音乐为单声道是一个我期待中的小把戏,具体的例子可以在网上找得到(这里贴出的话代码太多了点)。我不得不改变了一些取样。

    匹配!

    程序最重要的部分就是匹配过程了。看了Shazam的论文,他们用哈希值来配对,以此决定哪些歌才是最佳匹配。

    为节省时间,我用我们数据中的一条线(例如33, 47, 94, 137)作为一组哈希值1370944733,而不是考虑困难的点对算法。(例子里用3或者4个点是工作最好的,但是调优很困难,因为每次都需要重新索引我的mp3文件)

    例子里使用每条线的4个点做哈希:

    //Using a little bit of error-correction, damping 设一个阻尼值,用来做错误纠正 
    private static final int FUZ_FACTOR = 2; 

    private long hash(String line) { 
        String[] p = line.split("\t"); 
        long p1 = Long.parseLong(p[0]); 
        long p2 = Long.parseLong(p[1]); 
        long p3 = Long.parseLong(p[2]); 
        long p4 = Long.parseLong(p[3]); 
        return (p4-(p4%FUZ_FACTOR)) * 100000000 + (p3-(p3%FUZ_FACTOR)) * 100000 + (p2-(p2%FUZ_FACTOR)) * 100 + (p1-(p1%FUZ_FACTOR)); 

     
    现在我创建了两个数据集合:

    一个歌曲列表。List<String> (索引是歌曲ID,字符串是歌曲名songname)
    哈希数据库。Map<Long, List<DataPoint>>

    long类型的key代表了它自己的哈希值,它有对应的一个DataPoints的列表。

    DataPoint结构是这样的:

    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; 
        } 

     
    现在我们所有需要用来查找的东西都已经就绪。首先我读取了我所有的歌曲并且为数据的每个点生成哈希,把结果放进哈希数据库。

    然后是读取我们需要匹配的歌曲的数据。(待识别样本的)哈希值被计算出来,我们就去查找匹配的点。

    这有一个问题,对于每个哈希值,会有一些命中点(可能多于一个)。但是我们怎么来确定哪一个结果才是最终正确的歌曲呢?检查匹配的个数?不,这不行。

    最重要的因素是时间。我们必须对时间进行重叠(把待识别样本的时间轴和索引时候的音乐样本的时间轴对应起来才知道是不是正确的歌曲)!

    但是我们不知道他们位于歌曲的哪个位置(待识别样本具体开始于歌曲的哪个时间点是不一定的,用户可能在听到歌曲的任意时候进行识别),又怎么能做到呢?毕竟,我们只能简单地录下歌曲的末尾旋律(After all, we could just as easily have recorded the final chords of the song)。

    在查询过程中,我发现一些很有意思的东西,因为我们有下面一些数据:

    1. 录音的哈希。 -->待匹配的数据样本的哈希值
    2. 可能的匹配点的哈希。-->和1匹配的哈希值(数据库里可能找到多个)
    3. 可能的匹配点的歌曲ID。-->和1匹配的哈希值(数据库里可能找到多个)对应的歌曲ID
    4. 录音中的当前时间。-->和1对应的样本时间点(处于聆听了X秒里的哪个位置)
    5. 可能的匹配点的哈希对应的时间。-->和1匹配的哈希值(数据库里可能找到多个)对应的时间点(建立索引数据库的时候应该知道位于歌曲的哪个位置)

    现在我们可以用哈希匹配的时间(例如1352行)减去我们录音中的当前时间(例如34行),把这个差值和歌曲ID存储在一起。有了这个偏移,就能告诉我们它可能位于歌曲的哪个位置。

    当我们过一遍所有的录音数据的哈希,我们就得到很多的歌曲ID和偏移量。

    很酷的是,当你有了足够多的匹配偏移的哈希的时候,你就能找到你的歌曲。

    结果

    例如,我们聆听The Kooks – Match Box 20秒,这是我程序的输出:

    Done loading: 2921 songs 

    Start matching song... 

    Top 20 matches: 

    01: 08_the_kooks_-_match_box.mp3 with 16 matches. 
    02: 04 Racoon - Smoothly.mp3 with 8 matches. 
    03: 05 R?yksopp - Poor Leno.mp3 with 7 matches. 
    04: 07_athlete_-_yesterday_threw_everyting_a_me.mp3 with 7 matches. 
    05: Flogging Molly - WMH - Dont Let Me Dia Still Wonderin.mp3 with 7 matches. 
    06: coldplay - 04 - sparks.mp3 with 7 matches. 
    07: Coldplay - Help Is Round The Corner (yellow b-side).mp3 with 7 matches. 
    08: the arcade fire - 09 - rebellion (lies).mp3 with 7 matches. 
    09: 01-coldplay-_clocks.mp3 with 6 matches. 
    10: 02 Scared Tonight.mp3 with 6 matches. 
    11: 02-radiohead-pyramid_song-ksi.mp3 with 6 matches. 
    12: 03 Shadows Fall.mp3 with 6 matches. 
    13: 04 R?yksopp - In Space.mp3 with 6 matches. 
    14: 04 Track04.mp3 with 6 matches. 
    15: 05 - Dress Up In You.mp3 with 6 matches. 
    16: 05 Supergrass - Can't Get Up.mp3 with 6 matches. 
    17: 05 Track05.mp3 with 6 matches. 
    18: 05The Fox In The Snow.mp3 with 6 matches. 
    19: 05_athlete_-_wires.mp3 with 6 matches. 
    20: 06 Racoon - Feel Like Flying.mp3 with 6 matches. 

    Matching took: 259 ms 

    Final prediction: 08_the_kooks_-_match_box.mp3.song with 16 matches. 
     
    成了!!听20秒,它能匹配出我拥有的几乎所有歌曲。即使是现场版的录音也能在聆听40秒之后正确匹配。

    再一次感觉就像是魔法一样:)

    现在,代码还处于不能发布的状态,它工作得还不是很完美。这仅是一个周末hack,更像是个概念版或者说是算法研究。或许,有足够多的人问起的话,我会整理好代码并且在某处发布。

    更新:

    Shazam专利律师给我发了邮件阻止我释出代码并且要求移除这篇博文,可以在这阅读这个故事:here





    本文章由windviki原创。转载请注明出处。
  • 相关阅读:
    常用的npm指令总结
    Mongoose基础
    2016总结与展望
    sleep与wait的区别
    查询平均分大于80分的学生
    求最大不重复子串
    快速排序
    按位与(&)运算的作用
    异或运算的作用
    java 字符串中的每个单词的倒序输出
  • 原文地址:https://www.cnblogs.com/aloe/p/2392216.html
Copyright © 2020-2023  润新知