Making Audio Plugins Part 10: Virtual Keyboard

REAPER’s virtual keyboard is a little laborious to set up, and your customers may not always have a host with such functionality. Let’s add a little on-screen keyboard to our plugin’s GUI.

The GUI Element

In WDL-OL, GUI elements are called controls. WDL-OL comes with an IKeyboardControl which has all the functionality we need. It uses a background graphic and two sprites: One is a pressed black key, the other contains several pressed white keys. The reason for this is that all black keys have the same shape, but the white keys have different shapes. Initially, only the background will be visible. When a key is played, the pressed key graphic will be overlaid on top at the appropriate position.
If you are interested in creating a beautiful piano graphic yourself, check out this tutorial. Anyway, here are the three files that come with WDL-OL:

Background:

Pressed black key:

Pressed white keys:

Download all three, put them in your project’s /resources/img/ folder. Then drag them into Xcode to add them to the project. As usual with graphics, we’ll first add the filename to resource.h. While you’re there, remove the knob.png and background.png references and remove the two files from your project.

// Unique IDs for each image resource.
#define BG_ID         101
#define WHITE_KEY_ID  102
#define BLACK_KEY_ID  103

// Image resource locations for this plug.
#define BG_FN         "resources/img/bg.png"
#define WHITE_KEY_FN  "resources/img/whitekey.png"
#define BLACK_KEY_FN  "resources/img/blackkey.png"

Also change the GUI size:

// GUI default dimensions
#define GUI_WIDTH 434
#define GUI_HEIGHT 66

To have the png files included in windows builds, edit Synthesis.rc and modify the beginning to this:

#include "resource.h"

BG_ID       PNG BG_FN
WHITE_KEY_ID       PNG WHITE_KEY_FN
BLACK_KEY_ID       PNG BLACK_KEY_FN

Now add some public members to the Synthesis class (in Synthesis.h):

public:
    // ...

    // Needed for the GUI keyboard:
    // Should return non-zero if one or more keys are playing.
    inline int GetNumKeys() const { return mMIDIReceiver.getNumKeys(); };
    // Should return true if the specified key is playing.
    inline bool GetKeyStatus(int key) const { return mMIDIReceiver.getKeyStatus(key); };
    static const int virtualKeyboardMinimumNoteNumber = 48;
    int lastVirtualKeyboardNoteNumber;

Initialize lastVirtualKeyboardNoteNumber in the initializer list (in Synthesis.cpp):

Synthesis::Synthesis(IPlugInstanceInfo instanceInfo)
    :   IPLUG_CTOR(kNumParams, kNumPrograms, instanceInfo),
    lastVirtualKeyboardNoteNumber(virtualKeyboardMinimumNoteNumber - 1) {
    // ...
}

When MIDI notes are played from the host, they should be visible as pressed keys on our virtual keyboard. The virtual keyboard will call getNumKeys and getKeyStatus to find out which keys are currently being pressed. We have already implemented these functions on the MIDIReceiver, so we’re just passing it on.
The private section needs two additions:

IControl* mVirtualKeyboard;
void processVirtualKeyboard();

The IControl class is the base class of all the GUI controls. We can’t declare an instance of IKeyboardControl here because it isn’t known in header files. For that reason, we have to use a pointer. IKeyboardControl.h has some comments saying that you “should include this header file after your plug-in class has already been declared, so it is propbably best to include it in your plug-in’s main .cpp file”.
To make this a little more clear, let’s go into Synthesis.cpp. Add #include "IKeyboardControl.h" right before you #include resource.h. Now modify the constructor as follows:

Synthesis::Synthesis(IPlugInstanceInfo instanceInfo)
    :   IPLUG_CTOR(kNumParams, kNumPrograms, instanceInfo),
    lastVirtualKeyboardNoteNumber(virtualKeyboardMinimumNoteNumber - 1) {
    TRACE;

    IGraphics* pGraphics = MakeGraphics(this, kWidth, kHeight);
    pGraphics->AttachBackground(BG_ID, BG_FN);

    IBitmap whiteKeyImage = pGraphics->LoadIBitmap(WHITE_KEY_ID, WHITE_KEY_FN, 6);
    IBitmap blackKeyImage = pGraphics->LoadIBitmap(BLACK_KEY_ID, BLACK_KEY_FN);

    //                            C#     D#          F#      G#      A#
    int keyCoordinates[12] = { 0, 7, 12, 20, 24, 36, 43, 48, 56, 60, 69, 72 };
    mVirtualKeyboard = new IKeyboardControl(this, kKeybX, kKeybY, virtualKeyboardMinimumNoteNumber, /* octaves: */ 5, &whiteKeyImage, &blackKeyImage, keyCoordinates);

    pGraphics->AttachControl(mVirtualKeyboard);

    AttachGraphics(pGraphics);

    CreatePresets();
}

The interesting part begins after we have attached the background graphic. First we load the pressed black/white keys as IBitmaps. The second argument (6) to LoadIBitmap tells the graphics system that whitekeys.png contains six frames:

By default pRegularKeys should contain 6 bitmaps (C/F, D, E/B, G, A, high C), while pSharpKey should only contain 1 bitmap (for all flat/sharp keys).
IKeyboardControl.h

The keyCoordinates array tells the system how far each key is offset from the left. Note that you only have to do this for one octave; IKeyboardControl will infer the coordinates for all other octaves.
On the next line, we assign a new IKeyboardControl to mVirtualKeyboard. We pass a lot of information:

  • A pointer to our plugin instance. This is an example of the delegate pattern: The virtual keyboard will call GetNumKeys and GetKeyStatus on this.
  • The keyboard’s X and Y coordinates on the GUI.
  • The lowest note number. When you click the leftmost key, this note will be played.
  • The number of octaves
  • The addresses of our two pressed key images
  • The X coordinate of each key in one octave

