VST音频插件架构分析

发布时间 2023-08-02 23:18:20作者: Peacoor-Zomboss

VST音频插件架构分析

前言

Virtual Studio Technology (VST),可以翻译为虚拟工作室技术,是一种音频插件软件接口,由德国Steinberg公司发布,用于在Digital Audio Workstation (DAW,可以译为数字音频工作站)中集成合成器和效果器。

现在主要还在用的是VST 2和VST 3这两个版本,VST 2是对VST 1的扩充,所以一块讲;但VST 3则是相当于一个全新的架构,所以要和VST 2分开来讲。

官方提供了VST的SDK,其中VST 2的已经没有了(但是可以在其他地方找到),只有VST 3的可以在官网找到。不过本文并不分析完整的SDK,而是分析其架构,所以只会分析SDK中关于VST的API部分。

你可以在这里找到VST 3的官方英文文档,也可以在这里找到VST 3 SDK的下载链接,其API也在SDK中;或者可以去他们的GitHub仓库去Clone完整SDK代码。

如果你需要VST 2 SDK,可以去这个GitHub仓库下载,有需要的建议还是下了,万一以后真没了也不好说。不过VST 2我是真的找不到文档了。

不得不说,Steinberg为了强推VST 3而抛弃VST 2,甚至下架其SDK以及停止授权等行为,确实饱受诟病,不过这是人家公司自己的选择,而我们依然可以接着用,甚至用竞争对手的技术,随他怎么搞呢,反正哪个流行用哪个就是了。只不过唯一的问题就是如果销售的插件或者DAW带有VST 2,估计得给人家告死,所以想拿来卖只能用VST 3,当然如果开源免费的话肯定无所谓。

本文要分析的所有代码均来自pluginterfaces文件夹。

目录

VST 2

核心文件就是在pluginterfaces/vst2.x文件夹下的aeffect.haeffectx.h,其中第一个是VST 1的,第二个是VST 2的补充,所以其整体架构就是延续了VST 1的,因此先分析一下aeffect.h的东西。

跳过前面无聊的部分,我们直奔主题——几个重要回调函数和核心结构体(删了部分注释和条件编译,同时不考虑VST 2.4不使用的内容,也就是标了DECLARE_VST_DEPRECATED的):

// 主机回调,这个是需要由主机实现一系列的opcode,然后给插件调用的
typedef VstIntPtr (VSTCALLBACK *audioMasterCallback) (AEffect* effect, VstInt32 opcode, VstInt32 index, VstIntPtr value, void* ptr, float opt);
// 调度函数,插件实现一系列的opcode,提供给主机调用的
typedef VstIntPtr (VSTCALLBACK *AEffectDispatcherProc) (AEffect* effect, VstInt32 opcode, VstInt32 index, VstIntPtr value, void* ptr, float opt);
// 音频处理函数,插件实现,由主机提供输入和输出缓冲区,以及要处理的采样数,通常在单独的线程调用
typedef void (VSTCALLBACK *AEffectProcessProc) (AEffect* effect, float** inputs, float** outputs, VstInt32 sampleFrames);
// 同上,但是缓冲区的double类型的浮点数,而不是float类型的,有更高的精度
typedef void (VSTCALLBACK *AEffectProcessDoubleProc) (AEffect* effect, double** inputs, double** outputs, VstInt32 sampleFrames);
// 参数设置函数,主机提供一个索引和一个参数值,插件负责保存对应的参数
typedef void (VSTCALLBACK *AEffectSetParameterProc) (AEffect* effect, VstInt32 index, float parameter);
// 参数获取函数,主机提供一个索引,插件返回该索引对应参数的值
typedef float (VSTCALLBACK *AEffectGetParameterProc) (AEffect* effect, VstInt32 index);

