前一阵子一直在做CodeasilyX这个项目的音频处理的工作,由于对音频信号处理方面的技术欠缺,花了大量的时间在这个项目核心功能无关的音频功能上,但收获很多,学习到了很多音频处理的技术实现,所以在这里做个记录。

音频处理之变音变调

一段声音可以理解为多种频率正弦波的叠加,而音调就是一段声音的主要频率。改变了主要频率,就是改变了音调——提高了主要频率,就是升调,反之亦然。

一开始我找了很多文章,大多都是理论,我没有学过音频处理算法根本看不懂,幸好后来找到了这一篇HTML5调用摄像头+视频特效+录制视频+录音+截图+变声+滤波+音频可视化,里面提到了变声的实现,他的实现方式就是这句代码outputBuffer[sample]=inputBuffer[sample/2],这样就可以得到一个变得浑厚的声音,虽然这么一来音频的时长会出现问题,需要更合理的算法来处理这些buffer的对应关系。但正是因为这句代码我才明白了声音的变音各种算法跟程序的二进制处理之间的关系是怎样对应起来的,打开了我对音频处理的程序实现的一扇大门,

下面是我推断出来的正弦波与buffer的关系大概就是下面这两张图的对比:

正弦波,区间为[-1,1]
正弦波,区间为[-1,1]
音频的buffer数据
音频的buffer数据

上图的buffer数据是我录音得到的Float32Array数组,值区间为[-1,1],而恰巧正弦波的值区间也是[-1,1],这是不是就说明了正弦波的值就对应了音频buffer的值了?而调整这些buffer的值就等于是调整了正选波的值和频率?

尝试自己实现

我想实现把音调变高,像小黄人的声音那样,按道理就是outputBuffer[sample]=inputBuffer[sample*2]就可以让音调变高,但是这么做的话inputBuffer就不够数据分给outputBuffer了,就会导致outputBuffer音频时长少了一半,所以我自己研究怎么给outputBuffer插值,补足音频时长,可是不管怎么补,都会导致没有声音,真不知道浏览器怎么解析buffer放出声音的🤔,可能还是需要依靠算法来实现。

可是算法要涉及的知识实在太多了,感兴趣的话可以看一下这篇文章深入浅出的讲解傅里叶变换(真正的通俗易懂),em..通俗易懂,我看到后面就越来越不懂了😰,也许数学物理专业或者通信专业能看懂,但我一时半会学不来,留下了没技术的眼泪😭。

所以想自己实现等以后专门研究这方面在想吧。找找其他的方案。

SoundTouchJS

后来我找到了SoundTouchJS这个开源项目,基本可以实现我想要的变音功能,还能保持音频时长,一来解决了燃眉之急,二来还可以看源代码学习一下,美滋滋😁。

我的使用代码如下:

1
2
3
4
5
6
7
8
9
10
this.context.decodeAudioData(data, audioBuffer => {
this.shifter = new PitchShifter(this.context, audioBuffer, 4096, () => {
// 播放进度达到结尾时回调
this.playOver()
});
this.shifter.tempo = 1;
this.shifter.pitch = 1;
// 负数时声音变得浑厚
this.shifter.pitchSemitones = -1.8;
});

但是SoundTouchJS的播放形式是用AudioNode连接扬声器,流式播放的。

这里我要说明一下,一般网页里播放音频有两种方式:

  1. 第一种是我们常见的用Audio标签或者new Audio来加载一个音频文件,再控制AudioElement.play()进行播放的;
  2. 第二种就是通过this.context.createScriptProcessor监听onaudioprocess流式的取到源数据,给outputBuffer赋值,最后AudioNode.connect(AudioContext.destination)连接扬声器进行音频播放,该方法提供更高级更底层的能力控制音频输出,SoundTouchJS就是用的这种方法,可以做到播放过程中随时变音,这是比Audio标签加载文件直接播放更直观的区别。

但是我觉得这种流式播放的方式在做暂停、停止方面的操作时经常会卡音(就像磁带播放器卡带的情况),可能是因为流式取元数据有规定每次的块大小,块太大可能容易卡音,块太小则处理的次数会更频繁影响性能。而且每次暂停、停止、播放的操作都是要跟扬声器进行connectdisconnect的操作,也决定了onaudioprocess的触发时机,觉得挺容易出bug的。

所以我觉得这种流式播放的形式还是比较适合类似直播的场景或者自己调试变音的场景,不太适合录播的时候经常暂停或拖动进度条播放的情况。

所以我又要想办法把SoundTouchJS里onaudioprocess控制的outputBuffer存起来,再编码成文件。

SoundTouchJS编码文件

上面提到的把SoundTouchJS里onaudioprocess控制的outputBuffer存起来,再编码成文件,首先要找到soundtouchjs里的createScriptProcessor的地方,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const getWebAudioNode = function(
context,
filter,
sourcePositionCallback = noop,
bufferSize = 4096
) {
const node = context.createScriptProcessor(bufferSize, 2, 2);
const samples = new Float32Array(bufferSize * 2);
node.onaudioprocess = event => {
let left = event.outputBuffer.getChannelData(0);
let right = event.outputBuffer.getChannelData(1);
let framesExtracted = filter.extract(samples, bufferSize);
sourcePositionCallback(filter.sourcePosition);
if (framesExtracted === 0) {
filter.onEnd();
return node;
}
let i = 0;
for (; i < framesExtracted; i++) {
left[i] = samples[i * 2];
right[i] = samples[i * 2 + 1];
}
};
return node;
};

export default getWebAudioNode;

关键的就是这一句

1
let framesExtracted = filter.extract(samples, bufferSize);

它会把之前对源数据滤波之后的数据在onaudioprocess的时候做分割,然后赋值给outputBuffer,那我要的把就是这些一段一段的分割的数据给存起来,拿到数据就可以一次性编码文件了,这句代码现在放在onaudioprocess里就会按采样率每4096为一次得一次块,而全部存完需要花的时间正是音频的时长,这显然是不太符合我要编码文件的需求的,所以我不能在onaudioprocess里存数据,我的思路就是直接拿这个方法(filter.extract(samples, bufferSize))调用做递归,依然是每4096分割一块,然后存起来,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
filterBuffer() {
// 传入全部的buffer进行滤波
const bufferSize = 4096;
const data = new Float32Array(this._buffer.length * 2);
var offset = 0;
while(this._buffer.length * 2 - offset > bufferSize*2) {
const samples = new Float32Array(bufferSize*2);
this._filter.extract(samples, bufferSize);
data.set(samples, offset+=bufferSize*2)
}
// 抽出单声道
const singleSamples = data.filter((item,index) => index%2 == 0);
return singleSamples;
}

按道理这个方法是可行的,可结果却让人大跌眼镜,它合并出来的数据和在onaudioprocess里的数据竟然是不一样的,明明传入的是同样的参数,只是一个是按采样率调用,一个是单线程递归出来,最后我也没找到解决办法,只能继续沿用流式播放的形式,如果有大神能指出问题所在,本人定当感激不尽🙏

关于SoundTouchJS源代码

SoundTouchJS的源代码,目录结构如下:

看了下源码,好像也没有提到相关的正弦函数算法,具体实现原理还在研究,等研究出来了再写。

总结

最后我就是用了soundtouchjs的方案了,总体来说变音效果还是不错的,就是我姿势水平还不够多,没能解读出变音的奥秘,只能徘徊在新手村用着大神们的开源项目了😂