audiokaifazhanshizhan.jpg

本教程面向开发【音频处理/音乐制作】类软件,如效果器、虚拟乐器、宿主、采样音源、应用了音频效果的应用与游戏。不涉及编解码,不涉及语音识别。

第一章:音频开发技术

第一课、数字音频基础知识

前言

写这教程两个原因:

1.填补资料空缺

2.帮助想做 音源 或 VST/AU/AAX插件 的人

早在几年前,我就尝试写过相关教程,可是当时阅历尚浅,卡在一些小问题上,教程也就搁置了。

再次想起这个教程,已过去好几年了。上网搜了下,国内在这方面至今还是没什么靠谱的资料。于是决定动笔。

现在我对音频技术有了更多的了解,在这版课程中,我有信心教会你一个完整且系统的知识体系。

说到采样,采样率,位深,声道等,你可能觉得再熟悉不过。但我希望你耐心看下去,因为你可能并根本不知道它们是什么意思。

(编程语言,不要求会写C++,了解任何一门面向对象的语言都可以,用到C++特性的时候我会详细讲的。本教程大部分示例使用JUCE,但是原理放之四海皆准。)

声音的本质

声音是人的直观感觉,它在物理世界中体现为空气的振动(压强变化)。

声音与电信号之间的转换 (ANALOG)

我们已经知道,声音是空气中的振动,现在用电路的方式把空气中的振动记录下来。

十九世纪,贝尔实验室发明了电话,人类第一次把声音转换成电信号。

【变化的磁场产生电场】一块磁铁前放一个贴有线圈的振动膜就做成了麦克风,声音的空气振动带动振膜振动,线圈随着振动,磁场相对也就振动着变化,然后就产生了电压连续变化的电流,这样一段声音就转化成了电信号。

【变化的电场产生磁场】依然是一块磁铁前放一个贴有线圈的振动膜,完全一样的结构在这里却是扬声器,把信号电流通向线圈,线圈在连续变化的电流下产生连续变化的磁场,这个磁场和磁铁的磁场相互作用,然后产生振膜的振动,振膜的振动又带动空气振动,于此电信号就变回了声音。

直到20世纪末,人类都是通过这样的电信号处理声音,唱片,磁带 记录的就是这样的电信号。

电信号与数字信号之间的转换(ADDA)

预备知识:比特

随着计算机的出现,我们有了比特的概念。

计算机记录与操作的是二进制的数据,也就是只有0和1两个数的数据,比如数字7转化为二进制就是111(4+2+1)。

类似于十进制的 个、十、 百、 千、 万 位, 二进制的位叫做比特(bit)

比如1001就是一个四个比特位的二进制数据

8个比特位称作一个字节(Byte) 像这样 10011100

1024个字节称为一个千字节(KB)

1024个千字节为一个兆字节(MB)

1024个兆字节为一吉字节(GB)

1024个吉字节为一个太字节(TB)

举个例子,一段大小为16MB的 .wav 文件 它包含的就是 16*1024*1024*8 个1或0这样的数据。

采样技术

我们已经知道,计算机里操作和储存的只能是像101010这样的一串二进制数字,而音频的电信号则是一段连续变化的波形电信号,为了把这个连续的波形电信号记录成计算机里101110这样的数据,就要采样。

比如一个1秒的波形

202408171808.png

打了17个点,每隔十七分之一(1/17)秒打一个点,每个点记录波形在那个时间上振动到的位置(纵轴),这种记录就是采样。

这样 一段电信号就变成了一堆点。

那我用什么表示 每个点的振动位置呢 ?

用数字,比如我用1-3的,3代表在轴最上方,2代表在轴中央,1代表在轴最下方,第一个点在轴中央,就用2表示,二进制就是10, 第二个点在轴上方,用3表示,二进制就是11,取完17个点之后

得到整个信号的采样为 10 11 10 01 10 11 10 01 10 11 10 01 10 11 10 01 10

202408171809.png

(PS: 真实情况中,每个点表示的位置绝不仅仅是在轴上下那么简单,还要考虑幅度多大,例如我们提升精度,用1-5表示,3代表轴中央,1代表轴最下方,2代表轴最下方二分之一处,5代表轴最上方,4代表轴最上方二分之一处。)

这样操作之后,我们得到以下几个概念。

术语

采样频率(sample rate): 每秒打17个点,就说它的采样率是17Hz。44.1KHz就是说1秒内打44100个点,96KHz就是1秒打96000个点。采样频率指单位时间内采集的点数。

采样深度(bit depth): 每个点只用1-3,也就是二进制01-11, 用了2个比特位 的精度表示,就说它的采样深度是 2 bits, 采样深度越大,每个点描述的振动位置就越精确,记录的动态也就越准。16 bits就是说每个点用16个二进制位表示0000 0000 0000 0000,转换成十进制范围就是0-65535,换句话说就是把动态空间分成65535段来表示在这个点时波形振动到了其中的哪一段位置。采样深度指取样中对声音强度记录的精细程度。

比特率(bit rate): 每秒17个点且每个点用2个比特位,它的比特率就是每秒17*2个比特(bit),就说它的比特率是34bps (bit per second)。 比特率指单位时间内传送的比特量。

声道(channel) : 实际操作中,我们处理的往往是立体声,甚至是环绕声,而不是单声道,每一个声道都是一个独立的采样序列,立体声就是左右声道,两个采样序列。如果本例是立体声,它的比特率则要翻一倍 34*2 = 68bps。

无损音频(lossless): 像本例这样的,直接采样得到后,没有经过任何压缩处理的,(比特率直接等于 采样频率*采样深度*声道 的),属于无损音频,即使它的采样频率和采样深度都非常低,它仍然是无损音频。所以,音质不一定和是否无损相关。

采样定理 :也叫香农采样定理、奈奎斯特采样定律,为了不失真地恢复模拟信号,采样频率应该不小于模拟信号频谱中最高频率的2倍。

采样频率不等于被采样的信号的频率,采样频率只是取点的频率,如果采样频率小于信号频谱最高频率的2倍,就会出现aliasing现象,还原的波形会发生畸变,点这观看aliasing现象。

我说个更直观的,比如以1Hz采样频率采样一个1Hz的Sine波,那把采样点连起来则是一个1Hz的三角波,他们虽然波形大致相同,但是泛音特征完全不一样的。

人耳听到的最高频一般在16000Hz左右 保留一点余量,就算是20000Hz,根据采样定理,取它的两倍做采样频率则是40000Hz (40KHz),这就是现代设备采样频率至少为44100Hz的原因。

为什么是44100

因为 44100 刚好等于 2^2 3^2 5^2 * 7^2

这会让运算变得简单

第二课、搭建开发环境

语言:C++

IDE:Visual Studio 或 Xcode

框架:JUCE

1.JUCE稳定,性能好,大厂认可,应用广泛。就说我知道的 FL STUDIO / STUDIO ONE / KORG / ARTURIA / M-AUDIO / UAD 的厂家都有采用过这个框架,还有些有些音源像是 Addictive Drums 2 都是基于 JUCE 开发的。另外音乐学院用的Max/Msp也是用JUCE写的。Pro Tools厂商的AAX开发文档中也包含了JUCE的相关文档。

avid-juce.jpg

2.跨平台。 一次编程,多格式导出AU,VST,VST3,AAX等各种格式的插件,还可以导出StandAlone独立应用的版本。

3.接口简单,功能强大。现在的JUCE变强了很多,支持了C++11、14,加入lambda表达式后绑定监听容易多了,而且加入了dsp模块,就不用再手动实现像是傅立叶、卷积那种算法了,更不用考虑那些算法的性能问题。现在滤波,混响都有相应的接口,而17年的时候还没有这些。

安装搭建环境

安装xcode 在 app store里直接安就行了,parallels desktop 上面点击下载进去下载下来直接安就行了,可以试用14天。

安装visual studio

点进去上面的点击下载进官网,下载community版本的visual studio(不是visual studio code),紫色的不是蓝色的。

下下来一个安装器,双击,选中“使用C++的桌面开发”

Screen-Shot-2020-06-10-at-1.06.45-PM-1.png

右下角安装即可。

安装JUCE

点击上面 “JUCE(点击下载)”,进官网下载personal版本的就行了,点personal下面的download。

配置JUCE

下载后解压 看到这些文件,点击那个Projucer,那是主程序。

Screen-Shot-2020-06-10-at-1.14.12-PM.png

然后让你登陆,没有账号注册一个就可以了