// 前面几个回调函数的第一个参数需要的结构体
struct AEffect
{
    VstInt32 magic; // "VstP",按32位整数保存,注意'V'是在地址高位的,即0x56737450
    AEffectDispatcherProc dispatcher; // AEffectDispatcherProc函数指针
    AEffectProcessProc DECLARE_VST_DEPRECATED (process);
    AEffectSetParameterProc setParameter; // AEffectSetParameterProc函数指针
    AEffectGetParameterProc getParameter; // AEffectGetParameterProc函数指针
    VstInt32 numPrograms; // 预设的数量
    VstInt32 numParams;   // 参数的数量
    VstInt32 numInputs;   // 输入通道数量
    VstInt32 numOutputs;  // 输出通道数量
    VstInt32 flags; // 见VstAEffectFlags
    VstIntPtr resvd1; // 给主机留着的,置0
    VstIntPtr resvd2; // 同上
    VstInt32 initialDelay; // 大概是某些算法有延迟,这个参数告诉主机延迟的采样数,一般就0
    VstInt32 DECLARE_VST_DEPRECATED (realQualities);
    VstInt32 DECLARE_VST_DEPRECATED (offQualities);
    float    DECLARE_VST_DEPRECATED (ioRatio);
    void* object; // 指针,指向SDK里的类,不用SDK的话可以自定义使用,每个实例都不一样
    void* user;   // 留给插件开发者自由分配的指针
    VstInt32 uniqueID; // 每个插件要一个独一无二的标志,得找Steinberg申请,现在也没啥用了,尽可能独一无二
    VstInt32 version;  // 插件的版本
    AEffectProcessProc processReplacing; // 替换模式的process函数,这个替换应该是指输入输出缓冲区是同一个
    AEffectProcessDoubleProc processDoubleReplacing; // 同上,但是使用double类型的采样
    char future[56]; // 留给未来的(然而没有了),置0
};

// 上面AEffect里flags需要的标志位
enum VstAEffectFlags
{
    effFlagsHasEditor     = 1 << 0, // 插件有编辑器(UI界面)
    effFlagsCanReplacing  = 1 << 4, // 支持processReplacing,必须要设置
    effFlagsProgramChunks = 1 << 5, // 没用过,感觉没啥用
    effFlagsIsSynth       = 1 << 8, // 合成器得设置这个
    effFlagsNoSoundInStop = 1 << 9, // 没用过,大概是说插件保证process的时候输入为0就不会输出声音
    effFlagsCanDoubleReplacing = 1 << 12, // 支持processDoubleReplacing,可选
    DECLARE_VST_DEPRECATED (effFlagsHasClip) = 1 << 1,
    DECLARE_VST_DEPRECATED (effFlagsHasVu)   = 1 << 2,
    DECLARE_VST_DEPRECATED (effFlagsCanMono) = 1 << 3,
    DECLARE_VST_DEPRECATED (effFlagsExtIsAsync)   = 1 << 10,
    DECLARE_VST_DEPRECATED (effFlagsExtHasBuffer) = 1 << 11
};

注意回调函数那边的VSTCALLBACK其实就是C语言默认的函数调用约定,前面有定义,但实际上用C开发的话没必要。这几个回调函数里,一般情况下除了音频处理的函数需要在单独的线程中进行,其它的都是在UI线程进行的。

结构体主要提供插件的基本信息以及一些回调函数的指针,根据需要设置就行了,还有这个flags必须设置effFlagsCanReplacing,因为大部分插件都是有界面的,所以基本上都要设置effFlagsHasEditor,如果是合成器就设置effFlagsIsSynth,不然默认是效果器,最后这个effFlagsCanDoubleReplacing看需不需要双精度浮点数吧,建议是要的,因为现在大部分软件和插件都是64位的,一般64位CPU处理double很快而且精度更高,大部分DAW内部也是用double类型的数据。

插件的入口函数一般是这么定义的:

AEffect *VSTPluginMain(audioMasterCallback hostCallback)
{
    new MyPlugin(hostCallback)->getAEffect();
}

这里的MyPlugin是一个C++的类,参数就是audioMasterCallback的回调,然后调用getAEffect获取该类对应的AEffect结构体指针,每个插件的实例都要这样,不然插件之间会影响。

入口函数的名称除了VSTPluginMain也可以是main,一般只要前者,记得要在DLL导出,不同编译器有不同的方法。

