各种 MIDI 控件 - MidiUI 第二部分





5.00/5 (1投票)
继续我们的系列,包括旋钮控件和 MIDI 可视化器。
引言
虽然 WinForms 控件在处理数据录入屏幕等方面非常出色,但它们并不适用于音频应用程序。例如,旋钮通常比滑块/轨道条更合适。此外,也没有钢琴键盘控件,也没有办法可视化 MIDI 演奏数据。该库旨在解决这些问题,同时在适当的时候与我的Midi 库集成。
关于解决方案的说明:这是我所有 MIDI 代码的汇总,包括 Midi 库源代码和所有演示。这里有两个相关的项目:MidiUI 项目和演示它的 MidiDisplay 项目。
旋钮控件
概念化这个混乱的局面
Knob
提供类似滑块/轨道条的控件,但以旋转拨盘的形式呈现用户界面。尽管上面看起来极简,但旋钮的几乎所有外观方面都是可自定义的。之所以采用上面的外观,是为了与其他 Windows 控件融合,它默认情况下就会这样做。
为了使其正常工作,该控件必须处理大小调整、绘图、焦点、按键和鼠标移动(包括滚轮),并在绘制例程中进行一些数学计算。
编写这个混乱的程序
处理焦点
如果您知道如何操作,处理焦点很容易,但我没找到一个好的指南,所以我将逐步介绍这个过程。
我们首先要做的是在控件的构造函数中添加以下行。我建议在任何其他代码之前添加它们。
SetStyle(ControlStyles.Selectable, true);
UpdateStyles();
TabStop = true;
这样可以确保控件成为 Tab 导航的一部分。
接下来,我们需要挂钩 OnMouseDown()
、OnKeyDown()
,为了安全起见,如果您已经重写了 ProcessCmdKey()
,请添加以下行(但请确保在之后调用基类)
Focus();
接下来,我们需要确保当控件获得或失去焦点时进行重绘,所以请在 OnEnter()
和 OnLeave()
中添加此行
Invalidate();
最后,在 OnPaint()
中,我们必须绘制焦点矩形
if(Focused)
ControlPaint.DrawFocusRectangle
(args.Graphics, new Rectangle(0, 0, Math.Min(Width,Height), Math.Min(Width,Height)));
我们根据 Width
或 Height
中较小的值来确定,以使矩形保持方形,但这只是我们旋钮控件特有的行为,它必须保持 1:1 的宽高比。
处理鼠标输入
处理鼠标输入需要重写 4 个事件,以涵盖鼠标按钮按下、鼠标按钮释放、鼠标移动和鼠标滚轮移动,因为旋钮对所有这些都有响应。通常,基本思路是记录鼠标按下事件,检查是否为左键按下,然后将鼠标的当前位置存储在一个成员变量中以备后用。然后,我们使用存储的位置,在鼠标移动过程中计算差异,并根据该差异设置 Value
。最后,当鼠标按钮释放时,我们只需重置鼠标输入标志的状态。处理鼠标滚轮略有不同,因为它已经提供了增量。我们只需将这些增量加到 Value
中或从中减去。在每种情况下,我们都会将 Value
限制在 Minimum
和 Maximum
之间。目前的一个中等限制是我们尚未进行命中测试,以确保鼠标落在圆圈内。这将在将来的版本中修复。
/// <summary>
/// Called when a mouse button is pressed
/// </summary>
/// <param name="args"></param>
protected override void OnMouseDown(MouseEventArgs args)
{
Focus();
if (MouseButtons.Left == (args.Button & MouseButtons.Left)) {
_dragHit = args.Location;
if (!_dragging)
{
_dragging = true;
Focus();
Invalidate();
}
}
base.OnMouseDown(args);
}
/// <summary>
/// Called when a mouse button is released
/// </summary>
/// <param name="args">The event args</param>
protected override void OnMouseUp(MouseEventArgs args)
{
// TODO: Implement Ctrl+Drag
if (_dragging)
{
_dragging = false;
int pos = Value;
pos += _dragHit.Y - args.Location.Y; // delta
int min=Minimum;
int max=Maximum;
if (pos < min) pos = min;
if (pos > max) pos = max;
Value = pos;
}
base.OnMouseUp(args);
}
/// <summary>
/// Called when a mouse button is moved
/// </summary>
/// <param name="args">The event args</param>
protected override void OnMouseMove(MouseEventArgs args)
{
// TODO: Implement Ctrl+Drag
if (_dragging)
{
int opos = Value;
int pos = opos;
pos += _dragHit.Y - args.Location.Y; // delta
int min = Minimum;
int max = Maximum;
if (pos < min) pos = min;
if (pos > max) pos = max;
if (pos != opos)
{
Value = pos;
_dragHit = args.Location;
}
}
base.OnMouseMove(args);
}
/// <summary>
/// Called when the mouse wheel is scrolled
/// </summary>
/// <param name="args">The event args</param>
protected override void OnMouseWheel(MouseEventArgs args)
{
int pos;
int m;
var delta = args.Delta;
if (0 < delta)
{
delta = 1;
pos = Value;
pos += delta;
m = Maximum;
if (pos > m)
pos = m;
Value = pos;
}
else if (0 > delta)
{
delta = -1;
pos = Value;
pos += delta;
m = Minimum;
if (pos < m)
pos = m;
Value = pos;
}
base.OnMouseWheel(args);
}
处理键盘输入
我们处理八个按键用于键盘输入。我们有箭头键用于以小增量更改旋钮位置,尽管目前有一个错误,右箭头键未被处理,我还不清楚原因。向上和向下键运行正常。我们还有 Page Up 和 Page Down 键,它们将旋钮更改为 LargeChange
的值。最后,我们有 Home 和 End 键,它们分别将 Value
设置为 Minimum
或 Maximum
。请注意,我们必须在一个单独的函数中处理箭头键,因为通常窗体会将它们用于其自己的目的,而我们必须覆盖该行为。
/// <summary>
/// Called when a key is pressed
/// </summary>
/// <param name="args">The event args</param>
protected override void OnKeyDown(KeyEventArgs args)
{
Focus();
int pos;
int pg;
if(Keys.PageDown==(args.KeyCode & Keys.PageDown))
{
pg = LargeChange;
pos = Value + pg;
if (pos > Maximum)
pos = Maximum;
Value = pos;
}
if (Keys.PageUp == (args.KeyCode & Keys.PageUp))
{
pg = LargeChange;
pos = Value - pg;
if (pos < Minimum)
pos = Minimum;
Value = pos;
}
if (Keys.Home == (args.KeyCode & Keys.Home))
{
Value = Minimum;
}
if (Keys.End== (args.KeyCode & Keys.End))
{
Value = Maximum;
}
base.OnKeyDown(args);
}
/// <summary>
/// Called when a command key is pressed
/// </summary>
/// <param name="msg">The message</param>
/// <param name="keyData">The command key(s)</param>
/// <returns>True if handled, otherwise false</returns>
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
Focus();
int pos;
var handled = false;
// BUG: Right arrow doesn't seem to be working!
if (Keys.Up == (keyData & Keys.Up) || Keys.Right == (keyData & Keys.Right))
{
pos = Value+1;
if (pos < Maximum)
{
Value = pos;
}
else
Value = Maximum;
handled = true;
}
if (Keys.Down == (keyData & Keys.Down) || Keys.Left == (keyData & Keys.Left))
{
pos = Value-1;
if (pos > Minimum)
{
Value = pos;
}
else
Value = Minimum;
handled = true;
}
if (handled)
return true;
return base.ProcessCmdKey(ref msg, keyData);
}
绘制控件
绘图有点复杂,只是为了在所有可用选项的情况下获得各个元素的实际位置。对于指针帽等内容有一些小的调整。此外,还需要一些三角函数计算,以基于半径和角度计算 X 和 Y。我们还必须将弧度转换为角度。
/// <summary>
/// Called when the control needs to be painted
/// </summary>
/// <param name="args">The event args</param>
protected override void OnPaint(PaintEventArgs args)
{
const double PI = 3.141592653589793238462643d;
base.OnPaint(args);
var g = args.Graphics;
float knobMinAngle = _minimumAngle;
float knobMaxAngle = _maximumAngle;
if (knobMinAngle < 0)
knobMinAngle = 360 + knobMinAngle;
if (knobMaxAngle <= 0)
knobMaxAngle = 360 + knobMaxAngle;
var crr = ClientRectangle;
// adjust the client rect so it doesn't overhang
--crr.Width; --crr.Height;
var size = (float)Math.Min(crr.Width-4, crr.Height-4);
crr.X += 2;
crr.Y += 2;
var radius = size / 2f;
var origin = new PointF(crr.Left +radius, crr.Top + radius);
var brf = _GetCircleRect(origin.X, origin.Y, (radius - (_borderWidth / 2)));
var rrf = _GetCircleRect(origin.X, origin.Y, (radius - (_borderWidth)));
var kr = (knobMaxAngle - knobMinAngle);
int mi=Minimum, mx=Maximum;
double ofs = 0.0;
double vr = mx - mi;
double rr = kr / vr;
if (0 > mi)
ofs = -mi;
double q = ((Value + ofs) * rr) + knobMinAngle;
double angle = (q + 90d);
if (angle > 360.0)
angle -= 360.0;
double angrad = angle * (PI / 180d);
double adj = 1;
if (_pointerEndCap != LineCap.NoAnchor)
adj += (_pointerWidth) / 2d;
var x2 = (float)(origin.X + (radius - adj) * (float)Math.Cos(angrad));
float y2 = (float)(origin.Y + (radius - adj) * (float)Math.Sin(angrad));
using (var backBrush = new SolidBrush(BackColor))
{
using (var bgBrush = new SolidBrush(_knobColor))
{
using (var borderPen = new Pen(_borderColor, _borderWidth))
{
using (var pointerPen = new Pen(_pointerColor, _pointerWidth))
{
pointerPen.StartCap = _pointerStartCap;
pointerPen.EndCap = _pointerEndCap;
g.SmoothingMode = SmoothingMode.AntiAlias;
// erase the background so it antialiases properly
g.FillRectangle(backBrush, (float)crr.Left - 1,
(float)crr.Top - 1, (float)crr.Width + 2, (float)crr.Height + 2);
g.DrawEllipse(borderPen, brf); // draw the border
g.FillEllipse(bgBrush, rrf);
g.DrawLine(pointerPen, origin.X, origin.Y, x2, y2);
}
}
}
}
if(Focused)
ControlPaint.DrawFocusRectangle(g, new Rectangle
(0, 0, Math.Min(Width,Height), Math.Min(Width,Height)));
}
static RectangleF _GetCircleRect(float x, float y, float r)
{
return new RectangleF(x - r, y - r, r * 2, r * 2);
}
MIDI 可视化控件
概念化这个混乱的局面
MidiVisualizer
允许您将 MIDI 序列作为一系列音符显示在“钢琴卷帘”上。它支持一个可选的光标,可以用来跟踪当前歌曲位置。它允许您自定义控件中所有元素的颜色。每个 MIDI 通道都有不同的颜色。
编写这个混乱的程序
绘制控件
除了外观设置之外,控件的核心是绘图。在这里,我们绘制背景并根据控件的宽度、高度和整体音符差异计算缩放,然后获取当前 MIDI 序列的音符映射。接下来,我们进行绘制,但仅在剪裁矩形内绘制以加快绘图速度,如果音符足够大,则使用每个音符的 alpha 颜色通道添加 3D 效果。最后,如果启用了光标,我们则绘制光标。我们不缓存音符映射的原因是 MIDI 序列可能随时更改。
/// <summary>
/// Paints the control
/// </summary>
/// <param name="args">The event arguments</param>
protected override void OnPaint(PaintEventArgs args)
{
base.OnPaint(args);
var g = args.Graphics;
using (var brush = new SolidBrush(BackColor))
{
g.FillRectangle(brush, args.ClipRectangle);
}
if (null == _sequence)
return;
var len = 0;
var minNote = 127;
var maxNote = 0;
foreach (var ev in _sequence.Events)
{
// found note on
if(0x90==(ev.Message.Status & 0xF0))
{
var mw = ev.Message as MidiMessageWord;
// update minimum and maximum notes
if (minNote > mw.Data1)
minNote = mw.Data1;
if (maxNote < mw.Data1)
maxNote = mw.Data1;
}
// update the length
len += ev.Position;
}
if (0 == len || minNote > maxNote)
return;
// with what we just gathered now we have the scaling:
var pptx = Width / (double)len;
var ppty = Height / ((maxNote - minNote) + 1);
var crect = args.ClipRectangle;
// get a note map for easy drawing
var noteMap = _sequence.ToNoteMap();
for(var i = 0;i<noteMap.Count;++i)
{
var note = noteMap[i];
var x = unchecked((int)Math.Round(note.Position * pptx)) + 1;
if (x > crect.X + crect.Width)
break; // we're done because there's nothing left within the visible area
var y = Height - (note.NoteId - minNote + 1) * ppty - 1;
var w = unchecked((int)Math.Round(note.Length * pptx));
var h = ppty;
if (crect.IntersectsWith(new Rectangle(x, y, w, h)))
{
// choose the color based on the note's channel
using (var brush = new SolidBrush(_channelColors[note.Channel]))
{
// draw our rect based on scaling and note pos and len
g.FillRectangle(
brush,
x,
y,
w,
h);
// 3d effect, but it slows down rendering a lot.
// should be okay since we're only rendering
// a small window at once usually
if (2 < ppty && 2 < w)
{
using(var pen = new Pen(Color.FromArgb(127,Color.White)))
{
g.DrawLine(pen, x, y, w + x, y);
g.DrawLine(pen, x, y+1, x, y+h-1);
}
using (var pen = new Pen(Color.FromArgb(127, Color.Black)))
{
g.DrawLine(pen, x, y+h-1, w + x, y+h-1);
g.DrawLine(pen, x+w, y + 1, x+w, y + h);
}
}
}
}
var xt = unchecked((int)Math.Round(_cursorPosition * pptx));
var currect = new Rectangle(xt, 0, unchecked((int)Math.Max(pptx, 1)), Height);
if (_showCursor && crect.IntersectsWith(currect) &&
-1<_cursorPosition && _cursorPosition<len)
using(var curBrush = new SolidBrush(_cursorColor))
g.FillRectangle(curBrush, currect);
}
}
钢琴盒控件
PianoBox
控件的详细介绍请在此处查看。
历史
- 2020 年 7 月 14 日 - 首次提交