数字音频、MIDI与DSP编程基础

本书是国内第一本讲解数字音频、MIDI与DSP(数字信号处理)的基础性教材,内容深入浅出,简明易懂,所给出的代码全部来源于笔者在实际开发过程中的真实案例,可直接用于音响工程、专业级音乐制作及录音混缩类软插件的设计开发及编程调试。此外还详细列举了普通程序员在这些领域的开发过程中最常遇到的陷阱、踩到的雷区与对应的解决技巧。现将该书的部分内容与写作提纲发布于此。所公布的内容主要有6个部分:数字音频基础概念,MIDI编程的常识与技巧,DSP基本处理,波形合成,波表合成,音频/MIDI类程序专用控件的编程技巧。

  · 数字音频基础概念
    · 信号(Signals
    · PCM(脉冲编码调制
    · 正弦波(Sinusoids
    · 复数(Complex
    · 采样精度
    · 音频插件(Plug-in
    · 插件开发
    · 延迟线(Delay Line
    · 动态处理
    · 字节序
    · 对数换底公式
    · 和声与频率
  · MIDI编程基础
    · MIDI消息
    · 运行状态
    · 复音触后(PolyKey Pressure
    · 控制器改变(Controller Change
    · 系统消息
    · MIDI编程其他重要概念与常用技巧
  · DSP基本处理
    · 标准化(Normalized
    · 混合信号(Mixer
    · 缩放振幅(Scaling
    · DC偏移(Offset
    · 环形调制(Ring Modulation
    · 傅里叶级数(Fourier Series
    · 延迟(Delays
    · 线性声像(Pan
    · 线性插值
    · 指数衰减
    · 振幅(采样值)与分贝值的互转
    · 依据采样值绘制可视波形
    · 采样率、采样帧数、采样个数、时值的互转
  · 波形合成
    · 所需的常量和变量
    · 各种波的采样值计算公式
    · 递增相位
    · 更简单的正弦波生成
  · 波表合成(Wavetable Synth
  · 音频与MIDI程序常见的专用控件
    · XY Controller(XY控制器
    · Line Graph View(包络线
    · Waveform View(波形振幅缩略图和幅频缩略图
    · Meter(电平表
    · Piano Roll(钢琴卷帘
    · Notation View(乐谱
    · Grid Editor(栅格编辑器
    · Timeline(时间线

数字音频基础概念


信号(Signals

可用函数进行描述的、更加紧凑或简化的信息载体。音频领域,信号通常为一维函数f(t),持续流逝或变化的时间或空间是自变量,因变量则是气压、电压(振幅)。

信号具有连续属性,由于计算机的固有限制和特性,信号通常使用离散时间和该时间所对应的值(离散采样)来表示,此即“数字信号(Digital Signal)”。基于时域的离散数字信号常用函数f(n)来表示,n(时间点)相当于数组的下标(索引),可通过下标n访问某个时间点的信号的值(振幅、电压控制值等)。离散的数字信号具有双极性,它表示自然界中的真实信号(模拟信号)是通过采样和量化等技术来实现的,量化决定了振幅的分辨率和声音的最大动态范围,此即ADC(模拟到数字的转换)。单位时间内采样的个数称为“采样率”,比如:1秒钟采样44100个,采样率即为44100Hz。每个采样之间的时间间隔称为“采样周期”或“采样增量”,采样率和采样周期互为倒数。

PCM(脉冲编码调制

基于PCM脉冲编码调制生成的数字信号具有两种基本属性:一是量化处理是线性的,二是信号的采样值代表离散的振幅,即两个采样周期之间的信号值是相同的,由此构成的波形,是阶梯式的块状图(阶梯的宽度即采样周期的时值,而两个极值之间有多少个阶梯,则取决于量化的分辨率,即采样精度,亦即数据类型的bit数。比如:short类型是16位精度,则两个极值之间最多可表示65536 /2个(2n-1)实际振幅值。而实际的采样个数,则由采样率来决定,即每秒采样多少次)。

自然界中的真实信号是连续的,其值不断变化,所构成的波形是完美的曲线,而非块状阶梯。量化后的采样值,采用两个采样点中间的实际值(半采样周期的宽度)。此时,位于两个振动周期边界的采样值会出现量化误差,即量化噪声,这是数字信号信噪比(响度的动态范围)这一概念的基本由来。

某个采样精度的最大动态范围可由以下公式计算出来(信噪比按dB响度值进行转换):
SNRdB = 20 × Lg2n = n × 20Lg2 ≈ 6.02 × n (n为采样精度,比如16 ,24 ,32 bit等

由此可推知:16 bit采样精度(量化精度)的最大动态范围约为96.32dB,24 bit则约为144.48dB。但是,实际中,由于量化噪声(量化误差)带来的信噪比问题,实际动态范围要小于最大动态范围,特别是小振幅的信号,这是PCM固有的缺陷之一。

正弦波(Sinusoids

正弦函数sin(a)代表角度a的直角对边O除以斜边H的比值,即:sin(a) = O / H. 按照惯例,H(圆半径)的值取1,则sin(a) = O。见下图所示。

上图:正弦函数及其周期性图示

同理,cos(a) = A / H = A. 因此,点s的坐标为(A, O)。在一个周期内,A和O的数值范围为-1 ~ 1,对应0到2π弧度(1π = 周长 / 直径,2π = 周长 / 半径,亦即:90度角所对应的弧度是π/2,180度角对应的弧度是π,270度角对应的弧度是3π/2,360度角对应的弧度是2π)。由cos()和sin()所构成的坐标点,在2π范围内(一个圆周)可构成一个圆,即半径1转了一圈做描绘出的点。角度a相当于某个旋转时刻的角度,假设该时间点为t,如果转得快,则a的角度值就大;转的慢,则a在时间t的角度值就小。旋转的快慢即可视为振动频率的高低(振动周期的长短)。

上述旋转,圆心不改变位置。如果圆心一并位移,即该圆边旋转边向右移动,则描绘出的点则构成正弦波曲线。即:正弦波是sin()函数带上时间系数所输出的连续采样值所构成的波形,这是DASP中最基本和最重要的信号波。正弦波的音高频率即1秒钟内该圆的旋转周期数,而该圆的位移速率的倒数则相当于信号采样率。振动周期的单位为Hz,表示为f。

复数(Complex

正弦函数可用复数来表示,a + ib这种形式即为复数,a为实数部分,ib为虚数部分。i 2 = -1. 正弦函数的复数形式为:c(n) = cos(wn) + j sin (wn)。j即i。两个采样值相加,用复数形式计算,得出的最终结果为一个实数:c(n) + d(n) = 2a co s(wn)。a为半径(最大振幅值)。

采样精度

音频数据的采样精度(位率),实际上是指每个采样的数据类型。

音频插件(Plug-in

插件是宿主程序在运行过程中根据需要或用户指令动态加载的一个程序,该程序不能独立运行,只能由宿主调用(将其加载到系统进程中)。插件被加载后,它所提供的接口(函数)供宿主调用(通常采用函数回调技术)。对宿主来说,插件是一个“黑盒子(Black Box)”,除了接口之外,宿主对插件的内部实现和功能一无所知。插件和面向对象编程具有天然的适配性,一个插件可视为一个类,当前已加载并正在运行的插件实例相当于该类的对象。一个典型的音频插件具有以下元素(特性、接口):输入、输出、参数、内部数据或选项、初始化、设置和获取参数、重新初始化、数据处理(依据输入数据或参数,产生输出数据的核心操作)。依据不同的插件规范(格式),宿主程序了解这些元素中的接口部分,当加载插件后,宿主将自动调用插件的接口函数,而具体实现的功能和处理则由插件来完成。

以VST插件格式为例,宿主加载插件的方式是:调用类似于creatEffectInstance()之类的函数创建并返回插件类的堆对象。


AudioEffect* createEffectInstance (audioMasterCallback audioMaster) 
{
    return new MyPlugIn (audioMaster, programNum, paramNum);
}

插件开发

目前流行的跨平台的音频插件格式为VST(基于C++,默认1进2出,所有参数的有效范围为0.0到1.0),该格式提供了供程序员使用的SDK开发包,其中有两大基类。编写插件时,需派生AudioEffectX的子类,该基类继承自VST的另一个基类AudioEffect。这两个基类提供了一些虚函数,派生类可根据需要实现之(并不需要全部实现)。大概的编码流程:构造和析构,检索宿主所提供的参数,处理音频信号。

OSX平台下专用的音频插件的格式为Audio Unit(AU)。其组织结构和编码流程与VST基本一致。

Windows平台下,插件程序的扩展名为“.dll”,Linux平台下为“.so”,OSX平台下为bundles包(经常隐藏在一个小的目录下,包括一些可访问的资源文件,可将该目录打包为一个文件)。

延迟线(Delay Line

DSP领域最重要的概念之一。模拟信号领域,Delay Line代表可将电信号延迟一段时间的元器件。DASP领域,特指用于存储音频数据的buffer(循环缓冲)。

Delay Line的作用是:将输入端不规则或偶发的信号集中存储在某个元器件(ASP领域)或缓冲区(DSP领域)中,而后以规则、稳定的方式对外输出(也可以将信号进行某种处理后,重新回馈到输入端,而后再次从输入端接收之)。

动态处理

压缩的扩展用途

注意:压缩等音频效果器中,可用MIDI控制器(CC)控制效果器参数。

字节序

不同类型的CPU,数据在内存中排列的字节序是不同的,主要有大端与小端两种。比如,一个四字节的十六进制数据为:0X456789AB,大端平台下,其在内存中的排列是:45 67 89 AB;小端平台下,内存排列则是:AB 89 67 45(均为每两个数字占一个字节)。Intel平台,数据采用Little Endian小端字节序。小端字节序的平台下读取大端字节序的文件和数据时,必须对字节进行左右倒置,反之亦然。

WAVE音频格式采用的是小端字节序(Little Endian),AIFF采用的则是大端字节序(Big Endian)。

以下代码为判断本机的字节序(大端字节序返回true,小端字节序返回false):


static const bool isBigEndian() 
{
    const int one = 1;
    return *(char*) & one == 0;
}

对数换底公式

已知a和b的值,求a的几次方等于b(即:logab):
double x = log(b) / log(a); // a的x次方 == b

注意:C语言的log()函数以e为底

和声与频率

和声学中的和弦乐理与数学有莫大的关系,大三和弦的音高频率比值为1:1.25:1.5,小三和弦的音高频率比值为1:1.2:1.5。假设根音为A,其音高频率为440Hz,则大三和弦A的三音频率为550Hz,五音频率为660Hz。但是,音乐学中的和声频率比值并不严格遵循数学上的有理数比值。事实上,五音的音高比值通常为1.498307。此即12平均律的由来(1.5比值为数学上的毕达哥拉斯音律)。

无论平均律,还是数学音律,基频的2.0比率均为升高一个八度。

MIDI编程基础


MIDI消息

可分为7种类型,按状态字节的高位字节数进行划分(数值范围0-127):

消息类型说明状态字节数据字节1数据字节2
Note Off音符停止发声0x80 – 0x8F音符编号力度值
Note On音符发声0x90 – 0x9F音符编号力度值
PolyKey Pressure复音触后0xA0 – 0xAF音符编号压力值
Controller Change控制器改变0xB0 – 0xBF控制器编号控制值
Program Change音色改变0xC0 – 0xCF音色编号N/A
Channel Pressure通道压力0xD0 – 0xDF压力值N/A
Pitch Bend弯音0xE0 – 0xEF粗调值精调值

上表:MIDI消息的类型,状态字节和数据字节

其中,Note On音符开消息的0x9X中的高位字节9代表音符开,而低位字节0到F则代表该消息所位于的MIDI通道(0到F共代表16个MIDI通道,0x90代表第一通道的音符开消息)。一个典型的3字节MIDI消息为:

0x92 0x40 0x51

可读为:第3通道音符发声,E4音符(编号为60号,即十六进制的0x40),力度值为81(对应十六进制的0x51)。也可从右至左进行解读:力度值为81的E4音符开始发声,该音符位于第3通道。

运行状态

MIDI消息中非常重要的概念。即:如果当前消息的类型(状态字节)与上一条消息的类型(状态字节)一致,则不再重复发送状态字节。也就是说:MIDI消息的类型具有后延性。这样一来,可大大提高传输效率。采用“运行状态”来发送和接收MIDI消息,每秒可处理1000条以上。通常,音符停止发声的状态字节不使用0x8X这个数值范围,而是使用音符发声的状态字节0x9X,同时将音符的力度值设置为0。大多数MIDI编程均采用这种方式。如此一来,配合“运行状态”技术,可进一步提高效率。如果坚持使用0x8X来停止音符,则该消息的数据字节2力度值代表音符停止发声时的力度,即释放时的力度值。

注意:力度值并不绝对代表音符发声的响度,甚至可将某些音色的力度响应定义为LFO的调制比率。

复音触后(PolyKey Pressure

即通道触后,同时按下多个音符,复音发声时,其中某个或某些音符可单独执行力度触后,此谓复音触后。对应的则是单音触后(影响所有同时发声的音符)。单音触后和通道触后不同,通道触后仅影响特定通道的所有音符,单音触后则影响所有通道中同时发声的音符。

控制器改变(Controller Change

简称CC,常用的CC有:

控制器编号作用控制器编号作用
CC 0选择(切换)音色库CC 1调制轮
CC 2呼吸控制器CC 4脚踏控制器
CC 6数据登录(MSBCC 7音量
CC 10声像CC 11表情
CC 64延音踏板CC 71声音控制器2(木管谐波
CC 74声音控制器5(铜管亮度 CC 91混响效果
CC 93合唱效果CC 120通道模式,余响停止发声
CC 121通道模式,重置所有控制器CC 123通道模式,音符停止发声

系统消息

字节数据范围:0xF0到0xFF。系统消息是全局消息,影响整个系统,不包含与通道有关的数据,可实现多种用途,比如设备之间的同步,或发送专用的消息,等等。比如:

注意:系统消息的字节数值中无F4、F5、F9、FD。

MIDI编程其他重要概念与常用技巧


/** 基于标准A音(音符编号:69)的音高频率,返回1参MIDI音符所对应的音高频率 */
double getFreq (const int noteID, const double freqOfA = 440.0)
{
    return freqOfA * pow (2.0, (noteID – 69) / 12.0);
}

DSP基本处理


标准化(Normalized

即振幅最大化。示例:


float maxValue = 0.f;

// 先查找最大值
for (int i = samplesNum; --i >= 0; ) 
    maxValue = (samples[i] > maxValue) ? samples[i] : maxValue;

if (maxValue != 1.0f && maxValue > 0.f)
{ 
     // 所有采样值除以最大值后赋值给自身
    for (int i = samplesNum; --i >= 0; ) 
        samples[i] /= maxValue;
}

混合信号(Mixer

两个或多个信号的同一个时间点的采样值相加。需注意三个常见的陷阱:反相归零;相位漂移;音高掩盖。

缩放振幅(Scaling

每个采样值乘以一个百分比值(缩放因子,增益/衰减比率,大于1为增益,小于1为衰减)。

DC偏移(Offset

每个采样值加上一个常量(偏移量),注意要进行振幅缩放,以防止过载。可生成单极信号。

环形调制(Ring Modulation

将两个不同音高频率的信号的采样值逐个相乘。调制后的振幅和音高频率为:0.5 × (两个信号的音高频率之和 + 两个信号的音高频率之差

傅里叶级数(Fourier Series

某个音高频率(基频)所构成的单周期正弦波可解构为多个不同频率的正弦波所混合的结果。这些不同的正弦波的音高频率为基频的整数倍,可以生成的新的正弦波的个数可以计算出来:基频反复乘以从2开始递增的整数,只要结果频率小于采样率/2即可。此时,递增的次数即为可生成的新正弦波的最大个数,由这些频率所构成的数列即为傅里叶级数。

延迟(Delays

将当前采样值衰减后与后面的采样值相加,相当于同一信号的不同时间点的采样值错位混合。回声、混响、延迟等音频效果处理中均用到此技术。示例:


// delayTime为延迟的秒数,sampleRate为采样率,delays为延迟的采样个数
const int delays = delayTime * sampleRate;
const float ampRatio = 0.5f; // 延迟采样的振幅衰减比例

for (int i = samplesNum; --i >= 0; )
    samples[i] += (delats < i) ? (samples[i – delaatSamples] * ampRatio) : 0.f;

线性声像(Pan


/** 计算线性声像模式下,左右通道的增益比率。
1参:当前声像位置:-1.0为极左,1.0为极右,0为居中。
2参:所传入的左通道增益值,本函数将修改之
3参:所传入的右通道增益值,本函数将修改之 */
void getLinerPanRatio (const double& panValue, double& leftFactor, double& rightFactor) const 
{
    leftFactor = (panValue <= 0) ? 1.0 : 1.0 – panValue;
    rightFactor = (panValue <= 0) ? 1.0 + panValue : 1.0;
}

线性插值

每个采样值均有两个属性,相当于坐标系中的一个点,x坐标对应该采样所位于的时间点,y坐标对应振幅()。现已知开始点的时间和值、结束点的时间和值,给出两者之间任意一个时间点,算出该点的值。这个值,就称为“插值”。换一种说法:该值不是利用三角函数或其它波形合成公式计算出来的,而是根据开始点、结束点、所位于的时间点这三个要素“插”进来的。算法实现:

  1. 首先判断插值点的时间是否大于等于结束点的时间,如果是,则直接返回结束点的值
  2. 而后判断开始点和结束点的时间(x坐标)是否一致,如果一致,返回结束点的值
  3. 计算插值比率,公式:frac = (插值时间 – 开始时间) / (结束时间 – 开始时间
  4. 根据插值比率算出插值点的值,公式:value = 开始值 + (结束值 – 开始值) * 差值比率

线性插值不仅常用于音频和DSP领域,其它领域运用的也非常广泛,比如图像处理等。

指数衰减

对一批连续的采样值进行指数增益或衰减,增益(衰减)率计算公式为:

增益/衰减率 = pow(结束值 / 开始值, 1.0 / 采样数

常见陷阱与解决技巧:如果是从零增益至最大振幅,开始值设为0.0001(1.0E-4,约-80dB),不能是0。可以在计算后再将第一个采样值置为0。同理,如果是衰减,结束值也同样设为0.0001(只能是无限逼近0,却永远不会等于0,这是指数函数的本质,可参见指数函数的坐标图),计算后再将最后一个采样值置零。

振幅(采样值)与分贝值的互转


// 参数必须大于0。如果是最小振幅,则可置为1.0E-4
double dB = 20.0 * log10 (sampleValue);

满刻度振幅1.0f对应0dB,0.5f对应-6dB。振幅每 * 0.5f,分贝值下降6dB。

可导出分贝值转换为振幅(采样值)的对应公式:振幅 = pow (10, dB / 20)

依据采样值绘制可视波形

y = 绘制区域的高度 - (samples[i] * y轴方向上的半径(振动高度) + y轴方向上的圆心坐标)

采样率、采样帧数、采样个数、时值的互转

实际使用中,所谓的采样个数其实指的是采样帧数。因为每个通道均有自己的采样值,同一个通道的所有采样个数加到一起,才是采样帧数。

波形合成


所需的常量和变量


const double sampleRate = 44100.00;  // 采样率
const frequency = 220.00;            // 所生成的音高频率

const double increment = 2.0 * double_Pi / sampleRate * frequency;
double currentPhase = 0.0;         // 当前采样的相位弧度
float sample = 0.f;                // 当前采样值

各种波的采样值计算公式


sample = (float)sin (currentPhase);         // 正弦波

sample = (currentPhase <= double_Pi) ? 1.f : -1.f; // 方波

sample = float (1.0 – currentPhase / double_Pi);   // 降齿波
sample = float (currentPhase / double_Pi – 1.0);   // 升齿波

// 三角波
sample = float (currentPhase / double_Pi – 1.0); 

if (sample < 0) 
    sample = -sample;

sample = 2.f * sample – 1.f;

递增相位

生成当前采样值之后,紧接着调用以下语句,递增相位弧度,并确保在0到2π之间。


currentPhase += increment;
const double twoPi = 2.0 * double_Pi;

if (currentPhase >= twoPi) 
    currentPhase -= twoPi;

if (currentPhase < 0.0) 
    currentPhase += twoPi;

反复调用采样值计算和相位递增语句,每次调用均生成一个正弦波采样值。调用的次数为所需的采样数。

更简单的正弦波生成


for (int i = samplesNum; --i >= 0; )
    sampleValue = float (sin (2.0 * double_Pi * 音高频率 * i / 采样率));

波表合成(Wavetable Synth


上面生成每个采样值时,均需调用sin()函数,该函数的参数同样要经过一系列的double运算,效率较低。实际上,固定音高和采样率的正弦波,每个振动周期均由一批相同的振幅值所构成,多个振动周期只不过是重复这些值而已。每秒钟的振动周期为音高频率,采样率 / 音高频率 = 每秒的振动次数(几个周期),由此可推导出单次振动周期包含了几个采样点。将构成单次振动周期的采样点存储到一个数组中,而后用查表法获取对应时间点的采样值,即可生成任意时间长度的所有采样值。

这种将单次振动的采样值存储起来,使用时查表获取的技术,即“波表合成”。可提前将波表中的采样值存储到数组中,也可生成波形时,根据给定的音高频率临时存储到数组中。通常,采用事先存储(固定表长),不同的采样率和音高频率,读取时采用“跳增”的方式读取某个时间点所对应的采样值(采样增量)。由于低频的振动周期较长,因此,需要更多的波表值。如果波表中的采样数不够,则使用重复读取某个值的常用策略。

由于音高频率并非整数倍增加或减少,采样增量实际上是一个分数值(实数),而非整数值。也就是:给出的采样点,对应的波表的索引是一个小数,此时可采用线性插值的方法计算出所需的、表中没有的值。如果要求不高,也可以直接舍弃小数部分,按整数索引获取表中的对应值(可能会引入噪声或失真)。

波表合成技术可以避免重复,同时又能大幅度提升效率,不仅适用于合成声音和信号,更可作为一种策略和技巧运用于实际编程的各类算法实现中。

波表合成方面的常见技巧、各类示例及代码:(

音频与MIDI程序常见的专用控件


除了各类常规的GUI控件(组件)之外,音乐制作类(录音及MIDI音序器)软件通常需要更丰富和特殊的控件,比如:

XY Controller(XY控制器

一块矩形区域,代表一个坐标系的象限,通常用一个小圆圈或十字线代表当前坐标点,该点的x坐标和y坐标可分别代表一对有所关联的音乐参数,比如音量和声像、振幅和频率、速度和音高等等。鼠标拖拽坐标点可改变其值。

Line Graph View(包络线

类似于图形压缩器的压缩曲线、音块所显示的包络线。包络线由一到多个包络点构成。该点同样具有两个属性值,比如:时间和数值、位置和大小等等。

Waveform View(波形振幅缩略图和幅频缩略图

Meter(电平表

Piano Roll(钢琴卷帘

Notation View(乐谱

Grid Editor(栅格编辑器

以栅格的形式显示一段波形,每个栅格的波形可单独编辑或改变其左右位置,栅格对应乐曲的节拍。常用于打击乐器采样的编辑(波形切片功能)。

Timeline(时间线

以下代码详解了以上各控件的设计与编程要点(代码太多,网站发布时略)。

本书作者:SwingCoder


如果本文对您有所启发或助益,请微信打赏

Email: underwaySoft@126.com 微信公众号: UnderwaySoft