接着说几个关键opcode,插件必须要实现的或者是实现基本功能需要的,否则插件跑不起来,opcode的定义在aeffect.haeffectx.h里,分别是枚举AEffectOpcodesAudioMasterOpcodesX

  • effGetVstVersion,一定要实现,返回2400,不然主机(大概率)会认为这个插件是老版本的
  • effGetParamLabel、effGetParamDisplay、effGetParamName,可选,和参数的显示有关,可以在DAW看到
  • effSetSampleRate、effSetBlockSize,有些效果需要采样率、块大小之类的参数
  • effCanBeAutomated,参数是否可以被自动化,挺有用的
  • effEditGetRect、effEditOpen、effEditClose,界面相关的,有插件界面肯定要实现
  • effEditIdle,主机在UI线程调用,用来给插件更新界面用的
  • effGetChunk、effSetChunk,保存或设置所有参数的值,给主机用来实现插件参数状态的保存

除了插件要实现opcode,有些功能需要调用主机提供的回调函数,在枚举AudioMasterOpcodesAudioMasterOpcodesX中。

  • audioMasterAutomate,比如用户拖动界面的控件,调用这个告诉主机这个参数可以被自动化
  • audioMasterBeginEdit、audioMasterEndEdit,在用户开始修改参数到结束修改参数时调用,通常是鼠标按下到松开
  • audioMasterGetDirectory,向主机询问插件DLL的路径,一般用来加载资源用

所以你只要实现最基本的AEffect结构体,实现几个核心回调函数,就可以实现一个最简单的VST 2.4插件了。

至于其他没提到的opcode,还有一大堆结构体、常量,不在本文讨论之列。

这样看来,VST 2架构的本质就是用到了C风格的回调函数,这很像Windows的窗口机制(WndProc函数)。

VST 3

VST 3之于VST 2,和VST 2与VST 1的关系完全不同,前者更像是重写的架构,而不是后者一样的扩充。

VST 3是由一组类似于COM的接口和一系列数据结构组成的。

如果说详细点,VST 3是由VST MAVST 3 API组成的,分别在pluginterfaces/basepluginterfaces/vst这两个文件夹里。

熟悉COM的看这个应该没什么问题, 不熟悉的话建议先了解一下COM技术,会稍微好一点。

与COM的IUnknown不同,VST自己定义了FUnknown,不过其实是ABI兼容的,VST的所有和COM类似的部分都是自己定义的,目的就是去除对Windows平台(COM有关头文件)的依赖,实现跨平台。

class FUnknown
{
public:
    virtual tresult PLUGIN_API queryInterface (const TUID _iid, void** obj) = 0;
    virtual uint32 PLUGIN_API addRef () = 0;
    virtual uint32 PLUGIN_API release () = 0;

    static const FUID iid;
};
DECLARE_CLASS_IID (FUnknown, 0x00000000, 0x00000000, 0xC0000000, 0x00000046)

类型tresult类似于HRESULT,其实就是一个32位整数,0表示OK,其余都是一个负数,表示各种错误。

类型TUID是这么定义的typedef int8 TUID[16];,其实和GUID是兼容的。

类型FUID是一个类,但是没有虚函数,其实可以理解为仅有一个TUID成员的带方法的高级结构体。

调用约定是PLUGIN_API,在Windows我平台是stdcall,在其他平台是cdecl。

源代码文件里好多东西,真正有用的其实不多,当然用C++搞这个COM接口确实蛋疼,毕竟语言级别的支持没有,比如每个接口都有的静态常量iid,实际上Steinberg是自己实现了一个FObject对象来实现的各种功能,还有一堆宏。不过实现的方法有很多,如果说把VST 3的接口翻译到别的语言,有些功能实现起来可能会轻松不少。

接下来是VST MA里的重要接口,IPluginBase,这个接口可以说是VST 3所有插件的基础了。

class IPluginBase: public FUnknown
{
public:
    // 初始化,给的接口必须可以获取IHostApplication,同时插件应该在这个方法里实现内存分配等任务,而不是构造函数里
    virtual tresult PLUGIN_API initialize (FUnknown* context) = 0;
    // 终止,之前分配的内存在这里销毁
    virtual tresult PLUGIN_API terminate () = 0;

    static const FUID iid;
};
DECLARE_CLASS_IID (IPluginBase, 0x22888DDB, 0x156E45AE, 0x8358B348, 0x08190625)

主机保证会在需要的时候调用initialize,同时确保会调用terminate让插件释放资源。

之后会讲到的几个VST 3接口,都是在其基础之上的,不过在此之前,最后讲一下VST MA里最后一个关键接口——IPluginFactory,很显然,用的工厂模式。