Screen-Shot-2020-06-10-at-1.19.01-PM.png

点最下面 I do not have an account 弹出一个网页注册

Screen-Shot-2020-06-10-at-1.19.32-PM.png

一直下一步,可能会验证邮箱,注册完关掉网页登陆,如果弹错把Projucer关了重开就可以登陆了。登陆后可能会选license ,选personal就可以了

来到JUCE主界面 创建一个音频插件试试

jucechuangjianchajian.jpg

第一次启动可能会提示你配置modules,如果直接放在C盘下一般会自动配置,如果右上角那个Modules Floder空的灰的,点右边那三个点,然后找到你下载JUCE解压的位置,选中里面的modules文件夹就行了

jucechuangjian2.jpg

然后选择你的IDE 点create创建

创建音频插件

为了验证所有功能完好,我们直接把这个工程在IDE中打开 点击这里

jucechuangjian33.jpg

直接生成

Screen-Shot-2020-06-10-at-1.50.20-PM-gssr.png

看一下生成到哪了 最下面 vst3后面那个路径(倒数第二行)

Screen-Shot-2020-06-10-at-1.52.10-PM-ysbg.png

进去看看

Screen-Shot-2020-06-10-at-2.03.08-PM-fhgu.png

真的有 复制到C:\Program Files\Common Files\VST3 里,打开测试用的DAW宿主扫一下加载它试一试。

Screen-Shot-2020-06-10-at-2.10.04-PM-xktl.png

成功了!

第三课、音频编程基础知识

认识JUCE框架的结构

继续上面的的工程,打开JUCE -> 在IDE里打开

jucechuangjian33.jpg

这次打开后就不要生成了,点击右边竖着的标签栏中解决方案资源管理器(第二个),展开源文件列表。

Screen-Shot-2020-06-10-at-2.31.14-PM.png

如果你的visual studio里没有这个“解决方案资源管理器”,就要通过点击视图->解决方案资源管理器来显示它。

Screen-Shot-2020-06-10-at-2.58.07-PM.png

选中NewProject_SharedCode -> NewProject -> Source文件夹(上上张图)

(NewProject就是上一节创建工程时默认的工程名 如果你创建的工程是其他名字,同理把NewProject换成其他名字)

Screen-Shot-2020-06-10-at-3.03.54-PM.png

这四个文件其实是两个东西: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头文件,看看有哪些声明

//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,了解这些方法的实现

//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

看一下本体

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一下 看下定义:

Screen-Shot-2020-06-10-at-8.26.33-PM.png

注释里提到了它是一个能读能写的序列,如果想要只读的,还有getReadPointer接口可供选择。

这意味着 要得到信号序列,直接读它返回的数组内容,要改变输出信号,直接写到它返回的数组的内容 就可以了。 这里的出入信号是相对于回调方法processBlock而言的。

2.获取信号序列

示例:立体声获取信号序列 数组 (processBlock回调中)

float* channelL = buffer.getWritePointer(0)  //左声道

float* channelR = buffer.getWritePointer(1)  //右声道

这样得到了channelL,channelR 左右声道 两个数字序列 数组,接下来我们需要知道每个序列数组里有多少采样点。

获取当前buffer块有多少个采样点:AudioBuffer的getNumSamples()

知道了采样点的的数量,写一个循环遍历调节两个声道数组里的所有点的值。

3.处理信号序列

示例:处理立体声信号(processBlock回调中)

在这里我把每个点都乘上0.5,这样所有点的振幅都小了一半,音量也就发生了改变

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 设置]

Screen-Shot-2020-06-10-at-9.20.23-PM.png

[Logic Pro X 上的 Buffer Size 设置]

Screen-Shot-2020-06-10-at-9.23.25-PM.png

[可以任意调节大小的 FL STUDIO 的 Buffer Size 设置]

Screen-Shot-2020-06-10-at-9.58.13-PM.png

AudioBuffer& buffer 中的 & 是什么意思?

参数前的&代表这个参数是引用传递,引用传递你可以理解为函数内的改变将会影响函数外传进来的这个参数的值,而正常情况下函数内对参数的改动不会影响函数外传进来的参数。

-> 正常情况:

void fun1(int x){ x=100;}

int main() { int input = 2; fun1(input); cout<<input;}    //输出2

-> 引用传递

void fun1(int& x){ x=100;}

int main() { int input = 2; fun1(input); cout<<input; }   //输出100

2. float* channelL = buffer.getWritePointer(0) 之后为何可以channelL[sample] ?

数组本质是指向该数组第一个元素的地址的指针

int myArray[3] = {0,1,2};

// myArray是一个指针 指向&myArray[0]

int* newPoint = myArray;

cout<<newPoint[1]; // 输出1

第二章:音频开发实战

第一课、制作第一个音量推子

下面一起开发一个简单的小插件练练手吧!

创建一个名为MyGain的工程 -> 在IDE中打开

界面

打开PluginEditor.h头文件,在字段声明里加一个推子

// PluginEditor.h

private:
    MyGainAudioProcessor& processor;
    Slider mySlider;      // 声明一个推子 <-
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MyGainAudioProcessorEditor)

然后打开PluginEditor.cpp,在构造方法中配置这个推子。

// PluginEditor.cpp

MyGainAudioProcessorEditor::MyGainAudioProcessorEditor (MyGainAudioProcessor& p)
    : AudioProcessorEditor (&p), processor (p)
{
    setSize (400, 300);

    mySlider.setBounds(0, 0, 200, 30);    // 推子大小与在界面上位置 <-
    mySlider.setSliderStyle(Slider::SliderStyle::LinearHorizontal);   // 推子什么样式 横的、竖的、圆...  <-
    mySlider.setTextBoxStyle(Slider::NoTextBox, true, 0, 0);          // 推子下方显示的文本框样式 <-
    mySlider.setRange(0, 1, 0.01);                                    // 推子根据进度返回0-1的数字 精度0.01 <-
   
    addAndMakeVisible(mySlider);
}

运算

打开PluginProcessor.h, 声明一个float型做音量

//PluginProcessor.h

    void getStateInformation (MemoryBlock& destData) override;
    void setStateInformation (const void* data, int sizeInBytes) override;

    float myVolume;                  // 声明一个float作音量 用来中转从PluginEditor.mySlider过来的值

private:

打开PluginProcessor.cpp, 修改processorBlock回调, 把里面内容全删了,这次设置音量直接用接口

//PluginProcessor.cpp

void MyGainAudioProcessor::processBlock (AudioBuffer<float>& buffer, MidiBuffer& midiMessages)
{
    buffer.applyGain(myVolume);           // 用AudioBuffer的接口 把myVolume变量的值设置为音量
}

连接界面与运算

回到PluginEditor.cpp,为mySlider编写一个监听方法,让推子被拖动时,就把推子的值赋给processor的myVolume中转变量。

// PluginEditor.cpp

MyGainAudioProcessorEditor::MyGainAudioProcessorEditor (MyGainAudioProcessor& p)
    : AudioProcessorEditor (&p), processor (p)
{
    setSize (400, 300);

    mySlider.setBounds(0, 0, 200, 30);    
    mySlider.setSliderStyle(Slider::SliderStyle::LinearHorizontal);  
    mySlider.setTextBoxStyle(Slider::NoTextBox, true, 0, 0);        
    mySlider.setRange(0, 1, 0.01);                                  

    mySlider.onValueChange = [this]() { processor.myVolume = mySlider.getValue(); };    // 监听方法:当推子值变动时(被用户拖动时)把推子的返回的值给processor里的myVolume变量 <-
   
    addAndMakeVisible(mySlider);
}

这样一个插件就写好了,生成一下放到宿主里试试

Screen-Shot-2020-06-11-at-5.07.54-PM.png

第二课、制作一个正弦合成器

输出波形

在第一章第三课当中,我们了解到:

1.波形信号在计算机中是一串用数值表示的点。

2.信号是在 processBlock 回调方法中被获取、处理与返回。

—信号输入到插件—> | processBlock | —信号从插件输出—>

具体要怎么做呢?

WHERE: 根据以上的信息,首先确定声音是在processBlock回调方法中产生的。

HOW: 之前的课程我已经讲过,在 processBlock 中,通过 getWritePointer 接口可以获取到写入数组的指针,然后用这个指针对 得到的写入数组的内容直接赋值,就可以实现输出信号的功能。(详见《音频开发技术(三)》)

WHAT: 正弦波信号 即 正弦波形状的点集 , 我们只需要向数组写入时域上是正弦波形状的点集就可以了。

