65.9K
CodeProject 正在变化。 阅读更多。
Home

各种 MIDI 控件 - MidiUI 第二部分

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2020 年 7 月 14 日

MIT

4分钟阅读

viewsIcon

8317

downloadIcon

179

继续我们的系列,包括旋钮控件和 MIDI 可视化器。

引言

虽然 WinForms 控件在处理数据录入屏幕等方面非常出色,但它们并不适用于音频应用程序。例如,旋钮通常比滑块/轨道条更合适。此外,也没有钢琴键盘控件,也没有办法可视化 MIDI 演奏数据。该库旨在解决这些问题,同时在适当的时候与我的Midi 库集成。

关于解决方案的说明:这是我所有 MIDI 代码的汇总,包括 Midi 库源代码和所有演示。这里有两个相关的项目:MidiUI 项目和演示它的 MidiDisplay 项目。

旋钮控件

Knob Control

概念化这个混乱的局面

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)));

我们根据 WidthHeight 中较小的值来确定,以使矩形保持方形,但这只是我们旋钮控件特有的行为,它必须保持 1:1 的宽高比。

处理鼠标输入

处理鼠标输入需要重写 4 个事件,以涵盖鼠标按钮按下、鼠标按钮释放、鼠标移动和鼠标滚轮移动,因为旋钮对所有这些都有响应。通常,基本思路是记录鼠标按下事件,检查是否为左键按下,然后将鼠标的当前位置存储在一个成员变量中以备后用。然后,我们使用存储的位置,在鼠标移动过程中计算差异,并根据该差异设置 Value。最后,当鼠标按钮释放时,我们只需重置鼠标输入标志的状态。处理鼠标滚轮略有不同,因为它已经提供了增量。我们只需将这些增量加到 Value 中或从中减去。在每种情况下,我们都会将 Value 限制在 MinimumMaximum 之间。目前的一个中等限制是我们尚未进行命中测试,以确保鼠标落在圆圈内。这将在将来的版本中修复。

/// <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 设置为 MinimumMaximum。请注意,我们必须在一个单独的函数中处理箭头键,因为通常窗体会将它们用于其自己的目的,而我们必须覆盖该行为。

/// <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 可视化控件

MIDI Visualizer

概念化这个混乱的局面

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 日 - 首次提交
© . All rights reserved.