C# 合成器工具包 - 第 II 部分






4.84/5 (21投票s)
演示如何使用 C# 合成器工具包创建简单的合成器。
目录
引言
这是我的 C# 合成器工具包系列文章的第二部分。 第一部分 概述了该工具包。 在这一部分,我们将采取更实际的方法,创建一个非常简单的合成器。 这个合成器每个音色只有一个振荡器。 振荡器能够合成三种波形之一:锯齿波、方波或三角波。 它能够向左或向右进行声像(panning)。 此外,合成器将使用工具包的合唱效果。 即使这个示例合成器架构简单,它也将帮助我们理解如何使用该工具包来创建自己的合成器。
SimpleOscillator 类
创建合成器的第一步是编写振荡器组件。 振荡器负责创建合成器的波形。 我们称之为 SimpleOscillator
。 它将派生自 StereoSynthComponent
类,因为它的输出是立体的,并实现 IProgramable
和 IBendable
接口。
枚举
创建一个公共枚举来表示组件的参数是有帮助的,但不是必需的。 这可以让组件的客户端知道它有哪些参数。 我们的 SimpleOscillator
类只有两个参数:声像位置和波形类型。 除了有一个表示参数的枚举外,我们还将添加一个表示波形类型的枚举。
public class SimpleOscillator : StereoSynthComponent, IProgramable, IBendable
{
#region Enumerations
public enum ParameterId
{
Panning,
WaveformType
}
public enum WaveformType
{
Sawtooth,
Square,
Triangle
}
#endregion
// Rest of class...
}
字段
接下来是 SimpleOscillator
的字段。 每个字段都附有解释其含义的注释
public class SimpleOscillator : StereoSynthComponent, IProgramable, IBendable
{
// Enumerations...
#region Fields
#region Constants
// The number of waveforms.
public const int WaveformTypeCount = (int)WaveformType.Triangle + 1;
#endregion
// Determines the oscillator's position in the stereo field.
private double panning = 0.5;
// The type of waveform the SimpleOscillator is currently producing.
private WaveformType waveType = WaveformType.Sawtooth;
// The note that is currently playing.
private int currentNote;
// Phase accumulator.
private double accumulator = 0;
// The amount to modulate the pitch based on pitch wheel movement.
private double pitchBendModulation = 0;
// Indicates whether the SimpleOscillator is currently playing.
private bool playing = false;
// Indicates whether the SimpleOscillator will overwrite the
// values in its buffer each time it synthesizes output.
private bool synthesizeReplaceEnabled = true;
#endregion
// Rest of class...
}
构造函数
让我们看看 SimpleOscillator
的构造函数。 有两个
public class SimpleOscillator : StereoSynthComponent, IProgramable, IBendable
{
// Enumerations and fields...
#region Construction
public SimpleOscillator(SampleRate sampleRate, StereoBuffer buffer)
: base(sampleRate, buffer)
{
Initialize();
}
public SimpleOscillator(SampleRate sampleRate, StereoBuffer buffer,
string name)
: base(sampleRate, buffer, name)
{
Initialize();
}
private void Initialize()
{
currentNote = A440NoteNumber;
}
#endregion
// Rest of class...
}
两个构造函数之间的唯一区别是第二个构造函数有一个 name
参数。 有时为合成器组件命名(例如,“幅度包络”)很有帮助,但它是可选的;不是必需的。
请注意 SampleRate
对象。 我发现为表示采样率而创建专用的 SampleRate
类对设计工具包很有帮助。 合成器内的所有组件共享相同的采样率。 与其为每个组件提供一个 SampleRate
属性(在合成器采样率更改时需要更新),不如让所有组件共享同一个 SampleRate
对象。
当采样率更改时,合成器会更新其 SampleRate
对象的 SamplesPerSecond
属性为新的采样率值。 该对象进而引发 SampleRateChanged
事件。 需要在采样率更改时得到通知的单个组件会注册此事件,并在事件触发时进行必要的内部值更新。 我们的 SimpleOscillator
类不需要此通知,因此它只需将 SampleRate
对象传递给基类而不注册事件。
SimpleOscillator
具有立体声输出,因此它使用传递给其构造函数的 StereoBuffer
对象。 在合成其输出时,它会将结果写入其 StereoBuffer
。 StereoBuffer
是一个轻量级包装器,围绕着一个多维数组(左声道和右声道两个维度)。 MonoBuffer
由单声道输出的合成器组件使用。 它是围绕一个一维数组的轻量级包装器。 StereoBuffer
和 MonoBuffer
类都使缓冲区管理更加容易。 与采样率值一样,缓冲区大小值由合成器内的所有组件共享。 缓冲区类使更新缓冲区大小更加容易,因为每个 Voice
都会跟踪其组件使用的所有缓冲区。 当缓冲区大小更改时,Voice
可以更改所有正在使用的缓冲区的大小,而无需组件担心。
两个构造函数都调用 Initialize
方法。 此方法包含从构造函数中提取出来的初始化代码;它只是一个辅助方法。 它将 currentNote
字段初始化为表示 A 440Hz (69) 的 MIDI 音符编号。 此时,SimpleOscillator
处于有效状态,可以使用了。
重写的方法和属性
SimpleOscillator
类派生自抽象的 StereoSynthComponent
类。 该类又派生自 SynthComponent
类。 SynthComponent
类有许多方法和属性我们必须重写。
- 方法
Synthesize(合成)
触发器
Release
- 属性
SynthesizeReplaceEnabled(合成替换启用)
Ordinal
Synthesize
方法是任何合成器组件的核心。 在此,组件合成其输出。 Synthesize
方法接受两个整数参数:offset
和 count
。 offset
参数是缓冲区中的零基索引偏移值。 它指示组件应在何处开始在其缓冲区中合成输出。 count
值指示应合成多少个样本。 SimpleOscillator
的 Synthesize
方法有些复杂,坦白说,并不太好看。 因此,为了简洁起见,我将在此省略显示。 请参阅下载中包含的 SimpleSynthDemo
项目以获取更多详细信息。
SimpleOscillator
的 Trigger
和 Release
方法如下所示
public override void Trigger(int previousNote, int note, int velocity)
{
currentNote = note;
playing = true;
}
public override void Release(int velocity)
{
playing = false;
}
当 Synthesizer
响应 MIDI note-on 事件触发 Voice
时,会调用 Trigger
方法。 Voice
进而触发其组件。 在这里,SimpleOscillator
会跟踪正在播放的音符。 此外,它会设置一个标志,指示它当前正在“播放”。 它会忽略 previousNote
和 velocity
参数。
previousNote
参数指示之前正在播放的音符。 这对某些组件可能很有用。 例如,负责滑音的组件需要知道之前正在播放的音符,以便它可以从前一个音符扫到当前音符。 velocity
参数指示当前音符播放的速度。 能够响应速度的组件将使用此值来调整其输出。
这是 SimpleOscillator
重写的属性
public override bool SynthesizeReplaceEnabled
{
get
{
return synthesizeReplaceEnabled;
}
set
{
synthesizeReplaceEnabled = value;
}
}
public override int Ordinal
{
get
{
return 1;
}
}
SynthesizeReplaceEnabled
属性指示一个组件在每次合成其输出时是否会覆盖其缓冲区中的值。 对于某些组件,缓冲区不被覆盖很有用。 例如,您可以让多个组件共享同一个缓冲区。 如果它们将输出 ADD
到缓冲区而不是覆盖缓冲区,则每个组件的输出会与其他组件混合在一起。 这有助于提高效率。
Ordinal
值表示组件应排序的顺序。 如第一部分所述,每个 Voice
都会跟踪其组件,并根据它们的 Ordinal
值进行排序。 本质上,组件被组织成一个定向无环图。 组件的 Ordinal
值是其所有输入的 Ordinal
值之和加 1。 由于 SimpleOscillator
组件没有任何输入,因此其 Ordinal
值为 1。
IProgramable 接口
IProgramable
接口表示获取和设置参数值的功能。 通常,合成器组件实现此 interface
以允许操作其参数。 该 interface
如下所示
public interface IProgramable
{
string GetParameterName(int index);
string GetParameterLabel(int index);
string GetParameterDisplay(int index);
double GetParameterValue(int index);
void SetParameterValue(int index, double value);
int ParameterCount
{
get;
}
}
每个方法的 index
参数是对象参数的零基索引。 GetParameterName
方法顾名思义,获取参数的名称。 GetParameterLabel
获取一个字符串,表示参数应如何标记。 想象一下硬件合成器上的旋钮或开关标签。
例如,SimpleOscillator
声像参数的标签是“Left/Right”。 GetParameterDisplay
方法获取参数值的文本表示。 所有参数的值范围都在 [0, 1] 之间。 然而,这个值可能不是最直观的用户显示。 为参数提供更易理解的文本表示可能很有用。 例如,SimpleOscillator
的声像参数显示为 [-1, 1] 的范围,其中 0 为中心,-1 为最左,1 为最右。
GetParameterValue
和 SetParameterValue
方法分别获取和设置参数的值。 需要记住的重要一点是,如上所述,所有参数的范围都是 [0, 1]。 ParameterCount
属性获取实现类提供的参数数量。 对于使用 VST 编程的人来说,这一切可能看起来很熟悉。 IProgramable
接口是基于 VST 处理参数的方法。
让我们看一下 SimpleOscillator
对 GetParameterName
方法的实现
public string GetParameterName(int index)
{
#region Require
if(index < 0 || index >= ParameterCount)
{
throw new ArgumentOutOfRangeException("index");
}
#endregion
string result = string.Empty;
string name = Name;
if(!string.IsNullOrEmpty(name))
{
name = name + " ";
}
switch((ParameterId)index)
{
case ParameterId.Panning:
result = name + "Panning";
break;
case ParameterId.WaveformType:
result = name + "Waveform";
break;
default:
Debug.Fail("Unhandled parameter.");
break;
}
return result;
}
}
首先,该方法确保 index
在范围内。 其次,它检索 SimpleOscillator
的名称。 如果未为组件命名,则名称可能为空。 如果名称不为空(或 null),则在名称后附加一个空格。 最后,该方法将 index
强制转换为我们创建的用于表示参数的 ParameterId
枚举类型,并根据该值进行切换。
合成器组件的名称会预置到每个参数的名称之前。 这有助于区分属于同一合成器组件不同实例的参数。 例如,假设您有两个 ADSR 包络对象,一个用于调制合成器的幅度,另一个用于调制其滤波器。 这些包络分别命名为“Amplitude Envelope”和“Filter Envelope”。 这两个包络都有一个攻击时间参数。 但是,幅度包络的攻击时间参数将命名为“Amplitude Envelope Attack Time”,而滤波器包络的攻击时间参数将命名为“Filter Envelope Attack Time”。
接下来是 GetParameterLabel
方法
public string GetParameterLabel(int index)
{
#region Require
if(index < 0 || index >= ParameterCount)
{
throw new ArgumentOutOfRangeException("index");
}
#endregion
string result = string.Empty;
switch((ParameterId)index)
{
case ParameterId.Panning:
result = "Left/Right";
break;
case ParameterId.WaveformType:
result = "Type";
break;
default:
Debug.Fail("Unhandled parameter.");
break;
}
return result;
}
此方法仅返回一个文本表示,说明参数应如何标记。 同样,想象一下硬件合成器上常见的旋钮和开关标签。
接下来是 GetParameterDisplay
方法
public string GetParameterDisplay(int index)
{
#region Require
if(index < 0 || index >= ParameterCount)
{
throw new ArgumentOutOfRangeException("index");
}
#endregion
string result = string.Empty;
switch((ParameterId)index)
{
case ParameterId.Panning:
{
double position = panning * 2 - 1;
result = position.ToString("F");
}
break;
case ParameterId.WaveformType:
result = waveType.ToString();
break;
default:
Debug.Fail("Unhandled parameter.");
break;
}
return result;
}
注意 panning
参数以及如何将其转换为 string
。 所有参数的范围都是 [0, 1],但使用实际值显示 panning
参数不太有用。 因此,在此将其转换为 [-1, 1] 范围内的中间值。 然后将此值转换为 string
。
最后,我们有了用于获取和设置参数值的 GetParameterValue
和 SetParameterValue
方法
public double GetParameterValue(int index)
{
#region Require
if(index < 0 || index >= ParameterCount)
{
throw new ArgumentOutOfRangeException("index");
}
#endregion
double result = 0;
switch((ParameterId)index)
{
case ParameterId.Panning:
result = panning;
break;
case ParameterId.WaveformType:
result = (double)(int)waveType / (WaveformTypeCount - 1);
break;
default:
Debug.Fail("Unhandled parameter.");
break;
}
return result;
}
public void SetParameterValue(int index, double value)
{
#region Require
if(index < 0 || index >= ParameterCount)
{
throw new ArgumentOutOfRangeException("index");
}
else if(value < 0 || value > 1)
{
throw new ArgumentOutOfRangeException("value");
}
#endregion
switch((ParameterId)index)
{
case ParameterId.Panning:
panning = value;
break;
case ParameterId.WaveformType:
waveType = (WaveformType)(int)Math.Round(value *
(WaveformTypeCount - 1));
break;
default:
Debug.Fail("Unhandled parameter.");
break;
}
}
请注意波形类型参数正在进行的转换。 在 GetParameterValue
方法中,它被转换为 [0, 1] 范围内的值(如工具包所要求)。 在 SetParameterValue
方法中,它被转换为表示波形类型的枚举。
IBendable 接口
IBendable
接口表示响应音高弯音轮生成的调制的函数。 Synthesizer
接收音高弯音消息,并根据其音高弯音范围设置将其转换为调制值。 这些值将被传递给所有实现 IBendable
接口的对象。
这是 IBendable
接口;它非常简单
public interface IBendable
{
double PitchBendModulation
{
get;
set;
}
}
SimpleOscillator
通过简单地跟踪音高弯音调制来实现此接口。 它稍后将在其 Synthesize
方法中使用此值来调制其音高。
#region IBendable Members
public double PitchBendModulation
{
get
{
return pitchBendModulation;
}
set
{
pitchBendModulation = value;
}
}
#endregion
总结 SimpleOscillator 类
在离开 SimpleOscillator
类之前,我们将为其添加一个额外的属性,指示它当前是否 playing
public bool IsPlaying
{
get
{
return playing;
}
}
如您所见,编写此类花费了大量精力。 幸运的是,最难的部分已经结束。 使用该工具包时,您的大部分工作将是编写合成器的组件。
SimpleVoice 类
下一步是创建一个派生自 Voice
类的类。 它将表示我们合成器中的单个音色。 派生自 Voice
的类会协调一组协同工作的合成器组件来合成音色的输出。 幸运的是,对于这个例子,我们的音色类比振荡器类更容易实现。
关于 Voice
基类有一个重要的说明。 它本身就是一个合成器组件,并且派生自 StereoSynthComponent
类,该类又派生自 SynthComponent
类。 Voice
类重写了 SynthComponent
类中的一些方法和属性,而将其他方法留给派生类重写。
这是 SimpleVoice
类的实现
public class SimpleVoice : Voice
{
#region SimpleVoice Members
#region Fields
// Used for generating the voice's waveform.
private SimpleOscillator oscillator;
#endregion
#region Construction
public SimpleVoice(SampleRate sampleRate, StereoBuffer buffer)
: base(sampleRate, buffer)
{
Initialize(buffer);
}
public SimpleVoice
(SampleRate sampleRate, StereoBuffer buffer, string name)
: base(sampleRate, buffer, name)
{
Initialize(buffer);
}
#endregion
#region Methods
private void Initialize(StereoBuffer buffer)
{
// Create oscillator passing it the sample rate used by the
// voice and the buffer given to the voice.
oscillator = new SimpleOscillator(SampleRate, buffer);
// Add components that will synthesize output.
AddComponent(oscillator);
// Add components that have parameters that will be adjusted.
AddParameters(oscillator);
// Add components that can respond to pitch bend modulation.
AddBendable(oscillator);
}
public override void ProcessControllerMessage
(ControllerType controllerType, double value)
{
// Nothing to do here as the voice doesn't respond to
// controller messages.
}
#endregion
#region Properties
protected override bool IsPlaying
{
get
{
return oscillator.IsPlaying;
}
}
public override bool SynthesizeReplaceEnabled
{
get
{
return oscillator.SynthesizeReplaceEnabled;
}
set
{
oscillator.SynthesizeReplaceEnabled = value;
}
}
#endregion
#endregion
}
查看 Initialize
辅助方法。 它会创建一个 SimpleOscillator
类的实例,并将音色的 StereoBuffer
传递给它。 SimpleOscillator
将把其输出写入此缓冲区。 然后它会调用 AddComponent
方法。 此方法属于 Voice
基类。 派生类调用此方法将组件添加到 Voice
的组件集合中。 此集合根据每个组件的 Ordinal
值进行排序。 当调用 Voice
的 Synthesize
方法时,它会遍历其组件集合,对每个组件调用 Synthesize
。 这确保组件按正确的顺序合成其输出。
例如,假设一个 LFO 组件正在调制振荡器的频率。 振荡器组件的 Ordinal
值将高于 LFO,因此它将出现在 Voice
的组件集合中的 LFO 之后。 换句话说,LFO 的 Synthesize
方法将在振荡器之前被调用,以确保 LFO 的输出已准备好供振荡器使用。
AddParameters
方法会接收 SimpleOscillator
对象。 这使得 Voice
基类能够自动化参数处理。 您只需添加实现 IProgramable
接口的所有对象,而无需自己协调。 Voice
基类以及 Synthesizer
类会处理其余的。 此外,SimpleOscillator
对象会被传递给 AddBendable
方法。 每当音高弯音轮移动时,其位置值都会被转换并传递给所有 IBendable
对象。
SynthHostForm 类
我们的合成器快完成了。 剩下的唯一一件事就是从 SynthHostForm
类派生一个类。 当应用程序实例化此类的实例时,它将提供一个运行我们 Synthesizer
的环境。 在此类中,我们只会重写几个方法和一个属性
public partial class Form1 : SynthHostForm
{
public Form1()
{
InitializeComponent();
}
protected override Synthesizer CreateSynthesizer
(int deviceId, int bufferSize, int sampleRate)
{
// Create a delegate for creating SimpleVoice objects.
VoiceFactory voiceFactory =
delegate(SampleRate sr, StereoBuffer buffer, string name)
{
return new SimpleVoice(sr, buffer, name);
};
// Create a delegate for creating effect objects.
EffectFactory effectFactory =
delegate(SampleRate sr, StereoBuffer buffer)
{
return new EffectComponent[] { new Chorus(sr, buffer) };
};
return new Synthesizer(
"Simple Synth",
deviceId,
bufferSize,
sampleRate,
voiceFactory,
8,
effectFactory);
}
protected override Form CreateEditor(Synthesizer synth)
{
throw new NotSupportedException();
}
protected override bool HasEditor
{
get
{
return false;
}
}
}
将创建 Synthesizer
对象以使用我们的 SimpleVoice
类作为其音色。 这是通过将 Synthesizer
传递一个返回 Voice
对象的委托来完成的。 换句话说,创建我们自己的 Synthesizer
所要做的就是传递一个创建我们编写的自定义音色的委托。 此外,我们还传递第二个委托来创建效果集合。 这会在合成器中创建效果链。
我们不会为这个合成器创建编辑器,因此 HasEditor
属性返回 false
,而 CreateEditor
方法在被调用时会抛出 NotSupportedException
。
结论
在本部分中,我们创建了一个简单的合成器,并在过程中对该工具包有了更深入的了解。 在第三部分中,我们将创建一个更复杂的合成器,它具有我们与传统减法合成器相关联的许多功能。 但现在,我希望您喜欢到目前为止的旅程。 我期待听到您的评论和建议。 感谢您的时间。
历史
- 2007 年 7 月 16 日 - 第一个版本
- 2007 年 8 月 16 日 - 第二个版本