正弦波形状的点集

Screen-Shot-2020-06-14-at-10.22.17-PM.png

示例: 输出 1000 Hz 的正弦波

分析:

1.确定用正弦函数 y = sin(x) 绘制正弦波的形状。

2.1000Hz 即 每秒振动1000次。设采样频率为N,则1秒内有有N个采样点,即每个过一个点,就完成(1000/N)次振动。

3.sin(x) 中 2Pi 个单位 为一次振动。

结合以上三点,得出 每经过一个采样点就相当于 在sin(x) 的横轴上移动了 2Pi * (1000/N) 个单位。

Sin(x) 的周期为 2Pi

Screen-Shot-2020-06-14-at-11.14.41-PM.png

好了,现在我们可以编写代码了,首先要知道当前的采样率是多少。JUCE 中用 getSampleRate() 接口获取 当前采样频率。

在JUCE中获取采样频率的接口

Screen-Shot-2020-06-14-at-10.59.30-PM.png

编写代码

新建名为 MyOsc 的工程,在IDE中打开,点开 PluginProcessor.h 文件,定义要用到的double字段

...
private:
    //==============================================================================
    double currentAngle = 0.0, volume = 0.1, frequency = -1;;     //  定义需要的字段 <-
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MyOscAudioProcessor)
...

然后来到 PluginProcessor.cpp 文件中 ,编写 processBlock 回调

void MyOscAudioProcessor::processBlock (AudioBuffer<float>& buffer, MidiBuffer& midiMessages)
{
    ScopedNoDenormals noDenormals;
    auto samepleRate = getSampleRate();

    auto* channelData = buffer.getWritePointer (0);
    auto* channelDataR = buffer.getWritePointer(1);
   

    for (auto sample = 0; sample < buffer.getNumSamples(); ++sample)
    {
        auto sample_pos = (float)std::sin(currentAngle);
        currentAngle += 2 * MathConstants<double>::pi * 1000 / samepleRate;

        channelData[sample] = sample_pos * volume;
        channelDataR[sample] = sample_pos * volume;
       
    }
}

以上代码实现了分析中内容。

验证

导出 -> 宿主中加载-> 成功听到了一个 1kHz 的纯音。

Screen-Shot-2020-06-15-at-4.55.43-AM.png

播放MIDI按键对应音符的频率

好了,现在我们已经完成了从频率到波形的输出,接下来实现音符到频率的转换。

获取Midi信号

processBlock回调的第二个参数,MidiBuffer中包含多个MidiMessage,而每个MidiMessage对象都相当于一个MIDI事件,每个MidiMessage都有它的事件类型(按键按下、抬起、哪一个键,力度多少等)

这里我们先用迭代器获取到MidiMessage对象,然后再通过MidiMessage对象的 getNoteNumber 接口访问到它的 NoteNumber(哪个键)。

然后又通过 MidiMessage::getMidiNoteInHertz() 接口把MIDI音符转化成频率,有了频率就可以用我们之前的代码发声了。

MidiMessage 对象的 isNoteOn() 意味着当前事件是按下按键触发的,isNoteOff() 则是代表是松开按键触发的。这里按键松开时 frequency 被赋值成 -1 以停止播放。

修改后的代码如下

void MyOscAudioProcessor::processBlock(AudioBuffer<float>& buffer, MidiBuffer& midiMessages)
{
    ScopedNoDenormals noDenormals;

    int pos;

    MidiMessage midi_event;

    for (MidiBuffer::Iterator i(midiMessages); i.getNextEvent(midi_event, pos);)
    {
        if (midi_event.isNoteOn())
        {
            frequency = MidiMessage::getMidiNoteInHertz(midi_event.getNoteNumber());
        }
        else if (midi_event.isNoteOff())
        {
            frequency = -1;
        }
    }

    if (frequency > 0)
    {
        auto totalNumInputChannels = getTotalNumInputChannels();
        auto totalNumOutputChannels = getTotalNumOutputChannels();

        auto* channelData = buffer.getWritePointer(0);
        auto* channelDataR = buffer.getWritePointer(1);

        auto samepleRate = getSampleRate();

        for (auto sample = 0; sample < buffer.getNumSamples(); ++sample)
        {
            auto sample_pos = (float)std::sin(currentAngle);
            currentAngle += 2 * MathConstants<double>::pi * frequency / samepleRate;

            channelData[sample] = sample_pos * volume;
            channelDataR[sample] = sample_pos * volume;
        }
    }
   
}

验证

成功了,按下按键,听到了对应的声音。

合成音色

合成器自然是指多个振荡器的合成,上方我们只写了一个振荡器,那么多个振荡器要如何合成呢。

很简单,就是每个采样点算数相加即可。

我们加一个高八度的音,高八度即频率翻倍,即 output = sin(x) + sin(2x)

只用改这一句

...
auto sample_pos = (float)std::sin(currentAngle) + (float)std::sin(2 * currentAngle);
...

验证

成功了,打开频谱仪,按下按键,看到了两个频率。

Screen-Shot-2020-06-15-at-6.30.14-AM.png

注意

插件导出前要设置成乐器才能接受到MIDI键盘的信号。

jucemyosc1.jpg

jucemyos2.jpg

这样设置之后再导出到IDE,在生成的插件就可以接受MIDI键盘的信号了。

一个正弦波合成器就完成了。

扩展阅读

输出三角波

有这种

Screen-Shot-2020-06-15-at-12.00.08-AM.png

这种

Screen-Shot-2020-06-15-at-12.00.38-AM.png

还有这种

Screen-Shot-2020-06-15-at-12.01.03-AM.png

输出方波

Screen-Shot-2020-06-15-at-12.02.06-AM.png

加法合成

加法合成即 通过多个正弦波相加模拟某种波形。

153962938_22_20190211023432304.gif

用加法合成的三角波

Screen-Shot-2020-06-14-at-6.47.29-PM.png

用加法合成的方波

Screen-Shot-2020-06-14-at-6.47.49-PM.png

加法合成的亮点在于,它可以模拟任何一种波形。

市场中的合成器

简单的合成器 3x Osc

Screen-Shot-2020-06-14-at-6.21.46-PM.png

复杂的合成器 Massive X

Screen-Shot-2020-06-14-at-9.24.07-PM.png

第三课、制作Kontakt音源

kontaktbiancheng1.png

前言

说起上一节中能自主产生波形的合成器,我们不免想到和它相对的,利用人工录制的采样发声的采样器。

比起开发一个采样器来说,开发Kontakt是更好的选择。

编写采样器有一些底层的,现阶段没有必要了解的内容。

开发Kontakt?

可能在我们印象中,Kontakt的作用就是打开它然后选音色用,和编程没什么联系。

但实际上,Kontakt是一个可编程的采样器。两个重点:

1.它是一个采样器。

2.它可被编程。

重新认识Kontakt

打开Kontakt,不加载任何音色

点击那个保存样子的按钮,然后选New instrument新建一个乐器

kontaktbiancheng2.jpg

然后就出现一个新的空乐器,点击扳手

kontaktbiancheng3.jpg

然后就出现了 Kontakt采样器功能的界面, 我们平时用的各种Kontakt音源就是在这里被制作的

Screen-Shot-2020-06-11-at-9.45.55-PM.png

先看一下界面的最下面有四排:

| InstrumentBuses | InsertEffects | SendEffects | Modulation |

这四排是可以展开的标签页,点左边的三角就可以展开/关闭。

它们全是效果,用的地方不同。

再看界面的最上面有五个按钮:

| Instrument Options | Group Editor | Mapping Editor | Wave Editor | Script Editor |

它们分别是:

乐器设置、群组编辑器、映射编辑器、波形编辑器、脚本编辑器。

制作音源(设置背景)

先看看第一个按钮,点一下Instrument Options(乐器设置)

Screen-Shot-2020-06-11-at-11.04.53-PM.png看到有个Instrument Wallpaper(乐器墙纸),那我们找张图

我在网上搜了张图,稍微P了一下,如下

kontaktbiancheng4.png

图需要png或tga的格式

设置成Wallpaper,点扳手回到主界面

kontaktbiancheng5.jpg

Screen-Shot-2020-06-11-at-11.39.55-PM.png

不管怎么说,背景改变了 (后期可以用代码控制界面大小来让背景显示的多一些)

制作音源(映射采样)

接下来点击第三个按钮:Mapping Editor(映射编辑器)

映射的意思是把采样(录音文件)对应到MIDI键盘上,当你某这个键的时候就播放上面对应的那个采样(录音文件)

