音频开发技术(三)音频编程基础知识
前言
这一节,我们认识一下JUCE音频插件框架的代码结构,顺便了解一下C++。
认识JUCE框架的结构
继续上一节的工程
打开JUCE -> 在IDE里打开
这次打开后就不要生成了,点击右边竖着的标签栏中解决方案资源管理器(第二个),展开源文件列表
如果你的visual studio里没有这个“解决方案资源管理器”,就要通过 点击视图->解决方案资源管理器 来显示它
选中NewProject_SharedCode -> NewProject -> Source文件夹(上上张图)
(NewProject就是上一节创建工程时默认的工程名 如果你创建的工程是其他名字,同理把NewProject换成其他名字)
这个Source文件夹里的四个文件是是框架自动生成的代码,它们就是整个音频插件的框架。
这四个文件其实是两个东西:PluginEditor类 与PluginProcessor类
PluginEditor类:负责着插件的界面。
PluginProcessor类:负责MIDI运算与音频运算。
C++中,一般把声明放在 XxxClass.h中,把定义放在同名的XxxClass.cpp中
一般我们打开XxxClass.h查看这个类有哪些变量(字段)和哪些函数(方法)
然后打开同名XxxClass.cpp查看这些函数干了什么, 这些变量用在了什么地方
PluginEditor与PluginProcessor交互:PluginEditor中包含了一个PluginProcessor的字段。
这个关系是JUCE框架为我们建立的,在插件初始化时,JUCE框架会自动把插件的PluginProcessor对象注入到PluginEditor对象中,我们直接使用就可以。也就是说,在PluginEditor.cpp文件里,直接调用processor.xxx就行了,这个processor被定义在PluginEditor.h里,这个定义和注入是框架自动为我们做的。
简而言之,通过processor字段实现两者交互。
学习一个框架时我会先看一下回调方法与生命周期,对于JUCE的音频插件而言,只需要关注回调方法就可以了,来看一下有什么可以用的回调。
认识PluginEditor与它的回调方法
(回调方法你可以理解为框架给你写东西的地方,不同的回调在不同条件下被框架调用执行,你把功能写在里面,就可以在特定条件下执行你写的功能。比如resize回调,即在窗口被拉伸时自动执行resize(){XXXXX}后面大括号‘{}’里XXXXX的功能)
(根据面向对象编程的原理,回调方法的必定是重写的,声明会带有override标示)
打开PluginEditor.h头文件,看看有哪些声明
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | //PluginEditor.h #pragma once #include <JuceHeader.h>; #include "PluginProcessor.h" class NewProjectAudioProcessorEditor : public AudioProcessorEditor { public: NewProjectAudioProcessorEditor (NewProjectAudioProcessor&); ~NewProjectAudioProcessorEditor(); void paint (Graphics&) override; void resized() override; private: NewProjectAudioProcessor& processor; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NewProjectAudioProcessorEditor) }; |
在这些声明里找到了这些方法:
1.创建时调用的构造方法NewProjectAudioProcessorEditor(NewProjectAudioProcessor&)
2.关闭销毁时调用的析构方法~NewProjectAudioProcessorEditor()
3.被重写的paint(Graphics&)回调方法,看名字应该知道是绘图的,上面提过这个类负责插件的界面
4.被重写的resize()回调方法,看名字应该知道它是改变窗口大小的时候调用的,常用来响应式地改变元素位置,居中、比例缩放之类的。
在下方看到这个字段:NewProjectAudioProcessor& processor; 这就是之前提到的实现PluginEditor与PluginProcessor交互的字段。
再看一下PluginEditor.cpp,了解这些方法的实现
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 | //PluginEditor.cpp #include "PluginProcessor.h" #include "PluginEditor.h" NewProjectAudioProcessorEditor::NewProjectAudioProcessorEditor (NewProjectAudioProcessor& p) : AudioProcessorEditor (&p), processor (p) { setSize (400, 300); } NewProjectAudioProcessorEditor::~NewProjectAudioProcessorEditor() { } void NewProjectAudioProcessorEditor::paint (Graphics& g) { g.fillAll (getLookAndFeel().findColour (ResizableWindow::backgroundColourId)); g.setColour (Colours::white); g.setFont (15.0f); g.drawFittedText ("Hello World!", getLocalBounds(), Justification::centred, 1); } void NewProjectAudioProcessorEditor::resized() { } |
这里看到
1.构造方法NewProjectAudioProcessorEditor (NewProjectAudioProcessor& p) 初始化了processor字段,然后调用了setSize(400,300), 看名字应该知道是设置窗体的大小。
2.paint(Graphics& g) 回调中使用Graphics对象设置了颜色,设置了字体并输出了”Hello World”字符串。
3.在其他方法的实现中什么也没做。
在实际使用时,通常把界面组件(按钮,推子)的实现的放在ProcessorEditor构造方法中,而把额外的绘制,背景色等放在paint(Graphics& g)方法中。ProcessorEditor.cpp中,构造方法是以后我们主要编写代码的地方。
resized()在每次窗口变动时自动被框架调用,JUCE提供getWidth()/getHeight()实时获取窗口大小。
认识PluginProcessor与它的回调方法
打开PluginProcessor.h,我们看到一大堆不知道干什么方法,不用管它们,再看看PluginProcessor.cpp,也是一大堆不知道干什么的方法,不用管,它们都是一些可选的功能。
只需要关注其中一个回调方法 : processBlock
看一下本体
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | void NewProjectAudioProcessor::processBlock (AudioBuffer& buffer, MidiBuffer& midiMessages) { ScopedNoDenormals noDenormals; auto totalNumInputChannels = getTotalNumInputChannels(); auto totalNumOutputChannels = getTotalNumOutputChannels(); for (auto i = totalNumInputChannels; i < totalNumOutputChannels; ++i) buffer.clear (i, 0, buffer.getNumSamples()); for (int channel = 0; channel < totalNumInputChannels; ++channel) { auto* channelData = buffer.getWritePointer (channel); } } |
processBlock 这个回调作用是处理信号,把处理逻辑写在这个回调里
该方法传入参数: AudioBuffer类型的的buffer,和MidiBuffer类型的midiMessages。
第二个参数MidiBuffer,比较简单,可以参考官网教程(控制MIDI信号): Tutorial: Create a basic Audio/MIDI plugin, Part 2: Coding your plug-in 自行学习,这里不做讨论。
1.了解传入参数:AudioBuffer对象
复习采样:这里用通俗的话再讲一遍采样:
模拟信号变成数字信号是通过打一堆点来记录波形形状(记得44.1KHz吗:一秒打44100个点),把每个点用来记录在那个打那个点时的信号振动到位置(选取-1到1的小数来记录,轴上十分之一处记为0.1, 轴下二分之一处记为-0.5),得到一串数字,这一串数字就是波形的数字信号:
【模拟信号】 —[采样]—> 【数组】 -0.5, -0.32, -0.21, -0.1, 0.08, 0.19, 0.37, 0.6…….
回到主题,processBlock这个回调的第一个传入参数是一个AudioBuffer对象,
在这个AudioBuffer对象中,可以直接得到信号数组。
在第一节中,我们已经讲过声道channel的概念
获取信号数组: AudioBuffer的getWritePointer(int 声道编号)
F12一下 看下定义:
注释里提到了它是一个能读能写的序列,如果想要只读的,还有getReadPointer接口可供选择。
这意味着 要得到信号序列,直接读它返回的数组内容,要改变输出信号,直接写到它返回的数组的内容 就可以了。 这里的出入信号是相对于回调方法processBlock而言的。
2.获取信号序列
示例:立体声获取信号序列 数组 (processBlock回调中)
1 2 3 | float* channelL = buffer.getWritePointer(0) //左声道 float* channelR = buffer.getWritePointer(1) //右声道 |
这样得到了channelL,channelR 左右声道 两个数字序列 数组,接下来我们需要知道每个序列数组里有多少采样点。
获取当前buffer块有多少个采样点:AudioBuffer的getNumSamples()
知道了采样点的的数量,写一个循环遍历调节两个声道数组里的所有点的值。
3.处理信号序列
示例:处理立体声信号(processBlock回调中)
在这里我把每个点都乘上0.5,这样所有点的振幅都小了一半,音量也就发生了改变
1 2 3 4 5 6 7 8 9 10 | float* channelL = buffer.getWritePointer(0) //左声道 float* channelR = buffer.getWritePointer(1) //右声道 for(int sample = 0; sample < buffer.getNumSamples(); sample++) { channelL[sample] *= 0.5; channelR[sample] *= 0.5; } |
为什么要遍历呢?Buffer Size 与 buffer.getNumSamples()
音频是切成一块一块处理的,或者说一个buffer一个buffer处理,每个块或者buffer中包含一定数量的采样点(Samples)。
例如,一段采样频率为44100Hz的的音频,宿主的缓冲区大小调节为441 Samples,那么系统为了达到1秒44100个采样点的采样率,CPU每秒会调用它的processBlock 100次,而每次调用都会给processBlock的buffer参数放441个采样点。
缓冲区越小,CPU调用processBlock就越频繁,CPU负载也就越高,但是换来的的是延迟更小,每个块只处理少量的东西,更有实时感,反之同理。
在每次processBlock中,循环遍历所有采样点,确保处理了每一个采样点的值。
缓冲区大小(Buffer Size):是人为的设定,指一个块/buffer中有多少个采样点,它影响了cpu每秒调用processBlock方法的次数。
大部分缓冲区大小都是2的倍数 32,64,128,256,512,1024,2048。也有可以任意调节的,像是FL STUDIO 。
[win7 上虚拟 ASIO 的 Buffer Size 设置]
[Logic Pro X 上的 Buffer Size 设置]
[可以任意调节大小的 FL STUDIO 的 Buffer Size 设置]
1. AudioBuffer& buffer 中的 & 是什么意思?
参数前的&代表这个参数是引用传递,引用传递你可以理解为函数内的改变将会影响函数外传进来的这个参数的值,而正常情况下函数内对参数的改动不会影响函数外传进来的参数。
-> 正常情况:
1
2
3 void fun1(int x){ x=100;}
int main() { int input = 2; fun1(input); cout<<input;} //输出2
-> 引用传递
1
2
3 void fun1(int& x){ x=100;}
int main() { int input = 2; fun1(input); cout<<input; } //输出100
2. float* channelL = buffer.getWritePointer(0) 之后为何可以channelL[sample] ?
数组本质是指向该数组第一个元素的地址的指针
1
2
3
4
5
6
7 int myArray[3] = {0,1,2};
// myArray是一个指针 指向&myArray[0]
int* newPoint = myArray;
cout<<newPoint[1]; // 输出1
更多资料