class IPluginFactory : public FUnknown
{
public:
    // 获取插件工厂信息
    virtual tresult PLUGIN_API getFactoryInfo (PFactoryInfo* info) = 0;
    // 获取类的数量,一般都是2
    virtual int32 PLUGIN_API countClasses () = 0;
    // 获取索引index的类的信息
    virtual tresult PLUGIN_API getClassInfo (int32 index, PClassInfo* info) = 0;
    // 根据类的16位UID和接口的16位UID创建实例,FIDString就是char *
    virtual tresult PLUGIN_API createInstance (FIDString cid, FIDString _iid, void** obj) = 0;

    static const FUID iid;
};

DECLARE_CLASS_IID (IPluginFactory, 0x7A4D811C, 0x52114A1F, 0xAED9D2EE, 0x0B43BF9F)

其中PFactoryInfoPClassInfo如下:

struct PFactoryInfo
{
    enum FactoryFlags
    {
        kNoFlags                 = 0,
        kClassesDiscardable      = 1 << 0, // 工厂的类可变,主机得累死,一般不用
        kLicenseCheck            = 1 << 1, // 最新版已经弃用了,应该是检查许可证
        kComponentNonDiscardable = 1 << 3, // 组件不会被卸载,直到进程退出
        kUnicode                 = 1 << 4  // 组件支持Unicode,其实是UTF-16,基本都要支持,要国际化的嘛
    };
    enum
    {
        kURLSize = 256,
        kEmailSize = 128,
        kNameSize = 64
    };
    char8 vendor[kNameSize]; // 供应商名称
    char8 url[kURLSize];     // 供应商网站
    char8 email[kEmailSize]; // 供应商电子邮箱
    int32 flags; // 上面的FactoryFlags
};

struct PClassInfo
{
    enum ClassCardinality
    {
        kManyInstances = 0x7FFFFFFF // 允许多个实例,也就是加载好几个
    };
    enum
    {
        kCategorySize = 32,
        kNameSize = 64
    };
    TUID cid; // 这个类的UID,自己随机生成一个就行了,基本确保唯一性
    int32 cardinality; // 设置上面那个kManyInstances
    char8 category[kCategorySize]; // 类的类别,比如"Audio Module Class"、"Component Controller Class"这种
    char8 name[kNameSize]; // 类的名称,自己起一个
};

这个工厂后来还扩充了IPluginFactory2IPluginFactory3,接口如下:

class IPluginFactory2 : public IPluginFactory
{
public:
    // 获取PClassInfo2的类信息
    virtual tresult PLUGIN_API getClassInfo2 (int32 index, PClassInfo2* info) = 0;

    static const FUID iid;
};

DECLARE_CLASS_IID (IPluginFactory2, 0x0007B650, 0xF24B4C0B, 0xA464EDB9, 0xF00B2ABB)

struct PClassInfo2
{
    TUID cid; // 兼容PClassInfo结构体
    int32 cardinality; // 同上
    char8 category[PClassInfo::kCategorySize]; // 同上
    char8 name[PClassInfo::kNameSize]; // 同上
    enum {
        kVendorSize = 64,
        kVersionSize = 64,
        kSubCategoriesSize = 128
    };
    uint32 classFlags; // 目前仅针对"Audio Module Class",有kDistributable和kSimpleModeSupported
    char8 subCategories[kSubCategoriesSize]; // 子类别,比如"Fx"这种,有具体的常量定义
    char8 vendor[kVendorSize]; // 供应商名称
    char8 version[kVersionSize]; // 版本,比如"1.0.0.0"
    char8 sdkVersion[kVersionSize]; // VST版本,有常量定义,比如"VST 3.7.8"
};

class IPluginFactory3 : public IPluginFactory2
{
public:
    // 没啥好说的
    virtual tresult PLUGIN_API getClassInfoUnicode (int32 index, PClassInfoW* info) = 0;
    // 主机提供一个上下文接口,应该也是实现了IHostApplication
    virtual tresult PLUGIN_API setHostContext (FUnknown* context) = 0;

    static const FUID iid;
};

DECLARE_CLASS_IID (IPluginFactory3, 0x4555A2AB, 0xC1234E57, 0x9B122910, 0x36878931)