我们先在左边的文件浏览器里找到我们准备的录音文件,我这里用的是洛天依的.wav格式的呼吸包做例子

Screen-Shot-2020-06-12-at-12.26.33-PM.png

从左边按住一个波形文件,拖动到对应的按键,我们看到现在这个音频对应了好几个键(这样的话这几个键按下去都是这同一个声)

kontaktbiancheng6.jpg

没关系,保持拖动不要放开,把鼠标向下移动一些,它就收窄了。(或者松开然后拖拽白块的左右边界也能调节宽窄)

kontaktbiancheng7.jpg

就这样把所有波形拖进来

Screen-Shot-2020-06-12-at-12.28.36-PM.png

按下键盘,有声音了!

力度分层:一个人大喊出来的声音和他平时说话的声音,除了音量不同,声音内容也是完全不同的,乐器也是如此。为了模拟乐器这种真实自然的效果,我们在音源制作中,常常会让一个按键对应多个录音文件,演奏时由力度决定采样器播放哪个音频文件。

力度体现在KONTAKT的映射编辑器里就是纵轴。刚才我们说过,映射块可以拖动左右边界调整映射到键盘的范围。其实这个映射块还可以上下拖动来调整这个录音文件对应的力度范围。

例如:图中选中的这个块(黄块)对应的力度是43-80

kontaktbiancheng9.jpg

群组编辑器与波形编辑器:本例中并不需要使用它们,我简单介绍下用在什么地方。

-> 群组编辑器的一个作用是切换音色,一架钢琴,后盖盖上和打开是不同的音色,如果希望把这两种音色都涵盖到音源里,那么就要录两遍给两个群组。开盖一遍,合盖再一遍,这样我们拥有两组映射。我们用写代码切换群组的方式切换音源当前的音色。

群组还有一个用途是合成,有些音源是好几个麦克风录的,实现多个麦克风的声音合到一起的就是多个群组一起播放。

再有就是管弦音源会通过群组摆位实现一些效果。

-> 波形编辑器:调整波形,告诉采样器这个波形要怎么用。比如我们的录音文件就1s,但我们希望在按着它时它能发出持续的声音,不是1秒就没了。这就要用波形编辑器,在里面设置个Loop。这个编辑器里还有很多细节,影响音源精细程度。

制作音源(开始编程)

介绍完了前四个按钮,就只剩最后一个按钮了,Script Editor(脚本编辑器)

点开什么也没有,灰色的一个条,点这个灰条左下角的Edit打开编辑窗口

kontaktbiancheng8.jpg

我们就在这个白框里写KSP脚本,写好点击右上角Apply应用脚本

Screen-Shot-2020-06-12-at-2.32.45-PM.png

认识KSP脚本

接下来我们一起认识一下KSP脚本语言

变量

{这是一条注释}

{数值变量}
declare $x := 1                      

{数值数组变量}
declare %myArr[2]           

%myArr[0] := 7
%myArr[1] := -10  

@myText – 字符串变量

!myArray[] – 字符串数组

const $myVariable – 数值常量

polyphonic $i – 也是数值变量。不同的是如果写在on note 里 多个音同时按下时 这个量对每一音都是独立的

界面组件

界面组件声明后会立即出现在界面上。

{旋钮:(0,100,1)的意思是取值0到100,每次移动1个单位。这里只是举个例子,可以自由调整}
declare ui_knob $mySlider(0,100,1)

{按钮:有按下和没按下的两种状态,可以直接用$myParam :=1设置它为按下的状态 }
declare ui_button $myParam;

ui_slider – 推子

ui_label – 纯文字

ui_menu – 下拉菜单

ui_switch – 切换开关(还是个按钮)

ui_table – 一个步进器 可以画力度、ARP之类的

ui_file_selector – 加载文件用的

ui_level_meter – 显示音量的电平表

ui_text_edit – 可输入文字的文本框

ui_value_edit – 可输入数值的数值框

ui_waveform – 波形显示器

回调方法

我们在 ‘音频开发技术(三)’里已经提到过,回调方法是框架让你写东西的地方,不同的回调会在不同的条件下被框架执行,你把功能写在里面,就可以在特定条件下执行你写在里面的功能。

KSP中所有代码必须写在回调方法里,外部没有写代码的地方

on init
{这里的代码在初始化时执行。你可以把初始化和构造方法理解为这个软件一打开就执行的代码}
end on


on note
{这里的代码在有MIDI音符被按下时执行,类似游戏引擎中的KeyDown}
end on


on release
{这里的代码在有MIDI音符被抬起时执行,类似游戏引擎的KeyUp}
end on


on ui_control
{这里的代码在界面组件和用户交互时(拖动了推子,点了按钮等)执行}
end on

内置变量

内置变量就是可以直接使用的变量,是系统定义并赋值的,我们通过它们从系统获取信息,比如当前哪个键被按着

了解内置变量前我们先了解一下MIDI,每次接收到MIDI信号都会触发on note回调函数,而每个MIDI信号被当成一个事件EVENT处理,每个事件都有自己的事件id,note number(哪一个键)和note velocity(力度),后两者都是0-127的数值。

{当前MIDI事件是哪个键被按下,返回0-127的数值,多用在on note里}
$EVENT_NOTE

{当前MIDI事件按下的键多少力度,返回0-127的数值,多用在on note里}
$EVENT_VELOCITY

{(常用)用来给set/get_engine_par接口指明调整/获取什么地方的参数 具体参数多是从KSP官方参考手册上查阅得来}
$ENGINE_PAR_相应功能

$EVENT_ID – 当前MIDI事件的ID,一些设计中会用到,多用在on note里

$PLAYED_VOICES_INST – 有多少个键在被按下

$NOTE_HELD – 音符正在被按着就是1,没被按着就是0

$ALL_GROUPS – 表示操作全部组,放在接口参数里用来操作全部的组

功能接口

{设置界面高度为几个格子}
set_ui_height(4)

{把界面组件放到指定格子的位置}
move_control(界面组件,1,1)

{在主音色窗口显示我们写的界面组件。写代码时预览窗会显示但回到主音色窗口就不显示,需要加这句}
make_perfview

{输出信息到Kontakt最下面的状态栏 一般用来Debug}
message("输出信息")

{设置旋钮显示的单位}
set_knob_unit(界面组件,表示单位类型的内置变量)

{设置旋钮默认的值}
set_knob_defval(界面组件, 默认的值)

{让kontakt自动保存和载入这个组件或变量的值}
make_persistent(界面组件/变量)

{设定指定的系统参数}
set_engine_par(表示要调整的地方的内置变量,数值,作用于第几个Group,第几个效果器,哪个效果条)

{获取指定的系统参数}
get_engine_par(表示要获取的地方的内置变量,作用于第几个Group,第几个效果器,哪个效果条)

set_key_color(按键MIDI编号,颜色) – 设置一个MIDI按键的颜色

play_note(哪个键,力度,样本偏移,持续多少毫秒) – 播放一个按键映射的采样,多用在on note里
ignore_event($EVENT_ID) – 无视当前MIDI事件,即按下去不发声,多用在on note里,之后常用play_note重定向

_get_engine_par_disp(表示要获取的地方的内置变量,作用于第几个Group,第几个效果器,哪个效果条) – 获取系统显示的参数

disallow_group(1) – 禁用或mute一个组,如果是全部组可用$ALL_GROUPS
allow_group(0) – 激活一个组

set_script_title("标题") – 设置代码编辑窗口的标题
set_ui_height_px(200) – 用像素设置界面高度
set_text(组件,新名) – 改显示名
set_knob_label() – 为旋钮设置显示内容

inc($myValue) – 自加,等同于$myValue = $myValue + 1
dec($myValue)) – 自减,等同于$myValue = $myValue – 1

wait(1000000) – 等待多少微秒,一秒等于1,000,000微秒

change_note($EVENT_ID, $EVENT_NOTE + 12) – 改变音高 本例中提高八度
change_velo($EVENT_ID, $EVENT_VELOCITY / 2) – 改变力度 本例中力度减半
change_pan($EVENT_ID, -1000, 0) – 移动摆位 本例中极左摆位
change_vol($EVENT_ID, -5000, 0) – 改变MIDI音量 本例中减少5db
change_tune($EVENT_ID, 50000, 0) – 改变音高偏移 本例中音高偏移50个cents

格子

kontakt 的界面规格通常是由‘格子’来衡量的,而不是像素

