我会吹口哨,除了口哨外,音乐相关的东西与我无缘,为了能够把吹的口哨导入到编曲软件当中,我想到了,音高提取

音高提取程序,早就想写一个了,之前也确实写了,只不过失败了。

这个程序很简单,输入一个波形文件(mp3啦、wav这样子的文件),输出每一时刻的音高,为了能够导入到编曲软件,最后再把每刻的音高转换一下写到MIDI文件里面。

失败的

之前写的失败了,为什么呢?

因为我造了轮子。

首先,这个程序挺简单的,也不需要用户交互,所以,为了保证效率,我选择C++。

为了读取音频文件,我用了一个WAV库,读取的文件只保留第一个声道,最终得到一个float数组。

然后就是错误的开始:我写了提取频率的程序。

根据初中物理,声音是由物体震动产生,声音频率就是每秒波峰波谷的数量。

(具体怎么写的忘了,我去读了一下以前写的代码,这地方写的很迷惑,先用滚动窗口法平滑了数组,然后把声波升降趋势改变 且 与上一个波峰波谷的符号改变 认为是一个波峰波谷)

之后是进一步错误:认为频率与音高是线性关系。

本来写MIDI也是要造轮子的,但是后来发现这个轮子不好罩,于是找了一个MIDI库。

成功的

有了上次的教训,这次绝对不要造轮子了。

为了不绕路,开始写程序之前先上Github上搜了一下。

找到了PitchDetect这个项目。

这个项目写的很简单,很适合学习。

PitchDetect分析

这个项目是JavaScript写的。

可以以音频文件或麦克风作为输入,在每次浏览器页面刷新渲染的时候取声音样本进行计算得到频率,然后频率计算音高、音高得到音符名,然后显示到页面上。

这不就是我要做的项目嘛。好了不用做了。

但是这个项目是实时处理的,就算是音频文件也需要播放一遍,不能做到离线处理。

而且,它不会存MIDI,就算把每次音符显示存起来也是有问题的:浏览器页面刷新渲染的间隔是不确定的。

处理10分钟的音频就要等10分钟。这可不行。

但是频率提取、音高计算、音符计算是可以照搬的。

Html5 提供了 WebAudio 接口,可以在JS里面用麦克风扬声器什么的,以及给声音加滤镜等等,很强大。

可以用代码写出这样一道流水线:

音频文件 -> 均衡器 -> 音频分析 -> 扬声器

这里面每一个流程是一个节点,可以用代码创建出来。

在频率提取时,它是这样做的:

音频源 -> 音频分析 -> 扬声器

或者是

麦克风 -> 音频分析

然后在每次渲染的时候从 音频分析 节点里面拿数据,计算频率。


好了,接下来就轮到我操作了。

写这个项目,我发现了一个好东西:mozilla的mdn,web开发用的文档。

利用WebAudio接口创建一个 音频上下文对象

然后创建两个节点,连起来。

此时,我意识到一个问题,WebAudio接口不能离线操作啊,再怎么写也就和PitchDetect一样啊。

查文档,我发现了OfflineAudioContext 离线音频上下文。

这个用于后台处理音频,启动之后会尽快处理完音频,然后返回处理结果(异步回调)。

这个离线音频上下文和一般的音频上下文接口一样,可以照搬代码。

看来这样就解决我的问题了。

但是,它会尽快处理完音频,然后返回处理结果也仅仅会尽快处理完音频,然后返回处理结果,音频分析节点的分析结果并不是会传递给下一个节点的结果,而是需要javascript去随时取结果。

解决方法嘛,我发现了这个:

OfflineAudioContext.suspend()
在指定的时间安排音频暂停时间进程,并且通过 Promise 返回。

OfflineAudioContext.resume() (en-US)
恢复一个被暂停的音频的时间进程。

但是我不会用,试了很多次,这两个方法总是报错。

一筹莫展之时,我想到了,Github!

直接在Github上搜索OfflineAudioContext的代码。

翻了好几页之后,终于找到了有用的信息:

我可以把要处理的音频切段,切成很小很小的小段,一段一段处理。

文档里面说音频源这个节点是不能复用的,一旦开始就不能回到起始点了,但是可以删除这个对象,创建新的对象,文档还说这个节点创建的开销很小。

然后我发现离线音频上下文似乎也是,一旦开始就不能回到初始状态了。

所以,对于每一段,都创建新的音频上下文然后创建新的节点。能让机器辛苦的何必让人辛苦

问题解决了。

然后就是解决MIDI存储的问题了。

刚好有Javascript写的MidiWriterJS,问题解决。


实际上,上面仅仅是一些大问题,还遇到也很多小问题:

文件下载问题

Javascript如何发起一个下载请求?

似乎没有这样的接口。

没有的话,就自己造一个接口,这是百度搜索+修改得到的一个方法:

function downloadFileData(data, fileName) {
    blob = new Blob(data);
    let blobUrl = window.URL.createObjectURL(blob);
    let link = document.createElement('a');
    link.download = fileName;
    link.style.display = 'none';
    link.href = blobUrl;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
}  

利用这个,可以把任意的数据变成文件下载。

二进制文件下载问题

javascript里面似乎没有ByteArray这个东西,MidiWriterJS在创建文件数据的时候返回的是Uint8Array,而这个类型在构造Blob的时候会被认为是一串数字,然后被保存成十进制文本文件。

这样,就有了下面改进的文件下载方法:

function downloadFileData(data, fileName) {
    blob = new Blob([data],{"type": 'application/octet-stream'});
    let blobUrl = window.URL.createObjectURL(blob);
    let link = document.createElement('a');
    link.download = fileName;
    link.style.display = 'none';
    link.href = blobUrl;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
}

在构造Blob的时候强调一下,这是一个二进制文件

异步循环问题

在写这个项目之前,我对Javascript异步的理解也就仅仅是会用.then()

但是这个项目的音频处理部分的代码不是只会用这点东西就能解决的。

我需要异步执行一个任务A,当这个任务A执行完成之后再次以不同的参数异步执行任务A,这样循环数次之后,达到结束循环的条件,退出。

我的想法是这样的:

function task(arg){
    new Promise({
        /*---*/
    }).then(()=>{
        if(arg > n) return;
        task(arg + 1);
    });
}
task(0)

能够达到预期效果,但是这玩意属于递归,循环次数多了会爆栈。

写Blog代码的时候没研究过,Blog的加载就是这样写的

研究了一下async await关键字,这样写才没问题:

async function task(){
    for(let i = 0; i < n; i++){
        await new Promise({
            /*---*/
        });// 当然,如果这里是调用一个异步函数的话就不用new Promise了。
    }
}

这个项目已开源到Github上了,原谅我代码写的烂,项目地址在这里

我能想到的,最大的成功就是无愧于自己的心。