// 相比于PClassInfo2,就一些char8换成了char16,没啥好说的
struct PClassInfoW
{
    TUID cid;
    int32 cardinality;
    char8 category[PClassInfo::kCategorySize]; // 这个没变,因为有常量定义,只能是ASCII字符串
    char16 name[PClassInfo::kNameSize];
    enum {
        kVendorSize = 64,
        kVersionSize = 64,
        kSubCategoriesSize = 128
    };
    uint32 classFlags;
    char8 subCategories[kSubCategoriesSize];
    char16 vendor[kVendorSize];
    char16 version[kVersionSize];
    char16 sdkVersion[kVersionSize];
};

实际开发中,一般只要自己设置PClassInfo2,然后转成PClassInfoW就行了,如果用Steinberg提供的工具的话,甚至都不用写这些代码。

作为插件,在动态链接库里需要导出名为GetPluginFactory的函数,用来获得工厂对象,一个可能的实现如下:

SMTG_EXPORT_SYMBOL IPluginFactory* PLUGIN_API GetPluginFactory ()
{
    static MyPluginFactory *gFactory = nullptr;
    if (!gFactory) {
        gFactory = new MyPluginFactory;
    }
    else {
        gFactory->addRef();
    }
    return gFactory;
}

在Windows平台,还有可选的InitDllExitDll,可选是因为Windows有DllMain,可以实现加载/卸载时的资源申请/释放操作,但是Linux和macOS就不这样了。在Linux平台,使用ModuleEntryModuleExit;而在macOS平台,使用BundleEntryBundleExit。主机必须在加载插件后调用XxxEntry,且在卸载前调用XxxExit

主机获取到了插件的工厂之后,就会去依次调用几个方法获取相关信息,同时还会尝试调用queryInterface来获取IPluginFatory2IPluginFactory3的对象,最后调用createInstance来创建IComponentIEditController的对象。

主机一般会先调用createInstance获取IComponent,然后通过queryInterface获取IAudioProcessor

Steinberg建议插件的音频处理部分和参数控制部分分离,这个需要在classFlags里设置kDistributable,如果没有设置,那么主机就会尝试通过IComponent对象接着获取IEditController,否则就重新调用createInstance。分离的好处是灵活,不分离好处是代码简单,因为这几个接口都可以在同一个类里面实现。

可以参考一下Audio Processor调用过程图Edit Controller调用过程图

关于IComponentIAudioProcessorIEditController就不多介绍了,和底层架构关系不大,已经是特定功能的接口了。

要实现其他功能的话,比如GUI,还得有IPlugViewIPlugFrame,如果要在GUI控制参数的话还要IComponentHandler,还有好多接口以及各种各样的结构体,反正突出一个字——乱。

其实到这里,我也只是讲了VST 3那么多机制的冰山一角,当然底层架构基本就算是说完了,剩下的就是在这套架构的基础上增加的各种各样的功能了。

VST 2和VST 3的对比

在开始对比之前,我想先说一下历史,来捋一下时间线。

COM是微软在1993年首次提出的,VST 1.0是在1996年推出的,在90年代那会,COM逐渐流行起来,然后在1999年推出了VST 2,接下来几年就不断更新,到了2006年的2.4就不动了,随后就是2008年的VST 3。2013年,Steinberg宣布不再维护VST 2 SDK,但是开发者基本不受影响,直到2018年,Steinberg准备停止VST 2的支持,也就意味着18年之后,任何开发者如果没有在之前和Steinberg签了协议的话,就不能开发商业的VST 2插件了。而到了2022年,Steinberg更是直接宣布他家软件将最后支持VST 2插件24个月,然后就会移除VST 2的支持,然后就引起了骂声一片。

奇怪的是,明明1993年就有了COM,为啥2008年的VST 3才用了这个呢?事实上并不奇怪,因为VST 1到VST 2那段时间,VST插件可以说是非常火爆的,即使Steinberg在自己的软件Cubase和Nuendo已经用了类似COM的技术,也就是VST MA,但是想要推广开来还是需要时间的,所以他们也只能硬着头皮先更新VST 2再说。不过由于VST 2不符合Steinberg的利益需要了,所以他们推出了新架构的VST 3,但是实际上如果接着扩充VST 2,未尝不可实现现在的一下新功能,只不过VST 2的架构势必会变得更加臃肿——增加更多opcode,弃用一些老的opcode,甚至增加新的回调函数。