来自 Toby Pitman 的图,演示了 Kontakt 的 ‘格子’,图中格子的 x, y 颠倒了,比如 1,2 那个位置的格子实际是 2,1

kontaktbiancheng10.jpg

效果条

set_engine_par 和 get_engine_par 中最后一个参数的代表的不同效果条对应的值 Group:-1 Inseret:1 Send:0

kontaktbiancheng11.jpg

流程控制

KSP中用来判断用的是’=’,用来赋值的是’:=’。

on note

    {  if  }
    if($EVENT_VELOCITY>100)
        message("力度大于100")
    else
        message("力度小于100")
    end if


    {  while  }
    while($NOTE_HELD = 1)
        message("MIDI按键正在被按着")
    end while

end on

{ select }

select($EVENT_NOTE)

case 60

message("按了C3")

case 62

message("按了D3")

end select

查阅文档

真实开发中,我们需要一个字典一样的手册用来参考。

点此下载 Kontakt Script 官方参考手册 提取码: 8aqr

(若你所在的区域无法使用百度网盘,点击这里查看NI官网版本)

这手册像字典,不是用来看的,而是用来查的

下载下来,打开,然后按下快捷键 Ctrl + F (Mac是 command + F)

输入你要找的,比如 set_key_color,回车,阅读它的用法和各种颜色的代码。

开始写代码

我说过KSP所有代码必须写在回调方法里,外部没有写代码的地方,那我们先写一个回调:

on init
end on

这个回调里是在音源被加载/打开时被 kontakt 执行的

我们先用 make_perfview 打开音源显示界面组件的功能,然后用 set_ui_height() 调节一下界面高度

on init

make_perfview
set_ui_height(6)

end on

点击代码框右上角Apply提交

Screen-Shot-2020-06-13-at-9.32.37-PM.png

再点扳手回到主界面看到,设置高度后背景显示出来了

Screen-Shot-2020-06-13-at-5.36.50-PM.png

界面

接着,我们定义一个旋钮,然后用 move_control 功能接口把它移动到 ‘1,3’ 位置的格子上

有个细节,kontakt 不用小数,而是用整数,表示 0% 到 100% 的 0-1 对应在 kontakt 里是 0-1 000 000 。

千分之称之为毫,千分之的千分之称为微,把 0-1 变成(0,1000000,1)这样的参数就达到了微分精度。

201x 年左右,一些银行的金融系统处理小数运算是先乘 100 算完然后除 100,这和编程语言的特性有关。kontakt 这么做可能是同样原因。

on init

...

declare ui_knob $RevVol (0,1000000,1)
move_control ($RevVol,1,3)

end on

运行,出现了一个旋钮

Screen-Shot-2020-06-13-at-6.21.17-PM.png

同样地,定义四个旋钮

on init

...

declare ui_knob $RevVol (0,1000000,1)
move_control ($RevVol,1,3)

declare ui_knob $RevTime (0,1000000,1)
move_control ($RevTime,1,5)

declare ui_knob $DelayVol (0,1000000,1)
move_control ($DelayVol,2,3)

declare ui_knob $DelTime (0,1000000,1)
move_control ($DelTime,2,5)

end on

Screen-Shot-2020-06-13-at-6.25.03-PM.png

我们看到,这四个旋钮下方的数值都没有单位,用 set_knob_unit 功能接口为它们加上单位。

这接口怎么用呢,文档里搜一下,找到 $KNOB_UNIT_DB 和 $KNOB_UNIT_MS 参数 是我们需要的。

Screen-Shot-2020-06-13-at-6.47.04-PM.png

on init

...

set_knob_unit ($RevVol,$KNOB_UNIT_DB)
set_knob_unit ($DelayVol,$KNOB_UNIT_DB)

set_knob_unit ($RevTime,$KNOB_UNIT_HZ)
set_knob_unit ($DelTime,$KNOB_UNIT_HZ)


end on

好了,现在就显示单位了

Screen-Shot-2020-06-13-at-6.49.34-PM.png

接下来我希望让 kontakt 保存这些旋钮,否则当用户关闭再打开这个工程时,这些旋钮全部会自动清零,无法保存在工程中。使用 make_persistent() 的功能接口,可以让某个组件被自动保存在工程中 (在编程中,这个步骤叫做 数据持久化)

on init

...

make_persistent($RevVol)
make_persistent($DelayVol)

make_persistent($RevTime)
make_persistent($DelTime)

end on

好了,现在我们定义了控制混响音量和时长的旋钮,以及控制延迟音量和时长的旋钮,接下来要实现混响和延迟的功能。

Kontakt Script 实现音频效果的方式主要是 -> 挂上kontakt内置的效果器 -> 用代码控制内置效果器参数

我们先挂上效果器吧,有三个位置可以挂,Group, Insert, Send。自己根据需求挂不同地方,这里我把效果挂在 Insert 里。挂在不同的位置会影响到后面使用 set_engine_par 和 get_engine_par 的最后一个参数的不同。

set_engine_par 和 get_engine_par 中最后一个参数的代表的不同效果条对应的值 Group:-1 Inseret:1 Send:0

kontaktbiancheng12.jpg

我们来到InsertEffects标签页,点第一个效果框右下角的加号,挂上一个 Delay

Screen-Shot-2020-06-13-at-7.22.55-PM.png

然后同理,在第二个效果框上挂一个 Reverb

Screen-Shot-2020-06-13-at-7.23.12-PM.png

然后点击 Delay, 我们看一下,下面这些 Delay 的参数也就是在代码中用 get_engine_par 和 set_engine_par 交互到的参数。

Screen-Shot-2020-06-13-at-7.23.33-PM.png

Reverb也是同理。
我们根据自己的混音经验预先调整好一些,然后选几个常用的让我们写的界面组件控制。

Screen-Shot-2020-06-13-at-7.23.43-PM.png

用界面控制效果器

现在编写让界面组件和效果器的参数交互的代码。

那么,要把代码写在什么地方呢?我之前说过,ksp 的代码只能写在回调方法里,之前的代码都是写在 on init 回调里的,这个回调只在一开始打开 kontakt 时执行。

如果要让一段代码在每次旋钮转动时就设定效果器的参数,那显然不能写在 on init 里。因为 on init 里的代码只在音源一打开的时候执行一次,之后就不再执行了。

细心的同学应该看到,在上面的‘ -> 认识KSP脚本 的 回调方法 里’,还有 on ui_control 这个回调,它在界面组件和用户发生交互时执行,那就把代码写在这个回调里。

on ui_control($RevVol)
    set_engine_par($ENGINE_PAR_SEND_EFFECT_OUTPUT_GAIN, $RevVol, -1, 1, 1)
end on

首先,我们用 on ui_control() 后面的括号指定了是当 $RevVol 这个旋钮转动时才执行它里面的代码

然后我们用了

set_engine_par

来看一下这个接口的参数:

1.第一个参数是指定设置系统哪一种参数,这里的 ‘$ENGINE_PAR_SEND_EFFECT_OUTPUT_GAIN’ 它告诉 kontakt,我要控制的是效果器 wet 量,也就是效果器的效果音的成分。

2.第二个参数,就是指把要设置的系统参数改成什么值,同样也是 0-1000000 的范围,这个范围在 kontakt 里代表 0% 到 100%。这里我们直接把旋钮扭到的值赋给 wet 量,也就是效果音的量。(这就之前为什么把旋钮的取值设定为从0-1000000 : declare ui_knob $RevVol (0,1000000,1))

3.第三个参数为操作哪一个组,0代表第一个组,1代表第二个组,以此类推。这里我们的效果器是放在 Insert 里的,它作用于全局,不属于任何一个组,所以这里填上 -1。

4.第四个参数就是第几个效果块,英文管这叫slot中文是插槽的意思。看一下上面那张图,在 InsertEffects 标签页中,我们之在第一个插槽上挂的是Delay,第二个插槽上挂的Reverb。所以Reverb在第二个,那这里就写1。(0 代表第一个,1 代表第二个)

5.最后一个参数就是指在哪个效果条(标签页)里了,下面这张图在本文是第三次出现了。我是把效果器挂在InsertEffect标签页里的,这个标签页对应的的是1。这里就写1。

set_engine_par 和 get_engine_par 中最后一个参数的代表的不同效果条标签页所对应的值 Group: -1 Inseret: 1 Send: 0

kontaktbiancheng13.jpg

好,以此类推,我们再连接第二个旋钮