Interestingly, the virtual keyboard knows nothing about bg.png. It doesn’t need it! This is good because the keyboard may be part of one big background bitmap and it would be annoying to cut out the keyboard part just to pass it to the IKeyboardControl constructor. It just acts when keys are pressed.

If you have some C++ experience, writing new in the constructor may (and should) urge you to put delete mVirtualKeyboard in the destructor. If you do that and unload your plugin (i.e. remove it from a track), you’ll get a runtime exception. The reason is that when you call:

pGraphics->AttachControl(mVirtualKeyboard);

You’re passing ownership to the graphics system. This means that the memory management is no longer your responsibility, and using delete will try to deallocate memory that has already been deallocated.
Now empty the CreatePresets function:

void Synthesis::CreatePresets() {
}

And add kKeybX and kKeybY to ELayout:

enum ELayout
{
    kWidth = GUI_WIDTH,
    kHeight = GUI_HEIGHT,
    kKeybX = 1,
    kKeybY = 0
};

For performance reasons, the IKeyboardControl doesn’t redraw itself just by itself. A common pattern in graphics programming is to mark a GUI component as dirty, which means that it will be redrawn on the next paint cycle. If you look in IKeyboardControl.h, particularly OnMouseDown and OnMouseUp, you’ll see that mKey is set to some value and SetDirty is called (as opposed to Draw). SetDirty is an IControl member function (found in IControl.cpp) that sets the control’s mDirty member to true. On every paint cycle, the graphics system repaints all controls whose mDirty is true. I’m going into such detail here because this is an important aspect of how the graphics system works.

Reacting to External MIDI

Until now, the keyboard marks itself dirty only when it’s clicked. It gets the status of pressed keys from the mMIDIReceiver, but it has to be informed when external MIDI is received. mVirtualKeyboard and mMIDIReceiver know nothing about each other, so we’ll modify ProcessMidiMsg (in Synthesis.cpp):

void Synthesis::ProcessMidiMsg(IMidiMsg* pMsg) {
    mMIDIReceiver.onMessageReceived(pMsg);
    mVirtualKeyboard->SetDirty();
}

First, mMIDIReceiver can update its mLast... members according to the received MIDI data. Then, mVirtualKeyboard is marked as dirty. So on the next paint cycle, the renderer will call Draw on mVirtualKeyboard, which will call GetNumKeys and GetKeyStatus. This may sound a little indirect at first, but it’s a clean design that keeps components separate and avoids redundant work.
Our virtual keyboard now reacts to external MIDI input and shows the appropriate keys as being pressed.

Reacting to Virtual Key Presses

The last part that’s missing is the opposite direction: Reacting to clicks on the virtual keyboard, generating MIDI messages and passing them to mMIDIReceiver.
Add the following call to ProcessDoubleReplacing, right before the for loop:

processVirtualKeyboard();

And implement the function:

void Synthesis::processVirtualKeyboard() {
    IKeyboardControl* virtualKeyboard = (IKeyboardControl*) mVirtualKeyboard;
    int virtualKeyboardNoteNumber = virtualKeyboard->GetKey() + virtualKeyboardMinimumNoteNumber;

    if(lastVirtualKeyboardNoteNumber >= virtualKeyboardMinimumNoteNumber && virtualKeyboardNoteNumber != lastVirtualKeyboardNoteNumber) {
        // The note number has changed from a valid key to something else (valid key or nothing). Release the valid key:
        IMidiMsg midiMessage;
        midiMessage.MakeNoteOffMsg(lastVirtualKeyboardNoteNumber, 0);
        mMIDIReceiver.onMessageReceived(&midiMessage);
    }

    if (virtualKeyboardNoteNumber >= virtualKeyboardMinimumNoteNumber && virtualKeyboardNoteNumber != lastVirtualKeyboardNoteNumber) {
        // A valid key is pressed that wasn't pressed the previous call. Send a "note on" message to the MIDI receiver:
        IMidiMsg midiMessage;
        midiMessage.MakeNoteOnMsg(virtualKeyboardNoteNumber, virtualKeyboard->GetVelocity(), 0);
        mMIDIReceiver.onMessageReceived(&midiMessage);
    }

    lastVirtualKeyboardNoteNumber = virtualKeyboardNoteNumber;
}

After a cast, we get the pressed key’s MIDI note number using GetKey. IKeyboardControl doesn’t support multi-touch, so only one key can be clicked at once. The first if statement releases a key that is no longer clicked (if any). Since this function is called every mBlockSize samples, the second if ensures that clicking a key will only generate one note on message for a given click, and not one every mBlockSize samples. We’re remembering the lastVirtualKeyboardNoteNumber to avoid this kind of “re-triggering” on every call.

Showtime!

We’re ready to run our plugin again! You should be able to play notes using the plugin’s virtual keyboard. Using REAPER’s virtual keyboard (or any other MIDI input) should make the plugin’s GUI show the appropriate keys (plural) as being pressed. You will only hear a tone for the last-pressed key, though. We will address polyphony in a later post.

We can play our favourite Beethoven with the sound of classic analogue waveforms! But the sound is a little “static” and you can hear click sounds when you press and release a key (especially using the sine waveform). So the next thing to do is to add envelopes. You can download the current source files here.

If you found this useful, please feel free to
!
comments powered by | Disqus