本篇将会讲解数字合成器的基本原理,并基于JUCE开发一个简单的数字合成器。
本篇的重点是合成器的原理而不是代码具体如何编写,所以一部分代码细节将会省略。
JUCE框架
编程领域有句话说得好,做项目的时候不要重复造轮子,尽量把精力放到有用的地方。例如对于音频处理,我们希望关注的是具体处理音频的算法,而不是那一大堆和声卡、宿主软件交互的api。
JUCE(Jules’ Utility Class Extensions)框架提供了一系列在音频处理上非常有用的库,可以很方便地编写音频相关应用、插件并打包发布到不同平台。JUCE可以在其官方网站下载。IDE推荐使用visual studio,框架使用可以查询其官方教程或者这个很不错的中文教程。
安装JUCE并创建一个音频插件项目(需要在设置-Plugin format中勾选vst3选项),点击上方按钮使用Visual Studio 2019打开。
计算机处理音频时是逐块处理的。虚拟声卡设置中可以更改缓冲区大小。缓冲区越小音频的延迟就越低,但相应的对性能要求更高。
C++中,每个类分为.h文件(类的定义)和.cpp文件(类的实现)。在PluginProcessor.h我们可以定义一些有用的类成员属性,以供后面使用:
class SynthYiAudioProcessor : public juce::AudioProcessor
{
public:
//...
//波形生成相关参数
int positionStamp = 0;
double sampleRate;
//音符列表
NoteEvent notelist[129];
//...
}
在Visual Studio打开的项目中找到PluginProcessor.cpp的processBlock函数。在插件运行时,程序将会不断调用此函数来逐块处理音频。我们将在这里编写音频处理代码。
//SynthYi为项目名
void SynthYiAudioProcessor::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midiMessages)
{
//在这里编写音频处理代码
sampleRate = getSampleRate();//采样率
}
MIDI
MIDI(Musical Instrument Digital Interface),或乐器数字接口,是一种能被计算机理解的乐谱格式。Midi格式由一系列消息组成,记录了诸如按键按下、按键松开、踏板踩下、移调轮转动、力度改变等事件。在编曲宿主软件中的写的音符也将以MIDI的形式传输给虚拟乐器插件。
MIDI用128个id对应了钢琴上的128个键。JUCE提供了相关的工具类库来将MIDI id转换为对应的音高频率。在processBlock函数中MIDI事件列表作为函数参数传入,可以使用for循环来迭代当前Block中发生的midi事件:
int pos;
juce::MidiMessage midi_event;
for (juce::MidiBuffer::Iterator i(midiMessages); i.getNextEvent(midi_event, pos);) {
if (midi_event.isNoteOn()) {
//有音符被按下了。
}
}
振荡器(Oscillator)
振荡器是合成器的核心。振荡器负责产生特定频率的波形信号,一般是方波、正弦波、三角波等基本波形。模拟振荡器一般由RC震荡电路、LC震荡电路、石英震荡器等电路组成。数字振荡器比较灵活,一般使用一个函数来计算每个采样点的采样值。
例如下面的公式可以生成一个频率为$\omega$正弦波:
$Y_n=\sin (2\pi \omega x)$
锯齿波:
$\arctan\left(\tan\pi \omega x\right)$
或者干脆用傅里叶级数来表示。这是傅里叶级数形式的锯齿波:
$f\left(x\right)=\sum_{n=1}^{\infty}\frac{\sin nx}{n}$
傅里叶级数形式的方波:
$f\left(x\right)=\sum_{n=1}^{\infty}\frac{\sin\left(\left(2n-1\right)x\right)}{\left(2n-1\right)}$
这里的$\infty$是理想情况,合成的时候可以根据需要选择适当的项数。多数时候50级正弦波相加听起来就已经不错了。这里有一些傅里叶级数模拟出的波形图像:
但这两种合成方法都有很大缺陷。第一种方法生成波形时需要针对每个波形都编写一个函数,灵活性很低,而且并不是任意波形都可以用简单的数学函数来表示;第二种方法比第一种更灵活,因为几乎所有周期波形都可以被分解为傅里叶级数。缺陷在于合成器只能计算有限个波形,常常造成高频泛音的缺失。并且波形多了以后一次性需要计算上百个正弦波,对性能要求太高了。
商业合成器软件,如Serum血清合成器,一般多采用波表(wavetable)合成,即预先存储波形单个周期的采样,在合成时根据频率将采样拉伸并不断重复。Serum的波形预设库里有很多wav波形文件,在软件里打开其中一个:
可以看到每个波形都被记录了2048个采样点(注意右下角以采样为单位的时间显示)。
再换一个:
这样的波形是很难用函数表达的,但在波表合成器中,只需要存储波形采样即可。
群奏(Unison)
单个波听起来有时有些单薄。在数字合成器中,常会有群奏效果,即让几个频率相近的振荡器同时发声。
下面为一段群奏的试听。乐曲前半部分为单个锯齿波,后半部分为六个锯齿波齐奏。
波封(Envelope)
如果按下就发出声音,松开声音立刻就停止,这样直来直去的音乐听起来多平淡啊!
波封(Envelope)赋予合成器声音以音量上的动态。最为人熟知的波封形式是ADSR,即启动(Attack),衰减(Decay),延留(Sustain)和释放(Release),分别对应从按下按键到合成器到达最大音量的时间,合成器到达最大音量后音量衰减的时间,到达最大音量后音量衰减多少,以及松开按键后合成器声音消失的时间。一部分合成器如Serum还有保持(Hold)时间的选项,即合成器在最大音量停留多长时间。
听感上来说,启动时间越短,声音听起来越有冲击感,越有力。释放时间越长,音符的余音就越长。
波封作用于波形的振幅。这是一个波形的响度-时间图:
波封函数$f_{Env}(t)$描述了波形相对于时间的变换。对生成的波形应用波封:
$Y_t=X_t*f_{Env}(t)$
波封函数的一种代码实现,仅供参考:
double NoteEvent::EnvAHDSR(int currentStamp)//获取当前的相对振幅
{
int step = currentStamp - noteStart;
if (step <= 0) {//before
releaseFrom = 0.0;
return 0.0;
}
else if (currentStamp > noteReleaseTime && currentStamp < noteReleaseTime + release && noteReleaseTime != -2) {
//release
int releaseStep = noteReleaseTime + release - currentStamp;
return releaseFrom*(powCurve((double)releaseStep / (double)release));
}
else if (step < attack && step > 0) {//attack
releaseFrom= powCurve((double)step / (double)attack);
return releaseFrom;
}
else if (step <= attack + hold) {//hold
releaseFrom = 1.0;
return 1.0;
}
else if (step <= attack + hold + decay) {//decay
double nowPercentage = powCurve(1 - ((double)(step - attack - hold) / (double)decay));
releaseFrom= (1.0 - sustain) * nowPercentage + sustain;
return releaseFrom;
}
else if (noteReleaseTime == -2 || currentStamp < noteReleaseTime) {//sustain
releaseFrom = sustain;
return sustain;
}
return 0.0;//after
}
double NoteEvent::powCurve(double x)//响度一般是指数变化的
{
return std::pow(21.0, x) / 20.0 - 0.05;
}
滤波器(Filter)
合成器使用的滤波器多是动态滤波器。滤波器的生效幅度和截止频率随时间动态变化,可以创造出很有趣的听感。例如,将滤波器的截止频率与波封挂钩时,可以创造出弹跳感很强的Pluck音效。下面有一段试听,使用了截止频率动态的巴特沃夫滤波器:
低频振荡器(LFO)
低频振荡器(Low-Frequency Oscillation)输出一个频率较低的周期性控制信号。该控制信号可以控制合成器的输出音量,滤波器截止频率等很多东西。甚至,在数字滤波器上,还可以控制合成所用采样的序号,即LFO输出值不同时,合成的波形也不一样。
下面有一段试听,其中合成器合成的波形受LFO输出信号控制:
低频振荡器的波形产生原理和产生声音波形的振荡器完全一致。
效果器(Fx)
在上一篇中,我们实现了很多种效果器。它们也可以放在合成器上,作用于合成的音频。
下面这段试听音频中的旋律由加了失真效果器的正弦波演奏。正弦波被削波失真后,产生了一些类似方波的泛音,音色变得更明亮。
插件的导出和使用
在这里,我使用代码实现了一个简单的基于傅里叶级数的加法合成器。如果你要自己开发合成器的话尽量用波表的方式,因为我这个合成器运行时CPU占用能够到达50%以上……
关于JUCE的UI开发,可以参照JUCE的官方教程和z新豪的教程。因为内容实在是太琐碎这里真的放不下了。
在Visual Studio中编写完代码后,选择生成-生成解决方案,在在输出文件夹中找到.vst后缀的vst插件,拷贝进C:\Program Files\Common Files\VST3目录中:
打开DAW软件,加载插件即可使用。注意,要想让插件被识别为虚拟乐器,需要在JUCE中勾选Plugin is a Synth选项并开启MIDI输入。
下面是一段简单的试听(旋律来自《SandStorm》,启用波封,五个锯齿波群奏,未使用滤波器):