In this installment, I've added a knob control and a MIDI visualizer control to MidiUI.
Introduction
While WinForms controls are great for doing things like data entry screens, they aren't really designed for audio applications. For instance, a knob is often more appropriate than a slider/trackbar. There is also no piano keyboard control nor any way to visualize MIDI performance data. This library aims to solve that, while being integrated where appropriate with my Midi library.
A Note About the Solution: This is a rollup of all of my MIDI code, including the Midi library source code and every demo. The two relevant projects here are MidiUI project and the MidiDisplay project that demonstrates it.
The Knob Control
Conceptualizing this Mess
Knob
provides slider/trackbar-like control but presents the user interface in rotary dial form. Despite the minimalist appearance above, nearly every aspect of the knob's appearance is customizable. The reason for the look above is to blend in with other Windows controls, which it does by default.
In order to make it work, the control has to handle resize, drawing, focus, keystrokes and mouse movement (including the wheel) plus do some math in the painting routine.
Coding this Mess
Handling Focus
Handling focus is easy if you know how, but there's no good guide on how to do it that I've found, so I'll step through the process.
The first thing we have to do is add the following lines to the control's constructor. I recommend adding these above any other code:
SetStyle(ControlStyles.Selectable, true);
UpdateStyles();
TabStop = true;
That will ensure the control becomes part of the tab navigation.
Next, we have to hook OnMouseDown()
, OnKeyDown()
, and to be safe ProcessCmdKey()
if you're already overriding it, adding the following line (but make sure you call the base afterward):
Focus();
Next, we have to make sure it repaints when it enters or leaves focus, so add this line to OnEnter()
and OnLeave()
:
Invalidate();
Finally, in OnPaint()
, we must draw the focus rectangle:
if(Focused)
ControlPaint.DrawFocusRectangle
(args.Graphics, new Rectangle(0, 0, Math.Min(Width,Height), Math.Min(Width,Height)));
We go by the value of Width
or Height
, whichever is lowest in order to keep the rectangle square, but that behavior is specific to our knob control, which must maintain a 1:1 aspect ratio.
Handling Mouse Input
Handling mouse input involves overriding 4 events to cover mouse button down, mouse button up, mouse movement, and mouse wheel movement, since the knob responds to all of them. Generally, the idea is to record the mouse down event, check for left button down, and then store the current mouse location of the mouse in a member variable for later. We then use that stored location, computing the difference during mouse movement, and setting the Value
based on that. Finally, when the mouse button is released, we simply reset the state of the mouse input flag. Handling the mouse wheel is a little different since it already gives us deltas. We simply add or subtract those from Value
. In each case, we're clamping Value
to Minimum
and Maximum
. One moderate limitation here is we don't currently do hit testing to make sure the mouse lands within the circle. This will be fixed in a future release:
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);
}
protected override void OnMouseUp(MouseEventArgs args)
{
if (_dragging)
{
_dragging = false;
int pos = Value;
pos += _dragHit.Y - args.Location.Y;
int min=Minimum;
int max=Maximum;
if (pos < min) pos = min;
if (pos > max) pos = max;
Value = pos;
}
base.OnMouseUp(args);
}
protected override void OnMouseMove(MouseEventArgs args)
{
if (_dragging)
{
int opos = Value;
int pos = opos;
pos += _dragHit.Y - args.Location.Y;
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);
}
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);
}
Handling Keyboard Input
We handle eight keys for keyboard input. We have the arrow keys to change the knob position by small increments, although right now there's a bug where the right arrow key isn't getting processed and I'm not sure why yet. Up and down work fine. We also have the page up and page down keys, which change the knob by the value of LargeChange
. Finally, we have home and end keys which set Value
to Minimum
or Maximum
respectively. Note that we must handle the arrow keys in a separate function since normally the form uses them for its own purposes and we have to override that behavior:
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);
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
Focus();
int pos;
var handled = false;
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);
}
Painting the Control
Painting is a bit involved just to get the positions of the individual elements with all of the options available. There are little adjustments for things like pointer caps. Furthermore, there's a little bit of trig involved in order to calculate X and Y based on a radius and an angle. Also, we must convert from radians to degrees.
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;
--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;
g.FillRectangle(backBrush, (float)crr.Left - 1,
(float)crr.Top - 1, (float)crr.Width + 2, (float)crr.Height + 2);
g.DrawEllipse(borderPen, brf);
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);
}
The MIDI Visualizer Control
Conceptualizing this Mess
MidiVisualizer
allows you to view a MIDI sequence as a series of notes on a "piano roll." It supports an optional cursor that can be used to track the current song position. It allows you to customize the colors of everything in the control. Each MIDI channel is a different color.
Coding this Mess
Painting the Control
Other than some appearance settings, the meat of the control is the painting. Here, we paint the background and compute the scaling based on the width, height and overall note difference of the control, and then we take a note map of the current MIDI sequence. Next, we paint but only within the clip rectangle to speed up painting, adding a 3d effect using the alpha color channel on each note, if it's large enough. Finally we draw the cursor, if it's enabled. The reason we don't cache the note map is because the MIDI sequence may change at any time:
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)
{
if(0x90==(ev.Message.Status & 0xF0))
{
var mw = ev.Message as MidiMessageWord;
if (minNote > mw.Data1)
minNote = mw.Data1;
if (maxNote < mw.Data1)
maxNote = mw.Data1;
}
len += ev.Position;
}
if (0 == len || minNote > maxNote)
return;
var pptx = Width / (double)len;
var ppty = Height / ((maxNote - minNote) + 1);
var crect = args.ClipRectangle;
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;
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)))
{
using (var brush = new SolidBrush(_channelColors[note.Channel]))
{
g.FillRectangle(
brush,
x,
y,
w,
h);
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);
}
}
The Piano Box Control
A thorough treatment of the PianoBox
control is provided here.
History
- 14th July, 2020 - Initial submission