on ui_control($RevTime)
    set_engine_par($ENGINE_PAR_RV2_TIME, $RevTime, -1, 1, 1)
end on

和第一个按钮的代码一样,只是把参数换成了 $ENGINE_PAR_RV2_TIME,这是我在操作手册中查到对应混响时间的参数。

接着连接控制Delay的两个旋钮。

on ui_control($DelayVol)
    set_engine_par($ENGINE_PAR_SEND_EFFECT_OUTPUT_GAIN, $DelayVol, -1, 0, 1)
end on

on ui_control($DelTime)
    set_engine_par($ENGINE_PAR_RDL_TIME, $DelTime, -1, 0, 1)
end on

试一下,起效果了!

显示数值

等等,我们发现旋钮下方显示的值不太对,显示的是 0 – 1000000 的值,之前提到过,这个值在 kontakt 里 代表 0% 到 100%。

Screen-Shot-2020-06-14-at-1.01.13-AM.png

但是在 InsertEffects 里 Reverb 参数中的显示却是正确的。

Screen-Shot-2020-06-14-at-1.03.55-AM.png

于是我想把这里显示的值拿到旋钮下面显示,就用到这个接口:_get_engine_par_disp,它是获取系统参数显示出来的值,而不是对应 kontakt 的 0% 到 100% 的 0-1000000 的微分值。
获取到以后 用 set_knob_label 放到旋钮上显示

on ui_control($RevVol)
    set_knob_label($RevVol, _get_engine_par_disp($ENGINE_PAR_SEND_EFFECT_OUTPUT_GAIN,-1,1,1))
end on

Screen-Shot-2020-06-14-at-1.44.22-AM.png

如此类推,应用到四个旋钮上,界面就全部搞定了。

练习:

已知如下接口可以设置效果器的旁通 bypass

set_engine_par($ENGINE_PAR_SEND_EFFECT_BYPASS, 0, -1, 1或0, 1) :关

set_engine_par($ENGINE_PAR_SEND_EFFECT_BYPASS, 1, -1, 1或0, 1) :开

你能否设计两个按钮来控制 reverb 和 delay 的旁通效果?

(答案见源码)

键盘色彩

我听了一下这个呼吸包,发现里面有一些呼吸声比较甜,还有一些则像是吸面条般的声音,我不知道是用在哪的。

然后我想把比较甜的声音标记出来,在键盘上面,把它们的位置标红。

这里用到 set_key_color 接口,打开手册查一下它

Screen-Shot-2020-06-14-at-1.18.32-AM.png

根据这些参数。我设置了一下整体键盘的色彩,之前在Ksp中更多功能接口里写了,inc($x) 是 $x 自加的意思 x = $x + 1

on init
...
declare $keys
set_key_color(36, $KEY_COLOR_RED)
set_key_color(38, $KEY_COLOR_MAGENTA)
$keys := 40
while ($keys<52)
    set_key_color($keys, $KEY_COLOR_NONE)
    inc($keys)
end while

set_key_color(52, $KEY_COLOR_RED)
set_key_color(53, $KEY_COLOR_MAGENTA)
$keys := 54
while ($keys<72)
    set_key_color($keys, $KEY_COLOR_GREEN)
    inc($keys)
end while

set_key_color(72, $KEY_COLOR_RED)
set_key_color(74, $KEY_COLOR_MAGENTA)
$keys := 75
while ($keys<92)
    set_key_color($keys, $KEY_COLOR_CYAN)
    inc($keys)
end while
end on

Apply后,得到这样的效果

Screen-Shot-2020-06-14-at-1.21.20-AM.png

到这我们的音源就完成了。

扩展阅读

-> 如何录制多个Group 实现多套映射

我们之前在介绍 Group Editor 的时候提到过,多个 Group 可以实现音色切换、合成多个摆位的麦克风等功能。

这都是非常实用的功能,我们来看看是怎么做的。

1.先打开 Group Editor, 然后点击 Create Empty Group

kontaktbiancheng14.jpg

2.选中新的 Group,双击可以重命名。

Screen-Shot-2020-06-13-at-9.59.49-PM.png
3.打开 Mapping Editor,先取消勾选 ‘AutoSel. Grp'(是默认选中的),然后勾选 ‘Selected Groups only’。

kontaktbiancheng15.jpg
现在映射窗口里就是新的组了。

点击 Group1 再点新创建的 Group, 就看见映射窗的内容不同了(如果做过映射)。

-> Wave Editor 怎么用

同样地,之前在介绍 Wave Editor 时,我提到过,这个编辑器可以实现让一段不持续的音无限持续延长的功能。

现代作品中,这个技术在弦乐和人声比较常见,我们来看一下具体怎么做。

1.先把其他Editor关了,就打开 Mapping Editor 和 Wave Editor 两个窗口。

kontaktbiancheng16.jpg

2.在Mapping Editor 映射窗里中选中一个音频块,然后就在Wave Editor里看到它了。
接下来开启 Sample Loop 的开关,然后就出现了设置Loop范围的黄色的区域,接下来拖动黄色区域调整Loop的范围就行了。

kontaktbiancheng17.jpg

工欲善其事,必先利其器

在 kontakt 的 Script Editor 里写代码,除了最后 Apply 时知道对错之外,中途就完全没有任何信息。

选用一款合适的文本编辑器,可以大大提升我们的效率和体验。它可以像个助理一样告诉你代码的对错,提示你各种接口的用法,以及帮你自动输入变量和接口。

我们可以在外界用文本编辑器写好代码,写完再放到 kontakt 的 Script Editor 里运行。

打开 Sublime Text官网 下载对应你的系统的 Sublime Text -> 安装 -> 打开

然后在 Sublime Text 中 按下 Shift + Ctrl + P (Mac是 shift + command + P) 出现一个输入框,输入 install,选中这个 回车 (或点击它)

kontaktbiancheng18.jpg

等一会 出现这个界面

Screen-Shot-2020-06-13-at-8.12.32-PM.png

点确定,然后再次按下 Shift + Ctrl + P,输入 install, 选择 Install Package

等一会,就会出现了一个新的空的输入框,在新的输入框里输入 ksp,选这个,回车(或点击它)

kontaktbiancheng19.jpg

等一会弹出这个界面,整个就安装好了。

Screen-Shot-2020-06-13-at-8.28.55-PM-tajx.png

用的时候在右下角这里选上 KSP 类型

kontaktbiancheng21.jpg

Screen-Shot-2020-06-13-at-8.29.30-PM.png

就有代码提示和自动补全了

Screen-Shot-2020-06-13-at-8.34.24-PM.png

后记

一切技术在理解了以后都是简单的。如果我们觉得一个东西很厉害很高端,那只说明我们不够了解它。

第三章:音频开发进阶

第一课、保存插件状态

在第二章第三课中,我们讨论 KSP 脚本语言的时候, 谈及了 make_persistent 这个接口。

它的作用是让一个参数被系统自动保存,这样我们的参数就可以被记录在宿主软件的工程文件里,这个过程叫做 数据持久化。

在JUCE中,也是一样的,倘若我们的参数没有做过数据持久化,那么在工程下一次被打开时,所有参数都会全部消失,调好的旋钮、推子都会清零。

这一节,我们看一下JUCE中如何实现数据持久化,即如何保存应用的参数。

制作基本Gate门限器

新建一个 Audio Plugin 工程 MyGate -> IDE中打开

类似本教程第二章第一节课中写音量推子那样,不同的是这次的运算:判断如果音量没有达到阈值,则不输出。

PluginEditor.h

//PluginEditor.h
...
    MyGateAudioProcessor& processor;
    Slider Threshold;   // 声明一个推子 <-
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MyGateAudioProcessorEditor)
...

PluginEditor.cpp

//PluginEditor.cpp
...
MyGateAudioProcessorEditor::MyGateAudioProcessorEditor (MyGateAudioProcessor& p)
    : AudioProcessorEditor (&p), processor (p)
{
    Threshold.setBounds(0, 0, 300, 100);
    Threshold.setRange(0, 1, 0.01);
    Threshold.setSliderStyle(Slider::SliderStyle::LinearHorizontal);
    Threshold.setTextBoxStyle(Slider::TextBoxBelow,true,100,50);
    Threshold.onValueChange = [this] {processor._threshold = Threshold.getValue(); };
    addAndMakeVisible(Threshold);
    setSize (400, 300);
}
...

PluginProcessor.h

//PluginProcessor.h
...
    void setStateInformation (const void* data, int sizeInBytes) override;
    double _threshold = 0.0;     // 声明一个变量  <-
