博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

用c++设计音效插件 :2 音频插件的剖析

Posted on 2022-06-16 22:30  pencilCool  阅读(730)  评论(0编辑  收藏  举报

翻译自: https://learning.oreilly.com/library/view/designing-audio-effect/9780429954313/xhtml/Ch02.xhtml#sec2_1

关于AAX、AU和VST3的音频插件规范,首先要了解的是:它们在本质上都是一样的,它们都实现了相同的属性和行为集,专门针对以伪通用的方式包装音频信号处理软件的问题。插件被封装在C++对象中,从制造商提供的指定基类或基类集合中派生出来。插件宿主将插件对象实例化,并收到一个指向新创建对象的基类指针。主机只能调用它在API中定义的那些功能,并且要求插件正确地实现这些功能,以便被认为是DAW的一个适当的插件--如果所需的功能缺失或实现不正确,插件将失败。这在制造商和插件开发者之间形成了一个合同。你对不同的API钻研得越深,你就越能意识到它们内部的相似性。这一章就是关于API之间的相似性--了解这些底层结构细节将有助于你的编程,即使你使用的是JUCE或我们新的ASPiK这样的框架。在本书的其余部分,我将提到插件和主机。插件当然是我们的小信号处理宝石,它要对音频做一些有趣的事情,被打包成C++对象,而主机是DAW或其他软件,它加载插件,获得一个指向其基本基类的指针,并与之交互。

每个插件的API都被打包在一个SDK中,这实际上只是一堆充满C++代码文件和其他插件组件的文件夹。所有的API都没有预编译的库来连接,所以所有的代码都在那里,你可以看到。要使用任何框架,如JUCE或ASPiK,你需要下载你想支持的每个API的SDK。SDK总是附带有示例代码;你要做的第一件事就是打开一些代码并检查一下,即使你使用的是一个隐藏了实现细节的框架。记住:不要害怕。所有的API都是做同样的基本事情。是的,有很多API特定的东西,但你真的需要超越这些,寻找相似之处。本章将帮助你做到这一点,而接下来的四章将提供更多的细节和洞察力。一个好的练习是打开SDK提供的最基本的样本项目(通常是一个音量插件),然后试着在代码的某个地方找到本章中的每一个部分。

2.1 插件包装。动态链接库(DLLs)

所有的插件都被打包成动态链接库(DLLs)。你有时会看到这些被称为动态链接或动态链接,但它们都是指同一件事。从技术上讲,DLL是微软对共享库的一个特定术语,但这个术语已经变得如此普遍,似乎已经失去了对其母公司的依恋。我们将普遍使用它来指一个预编译的库,可执行文件在运行时而不是在编译时与之链接。可执行文件是DAW,预编译的库是DLL,也就是你的插件。如果你已经了解了DLL的工作原理,那么你可以安全地跳过这一节,但如果你对DLL是陌生的,那么无论你打算如何编写插件,都必须了解这一概念。

要了解DLLs,首先要考虑静态链接库,你可能已经使用过了,也许是在不知不觉中。C++编译器包括一组预编译的函数库,供你在项目中使用。也许其中最常见的是数学库。如果你试图使用sin()方法,你通常会在编译时得到一个错误,说明 "sin()没有定义"。为了使用这个函数,你必须链接到包含这个函数的库。这样做的方法是将#include <math.h>放在文件的顶部。根据你的编译器,你可能还需要告诉它要链接到math.lib。当你这样做时,你是在静态链接到math.h库,这是一个预编译的数学函数集,在一个.lib文件中(该文件在MacOS中以.a为后缀)。静态链接也被称为隐式链接。当编译器遇到一个数学函数时,它会用库中的预编译代码替换函数调用。通过这种方式,额外的代码被编译到你的可执行文件中。你不能取消对数学函数的编译。你为什么要这样做呢?假设在sin()函数中发现了一个错误,math.h库必须被重新编译和重新发布。那么你就必须用新的math.h库重新编译你的软件,以获得错误的修复。图2.1a形象地显示了静态链接,其中math.lib代码在主机代码中。

解决我们的数学错误问题的方法是在运行时链接到这些函数。这意味着这些预编译的函数将存在于一个单独的文件中,我们的可执行文件(DAW)将知道并与之通信,但只有在它开始运行之后。这种链接方式被称为动态链接或显式链接,如图2.1b所示。包含预编译功能的文件就是DLL。其优点是,如果在库中发现了一个错误,你只需要重新发布新编译的DLL文件,而不是用固定的静态库重新编译你的可执行文件。另一个优点是,这个系统的设置方式--在运行时连接到一个组件的主机--可以完美地作为一种扩展主机功能的方式,而主机在编译时不知道任何关于该组件的信息。它也是建立一个插件处理系统的理想方式。主机成为DAW,你的插件是DLL,如图2.1c所示。

图2.1:(a)一个编译有静态链接数学库的主机。(b) 一个与DLL版本进行通信的主机。(c) 一个插件版本。

当主机第一次启动时,它通常会经历一个过程,试图加载所有它能在你指定的文件夹(Windows)或定义好的API特定文件夹(MacOS)中找到的插件DLLs。这个初始阶段是为了测试每个插件,以确保用户决定加载它时的兼容性。这包括检索信息,如插件的名称,主机将需要这些信息来填充自己的插件菜单或屏幕,还可能包括对插件的基本甚至严格的测试。我们将此称为验证阶段。对于插件开发者来说,这可能是一个非常令人沮丧的阶段:如果你的插件在加载代码中出现了编译器没有捕捉到的运行时错误,它很可能会使主机应用程序崩溃。(Reaper®是一个例外;如果你的插件加载失败,它会告诉你,但它通常不会因此而崩溃)。此外,主机DAW可能会禁止自己在未来再次加载你的插件。这一点在Apple Logic®中尤为明显,如果你的插件验证失败,它将永久禁止你的插件。这可能会使插件的开发变得混乱。你可以在www.willpirkle.com 找到一些处理验证阶段错误的策略,但你能做的最好的事情是在主机中运行你的插件之前对它们进行预验证。AU和VST提供了这样的机制。

通常情况下,主机在验证阶段后会卸载所有的插件,然后等待用户在会话中把插件加载到一个活动轨道上。我们将此称为加载阶段。验证阶段至少已经做了一次这样的工作(如果不是全部的话);如果你在验证期间有bug,它们往往是在加载操作中引起的。当你的插件被加载时,DLL被拉入可执行文件的进程地址空间,这样主机就可以从它那里检索到一个指针,该指针在DAW自己的地址空间内有一个地址。对于大多数人来说,这是他们使用插件的唯一方式。在进程之间还有一种不太常见的方式,即你的DAW会从另一个可执行文件的地址空间加载一个插件。加载阶段是大部分插件描述发生的地方。描述是你需要了解的插件设计的第一个方面,而且它大多是相当简单的东西。

在加载阶段完成后,插件进入处理阶段,这通常在主机上实现为一个无限循环。在这个阶段,主机向你的插件发送音频,你那很酷的DSP算法对其进行处理,然后你的插件将改变后的数据送回主机。这是你的大部分数学工作发生的地方,这也是本书的重点。所有的插件项目都被设计成使这一部分变得简单,并且可以在不同的API和平台上普遍地传送。在处理时,用户可能会保存DAW会话的状态,你的插件的状态信息也必须被保存。他们以后可能会加载那个会话,你的插件的状态也必须被调用。

最终,你的插件将被终止,要么是因为用户卸载它,要么是因为DAW或其会话被关闭。在这个卸载阶段,你的插件将需要销毁任何分配的资源并释放内存。这是你的插件可能使主机崩溃的另一个潜在地方,所以一定要反复测试加载和卸载。

2.2 插件描述。简单的字符串

首先要从插件的描述开始。每个API都为插件指定了一个稍微不同的机制,让主机知道关于它的信息。其中有些非常简单,只需要填写几个字符串:例如,插件希望主机向用户展示的带有其名称的文本字符串(如 "Golden Flanger")。插件描述中最简单的部分涉及到定义插件的一些基本信息。这通常是在代码的适当位置用简单的字符串设置完成的,有时也可以在编译器本身中完成。这些包括最基本的信息类型:

  • 插件名称
  • 插件短名(仅AAX)
  • 插件类型:合成器或FX,并且根据 API的不同而不同 -
  • 插件开发者的公司名称(供应商)。
  • 供应商的电子邮件地址和网站URL

这些描述是用简单的字符串或标志来设置的,没有什么好讨论的。如果你使用ASPiK,你将在一个文本文件中定义这些信息--而且你只需花30秒就可以完成。有一些四字代码需要为AU、AAX和VST3设置,这些代码可以追溯到早期的VST1。这些代码是由四个ASCII字符组成的。在整个API中,有两个这样的四字代码,加上一个AAX特定的版本,如下所示:

  • 产品代码:对于你的公司销售的每个插件,必须是唯一的。
  • 供应商代码:对我们公司来说,它是 "WILL"
    AAX需要另一个叫做AAX插件ID的代码,它是一个多字符的字符串,而VST3需要另一个插件代码。这个128位的代码被称为全球唯一标识符(GUID,发音为 "goo-id");它也被称为普遍唯一标识符(UUID)。Steinberg把它称为FUID,因为它可以识别他们的FUnknown对象(FUnknown IDentifier),但它与标准GUID相同。GUID是用一个特殊的软件以编程方式生成的;大多数操作系统都有免费的版本。该软件的目的是根据你向它请求数值的时间点,以及它从你的网络适配器或其他内部位置获得的其他数字,创建一个真正唯一的数字。这个时间点是指从1582年10月15日午夜开始的100纳秒的时间间隔的数量。如果你使用ASPiK,这个值会在你创建项目时为你生成。
2.2.1 插件的描述。功能和选项

除了这些简单的字符串和标志之外,还有一些更复杂的东西需要插件在加载时为主机定义。这些东西在这里列出,并在前面的章节中描述。

  • 插件想要一个侧链输入
  • 插件在信号处理中创造了一个延迟(delay),需要通知主机。
  • 插件在播放停止后创建了一个混响或延迟 "尾音",需要通知主机它想包括这个混响尾音;插件为主机设置尾音时间
  • 插件有一个自定义的GUI,它想为用户显示。
  • 插件希望显示Pro Tools的增益减弱表(AAX)。
  • 插件工厂预置

最后,还有一些必须生成的MacOS特定代码,称为捆绑标识符或捆绑ID。这些也应该是你为你的每个插件设计创建的唯一代码,是MacOS组件处理系统的一部分。如果你使用过Xcode,那么你已经知道了这些通常在编译器本身或Info.plist文件中输入的值。与VST3 GUID一样,如果你使用ASPiK,这些捆绑ID值会为你生成,你永远不需要关心它们。

2.3 初始化。定义插件的参数界面

今天我们设计的几乎所有的插件都需要某种GUI(也叫UI)。用户通过与GUI的交互来改变插件的功能。GUI上的每个控件都与一个插件参数相连。插件参数可能是每个API中最复杂和最重要的方面;接下来的几章大部分内容都会提到这些参数。插件必须在插件加载阶段向主机声明和描述这些参数。

有三个基本原因使这些参数需要被公开。

如果用户创建了一个包括参数自动化的DAW会话,那么主机将需要知道如何在播放过程中改变插件对象的这些参数。
所有的API都允许插件声明 "我没有GUI",而是为用户提供一个简单的GUI。这个默认的用户界面的外观是留给DAW程序员的;通常它是非常简单和稀疏的,只是一堆滑块或旋钮。
当用户保存DAW会话时,参数状态被保存;同样,当用户加载一个会话时,参数状态也需要被调用。
实施细节将在接下来的章节中描述,但是GUI、插件参数、主机和插件之间的关系在所有的API中都是基本相同的,所以我们可以用更抽象的术语讨论这种关系。请记住,这些API基本上都是一样的 让我们想象一下,一个名为VBT的简单插件,它有一些参数供用户调整。

音量
低音增强/削减(dB)
高音增强/削减(dB)
通道输入/输出:左、右、立体声

image

图2.2:(a)一个简单的插件GUI。(b) 在这个GUI中,每个GUI控制都连接到一个底层的参数对象;主机收集控制的变化,它们作为参数更新被传递到插件中。(c) DAW的自动化工作方式相同,只是主机与参数而不是用户/GUI进行交互(注意通道参数是不可自动的)。

图2.2a显示了我们为VBT设计的一个简单的图形用户界面。每个GUI控件都对应着一个底层的插件参数。插件必须在加载阶段声明这些参数,每个GUI控件有一个参数。无一例外,这些参数都是作为C++对象实现的,声明的结果是这些对象的一个列表。这个参数列表的实际位置取决于API,但它一般存储在一个插件基类对象中,DAW有一个机制来获取和设置列表中的参数值。当用户存储和加载一个包含插件的会话时,它就是这样存储和加载插件状态的。在这些图中,我将显示这个参数列表是主机的一部分,不管它实际位于何处。我们将在API编程指南的章节中讨论主机用来以线程安全的方式更新参数的机制。

图2.2b显示了这个概念,每个GUI控件连接到一个参数。在处理阶段,参数更新被发送到插件。图2.2c显示了同样的事情--只是更新参数的是轨道自动化而不是用户。每个API的实现细节是不同的,但关于参数的信息存储是基本相同的。有两种不同类型的参数:连续的和字符串列表的。连续参数通常连接到可以传输连续值的旋钮或滑块。这包括float、double和int数据类型。字符串列表参数为用户提供了一个可供选择的字符串列表,GUI控制可能是简单的(一个下拉列表框)或复杂的(带有图形符号的开关的渲染)。字符串列表有时以逗号分隔的方式提供,有时则打包成字符串对象的数组,这取决于API的情况。对于图2.2a中的通道参数,逗号分隔的字符串列表将是。

"立体声、左、右"

参数的属性包括。

参数名称("音量")。
参数单位 ("dB")
参数是一个数值,还是一个字符串值的列表,如果是一个字符串值的列表,则是字符串本身
参数的最小和最大限制
新建插件的参数默认值
控制锥度信息(线性、对数等)。
其他API特定的信息
辅助信息
一些参数属性是文本字符串,如名称和单位,而另一些是数字,如最小、最大和默认值。分级信息是另一种被设置的数据。在所有的API中,你为你的插件想要公开的每个参数创建一个单独的C++对象。如果你比较一下下面三章中每一章的参数接口部分,你会发现这些API到底有多相似。

2.3.1 初始化。定义通道I/O支持

一个音频插件被设计成与一些输入和输出通道的组合一起工作。最基本的通道支持是三种常见的设置:单声道输入/单声道输出,单声道输入/立体声输出,以及立体声输入/立体声输出。然而,有些插件是为非常具体的通道处理而设计的(例如,7.1 DTS®与7.1 Sony)。另一些则被设计为将多声道格式折叠成单声道或立体声输出(如5.1环绕声折叠成立体声)。每个API都有不同的方式来指定通道I/O支持;它可以作为插件描述的一部分来实现,或初始化,或两者的组合。你可以在后面的插件编程指南章节中找到每个API的确切机制。

2.3.2 初始化。采样率的依赖性

几乎所有我们研究的插件算法都对当前DAW的采样率敏感,它的值通常是我们用来处理音频信号的一组方程的一部分。许多插件支持几乎所有的采样率,它的值与插件参数的处理方式类似--它只是计算中的另一个数字。少数插件可能只能处理某些采样率的信号。在所有的API中,至少有一种机制允许插件获得当前的采样率,以便在其计算中使用。在所有情况下,插件从一个函数调用或一个API成员变量中获取采样率。这通常是非常直接和简单的实现。

2.4 处理。为音频流做准备

该插件必须为每个音频流会话做好准备。这通常包括重设算法、清除内存结构中的旧数据、重设缓冲区索引和指针,以及为新的一批音频准备算法。每个API都包括一个在音频流之前调用的函数,这就是你要执行这些操作的地方。在ASPiK中,我们称之为reset( )操作;如果你使用RackAFX v1 (RAFX1),该函数被称为prepareForPlay( )--这是对我设计的Korg 1212 I/O产品的秘密点头,其驱动API使用了一个相同名称的函数。在编程指南的每一章中,都有一节专门讨论这个操作,解释了函数名称和如何初始化你的算法。有一点需要注意:试图使用来自主机的传输信息(即关于主机传输状态的信息:播放、停止、循环等)通常会有问题,除非插件特别需要关于进程的信息--例如,一个模式循环算法需要知道有多少小节在循环以及当前小节包括什么。此外,一些主机在报告它们的传输状态时是出了名的不正确(例如Apple Logic),所以它们可能会报告说音频正在传输,而实际上并没有。

2.4.1 处理。音频信号处理(DSP)

最终,效果插件必须处理音频信息,以某种方式对其进行转换。音频数据要么来自加载到会话中的音频文件,要么来自流入音频适配器的实时音频流。如今,在所有的API中,音频输入和输出样本都被格式化为浮点数据,范围为[-1.0, +1.0],如第一章所述。AU, AAX (native), 和RAFX使用浮点数据类型,而VST3允许float 或double 数据类型,尽管这些可能会随着时间而改变。在任何一种情况下,我们都将执行浮点运算,我们将对所有的C++对象进行编码,以便在内部使用duoble数据类型进行操作。在一个有趣但并不意外的转折中,所有的API都以完全相同的方式发送和接收多通道音频。从插件的角度来看,音频数据是来自音频文件还是来自硬件的现场输入,或者数据的最终目的地是文件还是音频硬件,都不重要。在图2.3中,我们假设我们有现场音频流进DAW。

图2.3的左边是硬件层和物理音频适配器。它通过硬件总线连接到音频驱动程序。驱动程序位于硬件抽象层(HAL)中。无论音频如何从适配器到达,驱动程序都会根据其操作系统定义的规范对其进行格式化,这通常是一种交错的格式,每个通道都有一个连续的样本。对于立体声数据,第一个样本来自左声道,然后交替为左、右、左、右等。请注意,这与大多数音频文件(包括.wav格式)的数据编码方式相同。因此,如果音频来自于一个文件,它也会以交错数据的形式传送。驱动程序将交错的数据以称为缓冲区的块状形式传递给应用程序。缓冲区的大小在DAW、驱动程序偏好或操作系统中设置。

image
图2.3:从硬件到插件,再返回的完整音频路径。

然后DAW将这些数据去掉interleaves,将每个通道放在自己的缓冲区,称为输入通道缓冲区。DAW还准备(或保留)缓冲区,插件将把处理后的音频数据写入称为输出通道缓冲区。然后DAW向插件发送音频数据,向每个缓冲区发送一个指针。DAW还发送缓冲区的长度和通道计数信息。对于我们这里的立体声例子,有四个缓冲区:左输入、右输入、左输出和右输出。两个输入缓冲区的指针以两槽数组的形式传递给插件,输出缓冲区也以同样的方式传递,在它们自己的两槽数组中。对于一个专门的折叠式插件,将5.1环绕声转换为立体声混音,会有六个指针用于六个输入通道,两个指针用于一对输出通道。很明显,使用缓冲区的指针和缓冲区的采样次数来发送和接收缓冲区的数据,比试图将数据复制到插件中和从插件中复制出来,或者想出一些难以实现的共享内存方案要有效得多。当插件通过返回指向输出缓冲区的指针来返回处理过的音频时,DAW会重新交错数据并将其发送给音频驱动程序,或者将其写入音频文件。这给了我们一些思考的东西。

插件接收、处理和输出数据的缓冲区。
每个通道都在它自己的缓冲区中到达。
每个缓冲区都是用一个指针传送的,这个指针指向缓冲区的第一个地址位置。
每个缓冲区的长度也必须被传送。
通道的数量和它们的配置也必须被传送。
由于我们的音频数据被打包成了缓冲区,我们要对缓冲区的处理做出一个重要的决定。我们可以独立处理每个缓冲区的数据,我们称之为缓冲区处理,或者我们可以一次处理每个缓冲区的一个样本,称之为帧处理。缓冲区处理对于多通道插件来说是很好的,因为这些通道可以被独立处理--例如,均衡器。但许多算法要求在处理每个通道的时候,每个通道的所有信息都是已知的。这方面的例子包括立体声转单声道的折叠插件、乒乓延迟、许多混响算法和立体声连接的动态处理器。由于帧处理是缓冲区处理的一个子集,而且是最通用的,所以我们将在帧中进行所有的书本处理。

2.5 将参数变化与音频处理混合起来

我们的插件需要处理与音频处理平行发生的参数变化,这就是事情变得棘手的地方。传入的参数变化可能来自于用户的GUI交互,也可能是自动化的结果。无论哪种方式,我们都有一个有趣的问题要处理:在我们的插件操作中,有两种不同的活动发生。在一种情况下,DAW将音频数据发送到插件进行处理,而在另一种情况下,用户正在与GUI交互,或运行自动化,或同时进行,这需要插件定期重新配置其内部操作和算法参数。用户并没有真正体验到插件的两种活动:他们只是听音频,并根据需要调整GUI。对用户来说,这都是一个统一的操作。我们也可能有输出参数发回给GUI;这通常采取音量单位(VU)计量的形式。我们也可能有一个自定义的图形视图,需要我们向它发送信息。所有这些东西都是在缓冲区处理周期中出现的。我们已经确定,在所有的API中,音频是在缓冲区中处理的,其机制是对你的插件进行函数调用。对于AU和VST来说,函数调用有一个预定义的名字,而在AAX中,你可以按照自己的意愿来命名这个函数。所以在所有情况下,整个缓冲区处理周期都发生在一个函数调用中。我们可以把缓冲区的处理周期分成三个操作阶段。

预处理:将GUI/自动参数的变化转移到插件中,并根据需要更新插件的内部变量。
处理:使用新调整的内部变量对音频进行DSP操作。
后期处理:将参数信息送回GUI;对于支持MIDI输出的插件,这时它们也会产生这些MIDI信息。

2.5.1 插件变量和插件参数

了解插件的内部变量和插件参数之间的区别是很重要的。每个插件参数持有一个与它的当前状态有关的值,这个值可能来自于用户与GUI的交互或自动化,也可能是一个代表音频VU表视觉状态的输出值。参数对象将这个值存储在一个内部变量中。然而,参数的变量并不是插件在处理过程中要使用的,在某些情况下(AU),插件永远无法真正直接访问该变量。相反,插件定义了自己的一套内部变量,在缓冲区处理周期中使用,并将参数的信息复制到其中。让我们来想想一个音量、低音和高音控制的插件。用户用一个GUI控制来调整音量,这个控制的单位是dB。这个以dB为单位的值在缓冲区处理周期的开始时被传递给插件。插件需要将dB值转换成标量乘法系数,然后需要将传入的音频样本乘以这个系数。有几种处理这个过程的策略。我们将在图2.4中描述的所有插件项目中坚持一个简单的范式。注意,我们把输入参数命名为入站,输出参数命名为出站。

每个GUI控件或插件参数(入站或出站)最终将连接到插件对象上的一个成员变量。
在预处理阶段,插件将信息从每个入站参数转移到其相应的成员变量中;如果插件需要进一步处理参数值(例如将dB转换为标量乘数),那么也是在这里完成的。
在处理阶段,DSP算法使用插件的内部变量进行计算;对于像计量数据这样的输出参数,插件也在这个阶段进行组装。
在后处理阶段,插件将输出信息从其内部变量转移到相关的出站参数中。
图2.4显示了DAW和插件的连接方式。音频通过一个缓冲区处理函数调用(processAudioBuffers)传递给插件。在缓冲区处理周期中,输入参数被读取,音频处理发生,然后输出参数被写入。出站的音频被发送到扬声器或音频文件,出站参数被发送到GUI。

image

图2.4:音频插件的操作连接。

集中在预处理阶段,考虑两种不同的处理方式来处理我们插件上的音量控制。在一种天真的情况下,我们可以让用户用一个GUI控件来调节音量,该控件传输一个0.0和1.0之间的数值。这使得音量控制很差,因为我们听到的音频振幅是对数的。然而,考虑到参数和插件变量的安排。GUI将传送一个在0.0和1.0之间的值。我们将把这个数据传送到一个名为volume的内部变量中,然后我们将把传入的音频数据乘以volume值。这在图2.5a中显示。原始的GUI值被直接用于计算音频输出。

在图2.5b中,改进后的插件将-60.0到0.0范围内的数值以dB为单位传送到插件中,然后插件将原始数据煮成一个新的变量,也命名为volume,插件可以在其计算中使用。在这种情况下,烹饪操作只需要一行代码,将dB值转换成标量乘数。图2.5c显示了一个平移控制,它传输的数值在-1.0(硬左)和+1.0(硬右)之间。插件必须把这个信息转换成两个标量乘数,每个通道一个。由于这个操作比较复杂,我们可以创建烹饪函数来完成这个工作,并产生更漂亮的代码。对于我们几乎所有的插件参数,我们将需要在计算中使用来自GUI的数据之前对其进行处理。为了保持事情的简单和跨插件的一致性,我们将遵守以下准则。

每个入站的插件参数值将被转移到一个直接对应于其原始数据的内部插件成员变量中;例如,一个基于dB的参数值被转移到一个基于dB的成员变量。
如果插件需要对数据进行处理,它可以这样做,但它需要创建第二个变量来存储处理后的版本。
每个出站的插件参数也同样会连接到一个内部变量。
所有的VU表数据必须在0.0(表关闭)到1.0(表全开)的范围内,所以在将插件变量转移到出站参数之前,必须对该范围进行任何转换。

图2.5:(a)一个简单的音量控制将原始数据传递给插件,以供立即使用。(b) 一个更好的版本向插件提供原始的dB值,它在使用前必须先进行处理。(c) 一个平移控制需要更复杂的处理。

如果你想在你自己的插件版本中把前两条准则作为一个单一的概念块,请随意。我们的经验是,在学习时,一致性会很有帮助,所以我们将自始至终坚持这些政策。将原始变量和熟化变量分开的另一个原因涉及到参数的平滑性。

2.5.2 参数平滑化

image

图2.6:(a)在普通的帧处理中,参数更新影响整个缓冲区。(b) 在带有参数平滑的处理中,每一帧都使用一个新的、略有不同的参数值。(c) 完整的缓冲区处理周期包括传输。

你可以在图2.6a中看到,插件的参数只在缓冲区处理周期的开始时被转移到插件中。如果用户在GUI控制上做了大幅度的调整,那么到达插件的参数变化可能会被粗略量化,这可能会在GUI控制被改变时引起点击声(这有时被称为拉链噪音)。如果用户将缓冲区的大小调整得相对较大,那么这个问题只会更加严重,因为传入的参数控制变化在时间上会更加分散。为了缓解这个问题,我们可以采用参数平滑法。重要的是要明白,如果要进行这种平滑处理,必须在插件方面进行,而不是在GUI或DAW中。通过参数平滑,我们使用插值来逐步调整参数变化,使它们不至于快速跳动。我们的参数平滑操作将默认在缓冲区处理周期内的每个采样周期做一个新的参数平滑更新。这意味着平滑的内部插件变量将在每一帧处理中被更新,如图2.6b所示。好消息是,这将消除咔嚓声或拉链噪音。坏消息是,如果参数变化需要一个复杂的烹饪方程或函数,我们可能会烧掉大量的CPU周期。我们使用的参数平滑对象也可以被编程为只在每隔N个样本期执行平滑操作(称为粒度)--当然,我们增加的粒度越多,控制变化就越有可能产生咔哒声和噪音。我们将在第6章中更详细地讨论参数平滑对象。

由于参数平滑会产生一些CPU费用,你应该只在绝对必要时使用它。对于一个传输离散值的GUI控件--例如,一个传输0、1或2值的三位置开关,对应于每个位置--通常不需要尝试平滑这个值来减缓位置间的移动。我们将只对那些与连续(浮动或双倍)插件变量相关的参数进行平滑处理。

2.5.3 处理前和处理后的更新

我们的ASPiK内核对象(或你所选择的插件管理框架)应该提供一种机制来执行将参数数据传送进和传送出插件的操作。为了进一步分解这个操作,我们将坚持另一个惯例,将参数信息的传输与可能需要的烹饪功能分开;这将产生更容易阅读和理解的代码,而且更具有区隔性。在图2.6c中,你可以看到预处理块调用一个我们称之为syncInboundVariables的函数。在这个函数调用过程中,每个参数的值被转移到一个相关的插件变量中。每次传输后,将调用函数postUpdatePluginParameter。在这个函数调用期间,插件就可以根据需要烹调传入的参数信息。在后处理阶段,函数syncOutboundVariables被调用:这将把出站的插件变量复制到它们相应的插件参数中。在这个块中不需要后处理函数,因为在处理阶段,插件可以根据需要轻松地格式化信息。

2.5.4 VST3样本精确更新

VST3规范与其他规范不同,它定义了一个可选的参数平滑能力,当用户在DAW会话中运行自动化时可以实现。这只涉及到自动化,并且完全独立于正常的用户-GUI交互,产生与其他API相同的每缓冲区参数更新。在自动化的情况下,VST3主机可能会在最后收到的参数更新之外提供中间值。确切的机制将在第三章中介绍;这不是解决自动化的参数平滑问题的完美方案,但它绝对是朝着正确方向迈出的一步。ASPiK已经设计了一个可以选择启用的功能来处理这些参数更新。与正常的参数平滑一样,当更新的参数值必须用CPU密集型函数来处理时,必须小心谨慎。与正常的参数平滑一样的问题也会存在。

在这一点上,你应该对该插件在功能块层面上的操作有一个很好的大画面。你可以看到插件的参数更新和音频信号处理是如何交错进行的,以至于每个缓冲区都是用一组参数值处理的,无论是否平滑。这种交错是这些插件架构的一个关键部分,超越了任何单独的API。接下来的API特定章节将详细阐述与这些插件规范有关的细节。在我们继续之前,我们需要讨论图2.4中涉及线程和安全拷贝机制的标签。

2.5.5 多线程软件

如果你研究过微处理器的设计和编程,你会遇到一种叫做指令指针(IP)的东西。在最底层,所有的软件都在三个阶段的循环中运行:获取、解码和执行。程序从内存中获取一条指令,对其进行解码,然后执行该指令。IP指向当前正在取用的指令。在指令执行后--假设没有发生分支,IP将被更新以指向序列中的下一条指令,三步循环重复进行。分支(if/then语句)将改变IP,跳到代码中的新位置。函数调用也做同样的事情,并相应地改变IP。我们说,IP在程序中 "通过指令移动"。曾几何时,你可以写一个只有一个IP的软件;我们大多数人的第一个 "Hello World "程序就是这样写的。一个指令指针在程序中从一条指令移动到下一条指令,被称为线程或执行线。这在图2.7a中显示。我们中的大多数人在成长中的编程课上都在写单线程程序,这些程序做了非常重要的事情,如计算储蓄账户的利息或列出素数。

图2.7:(a)一个单线程的程序只有一个指令指针(IP)在指令和数据中移动。(b) 一个多线程程序有多个IP访问相同的指令和数据。这里,IP1试图写入体积变量,而IP3读取它。(c) 在此图中,线程1和线程3之间存在着一种竞赛条件,因为他们竞争着用不同的值来写体积变量。

现在想想一个模拟喷气式战斗机的计算机游戏。这是一个不同类型的程序。图形显示我们在空中飞行:当我们发射导弹并击中目标时,会有同步的音频伴随,当我们移动操纵杆时,我们会看到天空在响应地跳舞。同时,还有一个小的雷达指示器在旋转,显示敌人就在我们身后。即使我们松开控制杆,什么也不做,飞机仍然在飞,至少有一段时间。

现在需要出现一堆事情同时发生,程序员需要相应地编写代码。在这种软件中,有多个IP,每个IP都在代码的不同部分移动:一个IP正在运行在屏幕上绘制图像的代码,而另一个IP正在循环播放音乐原声带的代码,还有一个IP正在跟踪操纵杆的运动。这是一个多线程应用程序的例子,如图2.7b所示。每个在代码中移动的IP的不同实例都是线程之一,这个软件有多个IP。操作系统参与创造一种假象,即所有这些都是同时发生的。在一个单CPU系统中,每个执行线程被允许有一小段处理时间;操作系统以轮流的方式分配CPU时间片,从一个线程移到下一个线程。当然,看起来所有的事情都是同时发生的,但实际上,所有的事情都被切成了处理片,时间短到你的大脑无法辨别,所有的事情都合并成了一个美丽的幻觉,你真的在驾驶飞机。即使是多CPU系统,大多数程序仍然是这样执行的,每个线程一次一个片断;增加更多的CPU只是增加更多的时间片断。

现在想想我们的音频插件。需要同时进行的两件事--音频处理和用户与GUI的交互--实际上是在两个不同的线程上进行的,这些线程与CPU共享时间。这意味着,我们的插件必然是一个多线程的软件。它至少有两个执行线程在攻击它。每个线程都可以宣布自己的优先级,以试图获得更多(或更少)的CPU时间。优先级最高的线程获得最多的CPU时间,而优先级最低的线程获得最少的时间。这也要求线程以异步方式运行。操作系统被允许根据需要调整CPU线程-分流时间:例如,如果系统因你的电影下载而陷入困境,它可能会决定降低已经低优先级的GUI线程信息的优先级,使你的插件GUI变得迟缓。但是,高优先级的音频不会受到影响,音频到达驱动器时不会出现故障。

每个线程的优先级确实不是我们需要处理的问题,尽管我们确实需要记住它。我们需要解决的更大的问题是,这两个线程需要访问一些相同的数据,特别是插件的参数。GUI需要改变参数,而音频处理线程也需要访问这些参数以相应地更新其处理。这两个异步运行的线程需要访问相同的信息,这是多线程软件问题的核心所在。现在想想那个飞行模拟器中的线程:它们需要分享多少数据才能给用户带来适当的体验?图2.7b显示了这个问题,其中线程1和线程3都试图访问程序中数据部分的体积变量。

共享数据的问题乍看之下并不严重:两个线程共享数据又如何?CPU每次只允许一个线程访问数据,那么它不是已经为我们完成了线程同步的过程吗?它不是在保持共享数据的有效性吗?答案是否定的。问题在于,数据本身是由多个字节组成的。例如,一个float或int的长度实际上是32位,也就是四个字节。一个double的长度是64位,即8个字节。线程以字节为单位读取和写入数据,一次一个。假设一个GUI线程正在向一个double变量写数据,它已经写完了8个字节中的前3个字节,当操作系统给它发出信号说它必须停止并将控制权让给下一个线程时,它就必须停止。对于普通变量,线程会立即停止,并记住它的位置,然后将执行传递给下一个线程。当我们的GUI线程得到一个新的CPU时间片时,它完成了对变量的剩余五个字节的数据写入,然后继续前进。但是,如果我们的音频处理线程在CPU时间的下一个队列中试图读取部分写入的双变量呢?它将会访问损坏的数据,这是多线程问题的一种类型:线程之间数据不一致。现在假设音频处理线程试图向该双变量写入数据,并成功地覆盖了三个新字节和五个旧的现有字节。后来,GUI线程重新获得控制权,用它认为需要写入的剩余五个字节完成了其余数据的写入。我们又一次在变量中留下了一个损坏的值。当两个线程竞相向同一个变量写入数据时,它们被称为处于竞赛状态:每个线程都在与另一个线程竞相写入数据,最后数据可能仍然被破坏。竞赛条件和不一致的数据是我们这里的基本问题。

好消息是,每个API都有自己的内部机制来确保插件的线程安全。我们花时间讨论这个问题的原因是,这些机制不仅影响到每个API的设计,而且还渗透到我们的插件代码必须如何编写。我们将在下面的章节中更详细地讨论每个API的多线程策略,让你对API有更深的理解和更多的洞察力。然而,请放心,ASPiK在各种API中的操作是100%线程安全的--你在编程时不需要直接关注这些问题。

2.6 单一的插件对象

插件本身被编译为DLL,但它将插件实现为一个C++对象或一组对象。这些对象的杂务包括以下内容。

为主机描述该插件
初始化参数列表
从用户/DAW检索参数更新
为DSP算法将参数更新煮成有意义的变量
用DSP算法处理音频信息
渲染GUI并处理来自用户的输入,然后将这些信息安全地转移到参数列表中。
在所有的API中,列表中的最后一项--渲染GUI--最好是在一个独立的C++对象中完成,这个对象是专门用来实现GUI的,而不是其他。试图将GUI和平台特定的代码结合到处理其他插件职责的同一个对象中,充其量是有问题的,最糟糕的是充满了基本的面向对象设计错误。因此,我们将暂时把那个C++对象放在一边,看看其他项目。

你可以把剩下的职责大致分为两部分,一部分是处理参数,另一部分是处理音频处理。VST3和AAX规范都允许两种不同的模式来创建封装插件的底层C++对象。在一个版本中,职责被分割成这两部分:处理音频和处理参数。在另一种模式中,一个单一的C++对象处理这两项工作。在AAX SDK中,这种模式被称为 "单体插件对象 "范式。为了保持一致性,我将在我们的插件项目和ASPiK中坚持使用这种单体对象的原型。如果你想在以后沿着参数/处理的边界线分割代码,请随意这样做--这并不是太困难。例如,VST3中的单体插件对象实际上只是一个C++对象,它从参数和音频处理对象中派生出来,继承了这两套功能。然而,在任何情况下,我们的GUI渲染对象都不会与单片机处理对象合并:它将始终作为一个独立的组件,我们的单片机处理对象既不需要也不知道GUI的存在。同样地,我们的GUI渲染对象也不知道有一个相关的处理对象正在使用它的信息。ASPiK实现的API特定的线程安全机制确保了GUI和插件对象本身之间的正确和合法的关系--这些将在第6章揭示。

2.7 Bibliography

Apple Computers, Inc. 2018. The Audio Unit Programming Guide, https://developer.apple.com/library/archive/documentation/MusicAudio/Conceptual/AudioUnitProgrammingGuide/Introduction/Introduction.html, Accessed August 1, 2018.

Avid Inc. AAX SDK Documentation, file://AAX_SDK/docs.html, Available only as part of the AAX SDK.

Bargen, B. and Donnelly, P. 1998. Inside DirectX, Chap. 1. Redmond: Microsoft Press.

Coulter, D. 2000. Digital Audio Processing, Chap. 7–8. Lawrence: R&D Books.

Petzold, C. 1999. Programming Windows, Chap. 21. Redmond: Microsoft Press.

Richter, J. 1995. Advanced Windows, Chap. 2, 11. Redmond: Microsoft Press.

Rogerson, D. 1997.Inside COM, Chap. 1–2. Redmond: Microsoft Press.

Steinberg.net. The Steinberg VST API, www.steinberg.net/en/company/developers.html, Accessed August 1, 2018.