PianoBox:交互式音乐键盘控件 - MidiUI 第一部分





5.00/5 (4投票s)
PianoBox 是一个可自定义的 Windows Forms 音乐键盘控件。这是 MidiUI 的第一部分。
引言
作为我即将推出的 MidiUI MIDI GUI 小部件套件的一部分,我展示了我的 PianoBox 控件。它是一个可自定义的音乐键盘控件,允许您输入和显示按下/高亮的音符。市面上也有其他类似的控件,但这是我为配合我的 Midi 库 而设计的控件套件中的第一个。
此控件使用了我的 MIDI 库,并已包含其二进制形式。源代码可在上面提供的链接中找到。
更新:添加了热键支持
更新 2:已实现焦点支持
概念化这个混乱的局面
如果没有一些 GUI 控件供用户交互,MIDI 库的用处并不大。正如我之前所说,我目前正在为我的 Midi 库 构建一套控件,而 PianoBox
控件恰好是第一个。它是一个 Windows Forms 控件,允许您通过用户界面进行交互,或者通过代码控制它来显示高亮或“按下”的钢琴键。您可以使用它来显示 MIDI 音轨中当前播放的和弦,以及/或者允许用户使用鼠标和/或计算机键盘来弹奏旋律。控件本身并没有 MIDI 的概念,只关心钢琴音符,但可以通过连接几个事件来将其连接到 MIDI 设备。
外观方面,该控件使用 4 种颜色以简单的二维渲染绘制,这些颜色可以通过控件的属性进行设置。它还可以水平或垂直渲染,这也通过控件属性指示。
行为方面,该控件允许您设置要显示的八度音阶数量,以及设置和清除单个钢琴键。它还允许您为钢琴键分配热键。此外,它还会报告由用户或代码按下的钢琴键。用户交互默认是启用的,但可以通过设置相应的属性来禁用。这并不会完全禁用控件,也不会改变外观,但会阻止用户弹奏钢琴键。
渲染这些乱七八糟的东西
我不得不重写了两次渲染代码,因为第一次的方法根本不像素对齐。我的错误是尝试了两步渲染,而我本应该使用三步。
第一遍
我们在这里要做的就是擦除背景,并使其与“白色”钢琴键的颜色相同。
第二遍
在这里,我们绘制所有高亮键(包括“黑色”和“白色”键)的背景。任何当前按下的键都会为其键背景绘制高亮颜色。这比听起来要棘手。问题在于,我们通过将空间分成每个八度 7 个相等的部分来绘制,每个部分代表一个白键。当我们从左到右(垂直方向甚至更难)前进时,我们本质上每次迭代都会移动 2 或 3 个键,具体取决于我们在键盘上的位置 - 请记住黑键有间隙。对于垂直方向,由于我们需要从下到上排列按键,因此我们在从上到下渲染时会反向计数。
第三遍
在这里,我们终于绘制了“白色”键之间的所有边框线,以及“黑色”键的所有“黑色”矩形部分。我们正在做类似第二遍的事情,将每个八度分成 7 个相等的部分。当我们前进时,我们绘制部分之间的边框,并且经常(好吧,通常)在中间添加一个“黑色”键。当我们从左到右移动时,我们像以前一样每次迭代将键计数增加两到三(垂直方向反向),但这次我们跳过渲染“黑色”键的“黑色”部分,因为它已经在第二遍中以橙色(或任何高亮颜色)绘制了。
命中测试这些乱七八糟的东西
为了将鼠标操作转换为按键,我们必须能够将二维 X 和 Y 坐标转换为钢琴键盘上的某个键。这比看起来要难,因为一个八度内的“白色”键形状不规则。有些看起来像“L”,比如 C
键,有些看起来像反向的“L”,比如 E
键,有些看起来像倒过来的“T”,比如 D
键。一个相关的复杂问题是,黑键在布局上有间隙——实际上是两个间隙,如果您算上八度的边缘(B
和 C
),我们也会算上。这意味着我们不能只用简单的数学方法来确定 X 和 Y 坐标映射到哪个键。
相反,对于每个八度,然后对于每个键,我们都有自己的命中测试代码,该代码会考虑键在八度内的形状和相对位置。
更糟糕的是,我们必须分别为垂直和水平方向执行此操作。
最后,我们对结果进行约束,以确保它在键的有效范围内。
连接这些乱七八糟的东西
为了与代码通信按键的按下和释放,我们有 PianoKeyDown
和 PianoKeyUp
事件,它们分别在钢琴键被按下或释放时触发。通过代码获取和设置按键,还有 GetKey()
和 SetKey()
方法以及只读的 Keys
属性。将一个键设置为按下或释放会触发相应的事件,除非另有说明调用 SetKey()
。Keys
属性返回一个 bool[]
数组,允许您检查每个键的状态。
编写这个混乱的程序
这最初比我通常愿意承认的要令人沮丧,但这主要是因为我一开始做得不对。我没有使用足够的绘图通道,因此我试图计算那些在渲染时容易出现缩放错误和偏移一像素的问题,导致在控件尺寸不完美时高亮音符出现难看的结果。
最终,代码比我最初设想的要复杂,或者说比我想要的要复杂,但我认为代码不应该比它能达到的更简单。有时,我们必须处理复杂的代码。
var g = args.Graphics;
var rect = new Rectangle(0, 0, Width - 1, Height - 1);
using (var brush = new SolidBrush(_whiteKeyColor))
g.FillRectangle(brush,args.ClipRectangle);
// there are 7 white keys per octave
var whiteKeyCount = 7 * _octaves;
int key;
// first we must paint the highlighted portions
// TODO: Only paint if it's inside the ClipRectangle
using (var selBrush = new SolidBrush(_noteHighlightColor))
{
if (Orientation.Horizontal == _orientation)
{
var wkw = Width / whiteKeyCount;
var bkw = unchecked((int)Math.Max(3, wkw * .666666));
key = 0;
var ox = 0;
for (var i = 1; i < whiteKeyCount; ++i)
{
var x = i * wkw;
var k = i % 7;
if (3 != k && 0 != k)
{
if(_keys[key])
g.FillRectangle(selBrush, ox+1, 1, wkw - 1, Height-2);
++key;
if(_keys[key])
g.FillRectangle(selBrush, x - (bkw / 2) + 1, 1,
bkw - 1, unchecked((int)(Height * .666666)));
++key;
if (_keys[key])
g.FillRectangle(selBrush, x, 1, wkw - 1, Height - 2);
}
else
{
if(_keys[key])
g.FillRectangle(selBrush, ox + 1, 1, wkw - 1, Height - 2);
++key;
if(_keys[key])
g.FillRectangle(selBrush, x, 1, wkw - 1, Height - 2);
}
ox = x;
}
if(_keys[_keys.Length-1])
{
g.FillRectangle(selBrush, ox, 1, Width-ox- 1, Height - 2);
}
} else // vertical
{
var wkh = Height / whiteKeyCount;
var bkh = unchecked((int)Math.Max(3, wkh * .666666));
key = _keys.Length-1;
var oy = 0;
for (var i = 1; i < whiteKeyCount; ++i)
{
var y = i * wkh;
var k = i % 7;
if (4 != k && 0 != k)
{
if (_keys[key])
g.FillRectangle(selBrush, 1, oy + 1, Width - 2, wkh - 1);
--key;
if(_keys[key])
g.FillRectangle(selBrush, 1, y - (bkh / 2) + 1,
unchecked((int)(Width * .666666)) - 1, bkh - 2);
--key;
if(_keys[key])
g.FillRectangle(selBrush, 1, y , Width - 2, wkh - 1);
}
else
{
if (_keys[key])
g.FillRectangle(selBrush, 1, oy + 1, Width - 2, wkh - 1);
--key;
if(_keys[key])
g.FillRectangle(selBrush, 1, y, Width - 2, wkh - 1);
}
oy = y;
}
if (_keys[0])
{
g.FillRectangle(selBrush, 1,oy, Width - 2, Height - oy-1);
}
}
// Now paint the black keys and the borders between keys
using (var brush = new SolidBrush(_blackKeyColor))
{
using (var pen = new Pen(_borderColor))
{
g.DrawRectangle(pen, rect);
if (Orientation.Horizontal == _orientation)
{
var wkw = Width / whiteKeyCount;
var bkw = unchecked((int)Math.Max(3, wkw * .666666));
key = 0;
for (var i = 1; i < whiteKeyCount; ++i)
{
var x = i * wkw;
var k = i % 7;
if (3 != k && 0 != k)
{
g.DrawRectangle(pen, x - (bkw / 2), 0, bkw,
unchecked((int)(Height * .666666)) + 1);
++key;
if (!_keys[key])
g.FillRectangle(brush, x - (bkw / 2) + 1, 1, bkw - 1,
unchecked((int)(Height * .666666)));
g.DrawLine(pen, x, 1 +
unchecked((int)(Height * .666666)), x, Height - 2);
++key;
}
else
{
g.DrawLine(pen, x, 1, x, Height - 2);
++key;
}
}
}
else // vertical
{
var wkh = Height / whiteKeyCount;
var bkh = unchecked((int)Math.Max(3, wkh * .666666));
key = _keys.Length - 1;
for (var i = 1; i < whiteKeyCount; ++i)
{
var y = i * wkh;
var k = i % 7;
if (4 != k && 0 != k)
{
g.DrawRectangle(pen, 0, y - (bkh / 2),
unchecked((int)(Width * .666666)), bkh - 1);
--key;
if(!_keys[key])
g.FillRectangle(brush, 1, y - (bkh / 2) + 1,
unchecked((int)(Width * .666666)) - 1, bkh - 2);
g.DrawLine(pen, 1 +
unchecked((int)(Width * .666666)), y, Width - 2, y);
--key;
}
else
{
g.DrawLine(pen, 1, y, Width - 2, y);
--key;
}
}
}
}
}
}
多么冗长的渲染!我们需要进行 3 遍渲染,先渲染背景,然后是选择,最后是“黑色”键 + 边框,并且我们必须在进行过程中计数,还要小心跳过不应渲染的缺失“黑色”键,并且所有这些都要重复两次,一次用于一种方向。
再次注意,我们的移动方式——我们在一个八度内以七个相等的部分移动——一个部分代表一个“白色”键,然后在每次迭代中根据需要渲染“黑色”键和它们之间的边框。这就是为什么前进的方式很奇怪,但我们必须这样做才能获得正确的测量。或者更确切地说,虽然我们可以用另一种方式去做,但这种方式虽然复杂,却是可用选项中最简单的。
命中测试甚至更糟糕,但代码至少相对直观,一旦你理解了它在概念上是如何工作的,就像我之前解释的那样。
我最不喜欢开发控件的是处理控件属性。每个公共属性都应该有一个与之对应的“属性已更改”事件,以及一个调用该事件的受保护的虚拟方法,这是标准的做法。更糟糕的是,由于具有此模式的控件会产生大量事件,因此在处理这些事件以使其全部高效运行需要额外的代码。然后,您需要在属性窗口中提示如何向设计器显示您的属性。所有这些都导致即使是最简单的属性也存在多个不同的、通常被大量属性修饰的成员。这是 Octaves
属性的代码。
static readonly object _OctavesChangedKey = new object();
...
int _octaves = 1;
...
/// <summary>
/// Indicates the number of octaves to be represented
/// </summary>
[Description("Indicates the number of octaves to be represented")]
[Category("Behavior")]
[DefaultValue(1)]
public int Octaves {
get { return _octaves; }
set {
if (1 > value || 12 < value)
throw new ArgumentOutOfRangeException();
if (value != _octaves)
{
_octaves = value;
var keys = new bool[_octaves * 12];
Array.Copy(_keys, 0, keys, 0, Math.Min(keys.Length, _keys.Length));
_keys = keys;
Refresh();
OnOctavesChanged(EventArgs.Empty);
}
}
}
/// <summary>
/// Raised when the value of Octaves changes
/// </summary>
[Description("Raised when the value of Octaves changes")]
[Category("Behavior")]
public event EventHandler OctavesChanged {
add { Events.AddHandler(_OctavesChangedKey, value); }
remove { Events.RemoveHandler(_OctavesChangedKey, value); }
}
/// <summary>
/// Called when the value of Octaves changes
/// </summary>
/// <param name="args">The event args (not used)</param>
protected virtual void OnOctavesChanged(EventArgs args)
{
(Events[_OctavesChangedKey] as EventHandler)?.Invoke(this, args);
}
几乎所有这些额外的代码都是为了支持属性更改事件,使用我们微软强制我们使用的标准/推荐模式。
其余的“花哨”部分是文档注释和各种属性,它们告诉属性窗口在设计器中如何最好地显示我们的属性,例如放在哪个类别下,以及带有哪个描述,因为它不会使用文档注释。
event.add
/event.remove
/Events[...]
这些代码的奇怪之处在于,当使用更简单、更“自动”的方法暴露和引发事件时,在效率方面存在限制,至少在我学习 .NET 1.x 时代的控件开发时是这样。如果有人对此有更改,请纠正我,但如果您有一个控件在没有这样做的情况下会产生大量事件,它会拖慢一切,所以您需要使用这个机制。因此,微软的所有控件都包含一个受保护的 Events
成员来方便使用这种模式,但这会非常费劲。
使用这个烂摊子
尽管有以上所有内容,或者更确切地说,正因为如此,使用该控件非常简单。
只需将此库添加到您的 winforms 项目,将其拖到窗体上,设置一些属性,然后连接您的钢琴键事件,您就大功告成了。我包含了一个我快速创建的示例应用程序,它演示了这一点。它将获取您选择的 MIDI 输入设备的输入并在钢琴键盘上显示。同时,它将您在钢琴键盘上弹奏的任何内容发送到选定的 MIDI 输出设备。
private void Piano_PianoKeyDown(object sender, PianoKeyEventArgs args)
{
if(null!=_outputDevice && _outputDevice.IsOpen && args.Key < 128)
{
_outputDevice.Send(new MidiMessageNoteOn((byte)args.Key, 127,
(byte)ChannelUpDown.Value));
}
}
private void Piano_PianoKeyUp(object sender, PianoKeyEventArgs args)
{
if (null != _outputDevice && _outputDevice.IsOpen && args.Key < 128)
{
_outputDevice.Send(new MidiMessageNoteOff((byte)args.Key, 127,
(byte)ChannelUpDown.Value));
}
}
private void _inputDevice_Input(object sender, MidiInputEventArgs args)
{
if (IsHandleCreated)
{
BeginInvoke(new Action(() =>
{
if (null != _outputDevice && _outputDevice.IsOpen)
{
_outputDevice.Send(args.Message);
}
// when we hit a note on, or note off below
// we set or release the corresponding piano
// key. We must suppress raising events or
// this would cause a circular codepath
switch (args.Message.Status & 0xF0)
{
case 0x80: // key up
var msw = args.Message as MidiMessageWord;
Piano.SetKey(msw.Data1, false, true);
break;
case 0x90: // key down
msw = args.Message as MidiMessageWord;
Piano.SetKey(msw.Data1, true, true);
break;
}
}));
}
}
这就是大部分的使用方法。事实上,上面直接使用它的部分我都用粗体标出了。后面的例程可能看起来有点吓人,带有奇怪的 BeginInvoke()
调用,以及其中的十六进制混乱。但这没什么大不了的。基本上,当 MIDI 输入设备收到输入时会调用它,但它可能从不同的线程调用,在某些情况下,可能在窗体完全加载之前就调用。所以我们检查以确保窗体的句柄已创建,然后使用 BeginInvoke()
来实质上在应用程序的主 UI 线程上运行后续代码。十六进制混乱只是 MIDI 协议中的 NOTE UP 或 NOTE DOWN 的说法。我们本可以对此使用更高级别的代码并避免所有十六进制,但效率稍低,并且在走这条路时有一个相对不常见的情况是,即使您付出努力去破坏它,它也可能无法正常工作。使用这种低级方式则绝对可靠,只是读起来有点难看。
您可以使用 MapHotKeyDefaultsToOctave()
将热键重新分配为默认值。这将设置从 Q 开始的前两排,或者从 Z 开始的后两排,以适应一个八度。键盘的布局大致类似于钢琴键盘,Q 代表 C
,2 代表 C#
。
限制
还需要更多的测试。随着我发布此库的迭代版本,它将变得更加健壮,尽管控件本身足够简单,应该可以正常使用。
按键处理尚未完全完成。它确实接受热键,但除此之外,没有其他方法可以用键盘进行导航。
历史
- 2020 年 7 月 12 日 - 初次提交
- 2020 年 7 月 12 日 - 更新,添加了热键支持
- 2020 年 7 月 12 日 - 完成了焦点支持