private:
...

PluginProcessor.cpp

//PluginProcessor.cpp
...
void MyGateAudioProcessor::processBlock (AudioBuffer<float>& buffer, MidiBuffer& midiMessages)
{
    ScopedNoDenormals noDenormals;
   
    auto* channelL = buffer.getWritePointer(0);
    auto* channelR = buffer.getWritePointer(1);

    for (auto sample = 0; sample < buffer.getNumSamples(); sample++)
    {
        channelL[sample] = std::abs(channelL[sample]) > _threshold ? channelL[sample] : 0;
        channelR[sample] = std::abs(channelR[sample]) > _threshold ? channelR[sample] : 0;
    }

}
...

1.这里我用了一个三目运算符,意思是:【条件 ? 条件成立返回的值 : 条件不成立返回的值】
2.为了方便,接收被赋值的变量时 我用了auto关键字,它就像是c#或js的 var 一样,能够自动判断类型。

验证

OK 没问题

Screen-Shot-2020-06-15-at-4.11.21-PM.png

持久化参数

改变参数的类型

我们先把 在 PluginProcessor 定义的变量 换成 JUCE 提供的 AudioParameterFloat 类型的指针. (同理还有 AudioParameterInt 等其他类型)

它在使用的时候和普通Float指针没区别。

PluginProcessor.h

//PluginProcessor.h
...
    // double _threshold = 0.0;
    AudioParameterFloat* _threshold;
...

从变量改成了指针,那用到它的地方就需要把它当作指针来使用,这样修改。

PlugiProcessor.h

//PlugiProcessor.h
...
        // channelL[sample] = std::abs(channelL[sample]) > _threshold ? channelL[sample] : 0;
        // channelR[sample] = std::abs(channelR[sample]) > _threshold ? channelR[sample] : 0;
        channelL[sample] = std::abs(channelL[sample]) > *_threshold ? channelL[sample] : 0;
        channelR[sample] = std::abs(channelR[sample]) > *_threshold ? channelR[sample] : 0;
...

还有这里也要修改。

PluginEditor.cpp

//PluginEditor.cpp
...
    // Threshold.onValueChange = [this] {processor._threshold = Threshold.getValue(); };
    Threshold.onValueChange = [this] {*(processor._threshold) = Threshold.getValue(); };
...

我们希望每次重新打开工程的时候,界面上的推子的位置也是我们的保存的参数。来到 PluginEditor 的构造方法,加上这句。

PluginEditor.cpp

//PluginEditor.cpp
...
    Threshold.onValueChange = [this] {*(processor._threshold) = Threshold.getValue(); };
    Threshold.setValue(*(processor._threshold));          // 设置推子组件的值为保存的参数 <-
    addAndMakeVisible(Threshold);
...

负责持久化的回调函数

记得在第一章第三节课当中我们提到过,PluginProcessor中有一大堆方法都是可选功能吗。

持久化的回调函数就在这一大堆可选功能中,在其中找到 getStateInformation 和 setStateInformation 这两个回调。

getStateInformation 储存到插件的信息到内存

setStateInformation 从内存中提取插件的信息

getStateInformation 中用 MemoryOutputStream 对象的 writeFloat 方法

Screen-Shot-2020-06-15-at-5.33.38-PM.png

setStateInformation 中用 MemoryInputStream 对象的 readFloat 方法

Screen-Shot-2020-06-15-at-5.32.37-PM.png

现在编写代码。

PluginProcessor.cpp

//PluginProcessor.cpp
...
void MyGateAudioProcessor::getStateInformation (MemoryBlock& destData)
{
    MemoryOutputStream(destData, true).writeFloat(*_threshold);
}

void MyGateAudioProcessor::setStateInformation (const void* data, int sizeInBytes)
{
    *_threshold = MemoryInputStream(data, static_cast<size_t>(sizeInBytes), false).readFloat();
}
...

生成导出试了一下,已经可以保存参数了。

持久化的细节

我们只是把插件的信息写到了内存中,而最终的持久化还是DAW宿主软件为我们做的。

宿主软件把内存中的信息编码保存在了工程文件中。

第二课学会使用API文档,编写一个混响插件

框架的时代

如今,我们输出一行 “Hello World” 到屏幕上,已经不需要手动地把数据移动到显存里面了。一句简单地语句就可以搞定。高级程序语言本身就是一种框架。

不要重复发明轮子

当你要开发的功能 已经存在了成熟的框架,我劝你最好使用它们。

相比一个人从头开始写底层功能,现成的框架已经有了一段时间的积累,它们更稳定,更安全,优化更好,而且还方便。

当然如果你对某些功能有更深入的理解,或是为了长远的打算,编写一个框架也是件很了不起的事。

无论用什么框架开发,都离不开对开发文档的查阅

本节我们就了解一下 如何使用 JUCE 开发文档,编写一个 Reverb 混响插件。

1.明确需求

一个混响效果器

2.收集信息

我们先来到 JUCE的开发文档(点击打开), 看看有没有用得上的东西。

网页里,按下Ctrl + F (Mac 上 Command + F), 搜索 reverb 然后回车

找到这个,应该是我们想要的

kontaktbianchengrev1.jpg

点进去,看到 Description 里说了,先用 setSampleRate 接口准备一下,然后用 processStereo 或者 processMono 处理信号就行了

Screen-Shot-2020-06-15-at-7.34.15-PM.png

看完说明,就大概了解了它的用法。(以后我们接触的东西多了,就不用看说明了,这些方法大同小异)

然而现在我们还有一个需求没有得到解决,那就是调节参数。

继续寻找,在上方的 Member Functions 功能接口的描述中,看到了 setParameters 接口,它的传入参数是一个 ‘Parameters’ 类型 的东西,我们点一下那个 Parameters。

发现它是个结构体,里面的字段就是混响的参数,这些参数都是默认初始化过的。

好了,现在已经收集到需要的全部信息了。

3.整理思路

我们看到上面 Reverb 的文档中写的都是些成员方法,理所当然就想到实例化一个 Reverb 的对象来用,在JUCE里,这是不必要的。

Reverb 是来自JUCE库里的类,它的对象不用 new 不用 Reverb() 实例化创建,只要声明了它,JUCE 框架就会自动为我们注入一个合适的 Reverb 对象。所以把它当成结构体就可以了,【Reverb 对象声明完直接使用】。(这是一个好架构,它让开发者专注于业务逻辑,避免分心)

有别于其他语言,C++实例化对象是可以不new的。

一般来说对象都是保存在堆上,但C++可以通过这种不new的方式直接把它保存在栈上,这样实例化出来的对象只作用于函数内部,函数执行后就删除了。

setParameters 用到的参数是个结构体,值类型的声明后直接用就行了。

Reverb 对象需要在使用前调用一下 【setSampleRate】接口。在第二章第二节课的时候,我们使用了一个 getSampleRate 接口获取采样频率,而这个接口并不是每一次都有返回。为了更好的稳定性,我们选用PluginProcessor 的回调 【prepareToPlay (double sampleRate, int samplesPerBlock) 】中的采样频率。

最后在 processBlock 中用 【processStereo】 接口处理立体声,这个接口的传入的参数是 左边的信号序列指针,右边的信号序列指针,处理多少个点。

这三个参数刚好就是 buffer.getWritePointer(0), buffer.getWritePointer(1), buffer.getNumSamples()。

好了,我们已经知道怎么做了

4.付诸行动

-> 数据处理

首先在 processor 的头文件准备好 Reverb 对象 和 它的 参数结构体

// PluginProcessor.h
...
Reverb reverbInstance;
Reverb::Parameters reverbParameters;
private:
...

然后在 prepareToPlay 回调里,调用 Reverb 对象的 setSampleRate 接口

// PluginProcessor.cpp
...
void MyReverbAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock)
{
reverbInstance.setSampleRate(sampleRate);
}
...

在信号处理的回调 processBlock 中, 把 buffer 写入指针 和 采样点数量的信息 直接传给 Reverb 对象的 processStereo 接口。

// PluginProcessor.cpp
...
void MyReverbAudioProcessor::processBlock (AudioBuffer&amp; buffer, MidiBuffer&amp; midiMessages)
{
ScopedNoDenormals noDenormals;

for (auto sample = 0; sample &lt; buffer.getNumSamples(); sample++)
{
reverbInstance.processStereo(buffer.getWritePointer(0), buffer.getWritePointer(1), buffer.getNumSamples());
}
}
...

运算的部分就写完了。