但不论如何,对于广大开发者而言,VST 3相比于VST 2并没有什么升级,而是换了一种Steinberg喜欢的开发方式而已,插件该有的功能基本都有,没有的很多主机也不会去实现,而且给VST 2接着打补丁也是可以实现的,况且VST 3更庞大,更复杂。但是Steinberg偏不,强推新的VST 3,甚至到现在打算完全放弃支持旧的VST 2。

可以看看国外的这篇文章,或者国内有人发布的翻译版本,基本说了二者的一些区别与好坏。

国外也有人讨论关于VST 3为数不多的好功能——样本精确自动化(提供采样点和参数的列表,能提高参数自动化时的精度,同时性能损失较少),不过大部分DAW很难支持,因为其他的插件格式不一定有这个功能,况且目前也有DAW(说的就是某水果形状的软件)做了减小单次处理的样本数量这样的方法来提高参数自动化的精度(对有些插件来说这样会出现问题)。当然这个功能确实非常不错,但是实际上在VST 2的架构上扩充,未尝不能实现。

从技术架构的角度出发,显然VST 2更加通用,兼容性更好,因为插件的底层就是C风格的回调函数,这适用于绝大多数的语言,所以可以很轻松地在其他语言实现,比如现在比较火的Rust、Zig这类的。

而VST 3的SDK则是和C++深度绑定,虽然官方也提供了C风格的接口,实际上就是微软COM的早期模式,用C语言的结构体来模拟现在C++的虚函数表。但是和C++的过度耦合,使得其他语言很难实现,但是也不是没有,比如Rust也有人做过。

至于我为什么会对这俩玩意这么熟悉,那就得回到2021年,那会对VST 2插件的开发有些兴趣,又不想用C/C++这样的技术开发,想用我之前比较熟悉的Object Pascal开发,同时配合Delphi的VCL或者Lazarus的LCL库可以轻松实现GUI界面的开发,尤其是LCL可以轻松跨平台。

所以在那年陆陆续续花了几个月就搞清楚了VST 2的架构,就是用最基础的C风格的回调构建代码,写了一些基础的插件,当然是因为DSP那块不是很熟悉,也不会复杂的算法。

后来考虑到VST 2确实也比较老了,加上我又没赶上当时最后一波的机会(2018年之后就无法获取VST 2专用授权许可证了),所以也不可能再发布VST 2的任何插件了,所以就将目光转向了VST 3(在专用授权许可证之外,还可以选用GPLv3许可证),然后就断断续续花了两年时间,到最近(2023年7月)才终于完成最后一步——摸清架构,写出基础插件。花了这么久的时间,主要还是C++的代码翻起来太难受了,加上代码量巨大,光是翻译成Object Pascal的接口部分代码,就将近4000行,再加上还有实现一个基础插件还需要1000多行的代码,这和VST 2完全不能比(接口1000多行,基础插件几百行)。

如果你有兴趣,可以光顾一下我的GitHub,关于VST 2和VST 3的Pascal版本的链接就放这里了:

结语

VST 2和VST 3是两个独立的技术路线,只因创造者Steinberg换了他们的口味,导致VST 2这一简单好用且流行的格式被迫随着时间而逐渐淡出人们视野。当然VST 3也不是一无是处,毕竟COM技术也是微软多年实践验证过的,也没什么问题。而且Steinberg提供了高度封装的VST 3 SDK,以及AAX、AU等其他格式的包装器,还有一系列的工具,使得实际的开发得到了简化(当然大部分人可能用JUCE这种库)。

但是Steinberg做的不好的就是想要强行改变开发者习惯,在VST 2用的好好的情况下,居然用各种手段迫使开发者转换到VST 3来,而且甚至还想要放弃支持VST 2。强如微软也依然保持着基本的兼容性,甚至MIDI 2.0也是对MIDI 1.0的补充,要知道MIDI 1.0那是1983年的古老标准了。不是谁都是苹果,可以有那么强的实力随意破坏向后兼容性。

希望Steinberg好自为之,接着支持原有的VST 2,好好经营VST 3,同时不要再让未来可能的VST 4也这样了。