那么 setParameters 在哪呢? 我们还没有写,要知道processor的初始化是早于editor的,毕竟它是作为editor构造方法的参数被传入的,那既然 processor 中没有调用 setParameters 接口,在设置参数前没有参数的情况下 processStereo 被调用了怎么办呢。

没有关系,Reverb 已经为我们初始化了一个默认的结构体,这个默认的结构体里包含默认的参数值。

F12 到 Reverb 里看一下它的定义

Screen-Shot-2020-06-15-at-11.51.34-PM.png

-> 界面

在头文件里声明一个推子

// PluginEditor.h
...
MyReverbAudioProcessor&amp; processor;
Slider RoomSize;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MyReverbAudioProcessorEditor)
...

我们已经知道了 Reverb::Parameters 是有默认值的,于是我们调整推子的位置到默认的参数的地方。

然后写推子监听方法:当推子位置改变时调节 reverbParameters 结构体,并把它应用到 reverbInstance 上。

// PluginEditor.cpp
...
MyReverbAudioProcessorEditor::MyReverbAudioProcessorEditor (MyReverbAudioProcessor&amp; p)
: AudioProcessorEditor (&amp;p), processor (p)
{
RoomSize.setRange(0, 1, 0.01);
RoomSize.setBounds(0, 0, 300, 50);
RoomSize.setSliderStyle(Slider::SliderStyle::LinearHorizontal);
RoomSize.setTextBoxStyle(Slider::TextBoxBelow,true,100,50);

RoomSize.setValue(processor.reverbParameters.roomSize);
RoomSize.onValueChange = [this]
{
processor.reverbParameters.roomSize = RoomSize.getValue();
processor.reverbInstance.setParameters(processor.reverbParameters);
};

addAndMakeVisible(RoomSize);
setSize (400, 300);
}
...

这里 []{} 也就是 [](){}, C++ 中 Lambda 表达式的小括号 ‘()’ 没有参数时可以省略

5.验证 & 反馈调节

OK 没有任何问题

Screen-Shot-2020-06-16-at-1.53.02-AM.png

第三课、学会使用DSP模块,制作一个EQ效果器

JUCE 在 5.1 版本之后加入了 dsp 模块,从此可以通过调用接口的方式实现各种音频效果,降低了对开发者算法水平的要求。

确定目标

一个 EQ 效果器

收集信息

在开发文档中的 dsp 分区里,发现 Filter 是我想要的。

在查阅相关资料后,得知 Filter 对象 有三个接口需要调用 :

1.prepare(ProcessSpec&)- 在处理信号前调用,设定采样率等规格参数,必须调用

2.reset – 重置通道

3.process(ProcessContextReplacing或ProcessContextNonReplacing) – 处理信号

查到 ProcessorDuplicator 把一个处理器变成双声道立体声两个处理器,同时它为被代理的处理器(比如这里的 dsp::IIR:Filter ) 实现一种适配器的模式。这种模式中对 ProcessorDuplicator 的操作和对原处理器(比如这里的 Filter )的的操作极为类似。(在这里就是 ProcessorDuplicator 对应 Filter, ProcessorDuplicator 的参数字段 state 对应 Coefficients )

整理思路

用 ProcessorDuplicator 代理 Filter,即 Filter 的 prepare、reset、process 都换成 ProcessorDuplicator 的。此外 ProcessorDuplicator 的 state 对象等同于 Filter 的 Coefficients的作用。

用 dsp::IIR::Coefficients::makePeakFilter 接口为 state(代理的Coefficients)参数赋值,用以设置滤波器参数。

在prepareToPlay中调用 prepare,releaseResources调用reset。

在Audio Plugin中 输出和输入同源,输入输出都是 getWritePoint 这个数组,所以 process 采用 ProcessContextReplacing 这个参数,而 ProcessContextReplacing 也需要一个AudioBlock参数才能初始化。

(ProssContextReplacint(AudioBlock(buffer))

付诸行动

先要在JUCE工程中勾选DSP模块,才能打开DSP功能使用 dsp 命名空间。

Screen-Shot-2020-06-16-at-5.06.09-AM.png

声明推子

// PluginEditor.h
...
    MyEqAudioProcessor& processor;
    Slider HF_Vol;  // 声明一个推子 <-
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MyEqAudioProcessorEditor)
...

让推子影响变量

// PluginEditor.cpp
...
MyEqAudioProcessorEditor::MyEqAudioProcessorEditor (MyEqAudioProcessor& p)
    : AudioProcessorEditor (&p), processor (p)
{
    HF_Vol.setRange(0.01, 1, 0.01);
    HF_Vol.setBounds(0, 0, 100, 50);
    HF_Vol.setSliderStyle(Slider::SliderStyle::LinearHorizontal);
    HF_Vol.setTextBoxStyle(Slider::TextBoxBelow, true, 100, 30);;
    addAndMakeVisible(HF_Vol);

    HF_Vol.onValueChange = [this]
    {
        *(processor.myFilter.state) = *(dsp::IIR::Coefficients<float>::makePeakFilter(processor.getSampleRate(),2400.0f,0.7f,HF_Vol.getValue()));  
    };

    setSize (400, 300);

    *(processor.myFilter.state) = *(dsp::IIR::Coefficients<float>::makePeakFilter(processor.getSampleRate(), 2400.0f, 0.7f, HF_Vol.getValue()));
}
...

声明处理器

// PluginProcessor.h
...
    void setStateInformation (const void* data, int sizeInBytes) override;
    dsp::ProcessorDuplicator<dsp::IIR::Filter<float>, dsp::IIR::Coefficients<float>> myFilter;     // 声明一个处理器 <-
private:
...

prepare 与 reset

// PluginProcessor.cpp
...
void MyEqAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock)
{
    dsp::ProcessSpec ps;
    ps.maximumBlockSize = samplesPerBlock;
    ps.numChannels = getTotalNumOutputChannels();
    ps.sampleRate = sampleRate;

    myFilter.prepare(ps);
   
}

void MyEqAudioProcessor::releaseResources()
{
    myFilter.reset();
}
...

处理器处理信号

// PluginProcessor.cpp
...
void MyEqAudioProcessor::processBlock (AudioBuffer<float>& buffer, MidiBuffer& midiMessages)
{
    ScopedNoDenormals noDenormals;
   
    myFilter.process(dsp::ProcessContextReplacing<float>(dsp::AudioBlock<float>(buffer)));
}
...

这样就完成了

结语

在音频开发的路上,恭喜你学到这里你已经到达十级,现在是时候走出新手村,开始第一次转职了。

接下来有三条路线:信号处理、音频应用、KONTAKT音源。

你可以任选一条你喜欢的。

一、信号处理

信号处理关注音频效果的底层算法。

如果你是信号处理专业的学生、教育从业者、科学家、算法设计师、SDK开发者(音频类库),或是想成为一个高手,这是最适合你的选择。

[小一段讲解滤波器的公式]

Screen-Shot-2020-06-12-at-5.01.14-AM.png

我推荐这本书,它讲述了必备的数学基础与大部分音频效果的原理,并且用JUCE做了演示:音频效果的理论、实现与应用 提取码: 4uqk | 这里是书中内容的源码实现

二、音频应用

音频应用关注制作成品效果器/软件。它更多使用成熟稳定的类库与接口,而较少关注算法。

如果你是效果器开发者、音频软件开发者这种追求稳定的,或者是希望短期内快速出一个演示Demo、毕业项目之类的,这是最合适的选择。

[JUCE实现的EQ]

Screenshot.png

点击下载上图插件源码

学习JUCE,乃至任何一个框架,最好的办法是观看官方的教程、查阅官方的开发文档。然后就是多看代码,Github上有不少优秀的项目可供学习。要开发界面成熟高端的音频应用,最好了解一下JUCE的图形库。

点击访问:JUCE官方教程 | JUCE开发文档 | 上Github看相关的源码

三、KONTAKT音源

KONTAKT音源关注音响质量,编程上略为简单。

如果你相比编程更擅长录音技术、具有设备优势、总是在混音上有想法,那这是最好的选择。

[实用的KONTAKT音源]

Screen-Shot-2020-06-12-at-5.38.47-AM.png

KONTAKT可以通过看代码学习,加载任何一个KONTAKT音源,点扳手,再点Scirpt Editor里的Edit,可以看到代码,另外这里有两本KONTAKT编程的教程

Kontakt Script 简易教程 提取码: q5a3

Kontakt Script 官方参考手册 提取码: 8aqr

END...