BTDSys PeerCtrl Library Tutorial

This tutorial will show you how to make a basic peer-controlling machine. The full code is included (example.cpp); I'll just explain a few of the more important bits. I'm assuming you have a working knowledge of C++ and programming Buzz machines - if not then work through some C++ tutorials or textbooks, and Cyanphase's tutorial for Buzz effects, then come back. I'm also assuming you've at least seen some of my peer controllers, so you have some idea roughly how they work and what they do.

The example machine simply allows the user to control a number of parameters, with inertia. Like a stripped-down version of the BTDSys PeerCtrl machine. It might not seem like much, but it'll form the basis for any LFOs, algorithmic controllers, piano rolls or whatnot you might write.

Before you start, you need to make sure you link with peerctrl.lib (add it to the same line in the project settings where you add mdk.lib) and #include "peerctrl.h".

The heart of peer controllers is the CPeerCtrl object (or objects). You need one of these for every parameter on other machines you want to control. They need to be dynamic (since they have to be initialised in Init()) so we'll just define an array of pointers in the mi class:

    CPeerCtrl *ctrl[128];
Now we can initialise them, and read data from the Buzz song file if appropriate, in mi::MDKInit()
void mi::MDKInit (CMachineDataInput * const pi)
{
    Initialising = true;

    ThisMac = pCB->GetThisMachine();

    for (int t=0; t<128; t++)
    {
        ctrl[t] = new CPeerCtrl(ThisMac, this);
        if (pi) ctrl[t]->ReadFileData(pi);
    }
}
The Initialising flag is used as a signal to other functions, that they shouldn't do anything yet (since the machine has only just loaded, and perhaps the machine it's supposed to control hasn't yet). Trust me, it's necessary if you want to avoid crashes on song loads.

Now to save data into the song file:

void mi::MDKSave(CMachineDataOutput * const po)
{
    for (int t=0; t<128; t++)
        ctrl[t]->WriteFileData(po);
}
And also we want to erase those CPeerCtrl objects when the machine is destroyed, to avoid memory leaks:
mi::~mi()
{
    for (int t=0; t<128; t++)
        if (ctrl[t]) delete ctrl[t];
}

To calculate the values to send to parameters, we can access and use information about the selected parameter:

inline int GetParamValue(CPeerCtrl *ct, float val)
{
    const CMachineParameter *mpar = ct->GetParamInfo();
    return mpar->MinValue + (int)(val * (mpar->MaxValue -mpar->MinValue));
}

This code converts a float value between 0.0f and 1.0f into an int value in the range of the parameter.

Now onto the Tick() function. The main part is as follows:

    if (!Initialising)
Remember, we need to know the song is fully loaded before we start trying to control other machines (Buzz tends to start calling machine functions like Tick() before the song is fully loaded).
    {
        for (int t=0; t<numTracks; t++)
        {
            ctrl[t]->CheckMachine();
It is important to call CheckMachine() every tick. What CheckMachine() does is check if the machine we're controlling has been renamed, deleted etc, and adjust assignment settings accordingly.
            if (tval[t].Value != paraValue.NoValue)
            {
                tracks[t].ValTarget = tval[t].Value / 65534.0f;
                tracks[t].ValStep = (tracks[t].ValTarget -tracks[t].ValCurrent) / InertiaLength;
                tracks[t].ValCountDown = InertiaLength;
            }
This is just your standard inertia code.
            if (ctrl[t]->GotParam() && tracks[t].ValCountDown > 0)
                ctrl[t]->ControlChange_NextTick(0, GetParamValue(ctrl[t], tracks[t].ValCurrent));
You must check GotParam() before trying to change parameter values or get parameter info. If you don't, your machine will crash.

The PeerCtrl library has two methods of control change: NextTick (which is a standard Buzz feature, and updates sliders on screen, but only changes controls once a tick) and Immediate (which is a hack, doesn't move sliders, but can change controls much faster than once a tick). Typically a machine should use both, to combine accurate timing and visual feedback.

You could replace the 0 in the ControlChange_NextTick() call to choose a target track number (only applies if you're controlling a track parameter). The second argument is the value to use.

That's the end of the Tick() procedure. Now for the Work() function.

The Work() function doesn't generate any audio, but it performs control changes between ticks. Also Buzz won't call Work() until the song is fully loaded, so we can clear the Initialising flag.

bool mi::MDKWork(float *psamples, int numsamples, const int mode)
{
    for (int t=0; t<numTracks; t++)
    {
        if (tracks[t].ValCountDown > 0 && ctrl[t]->GotParam())
        {
            tracks[t].ValCurrent += tracks[t].ValStep * numsamples;
            tracks[t].ValCountDown -= numsamples;
Here we check that GotParam() function again, then do some standard inertia stuff.
            if (tracks[t].ValCountDown <= 0)
            {
                tracks[t].ValCurrent = tracks[t].ValTarget;
                ctrl[t]->ControlChange_NextTick(0, GetParamValue(ctrl[t], tracks[t].ValCurrent));
            }
Here's what happens if the inertia slide has finished. Note we do a non-hack control change here, to make sure the slider on the target machine reflects the final value.

Now we can send a control change:

            ctrl[t]->ControlChange_Immediate(0, GetParamValue(ctrl[t], tracks[t].ValCurrent));
This one won't wait until the next tick to take effect. Doing this is essential for smooth inertia, and also for accurate timing in other machines.

Note that there's no point calling ControlChange_Immediate() more often than once per Work() function, since the control changes won't take effect any faster than that. Also calling it too fast can cause excessive CPU usage, since it forces execution of the target machine's Tick() function which tends not to be too efficient. A good idea would be to add an attribute to slow the control changes down if necessary.

Now all that's needed is the GUI code for assigning parameters.

This code is called when the dialog box ends:

        char MacName[128];
        char ParName[255];

        if (GetDlgItemText(hDlg, IDC_MACHINECOMBO, MacName, 128) != 0)
        {
            int s = SendMessage(GetDlgItem(hDlg,IDC_PARALIST), 
                LB_GETCURSEL, 0, 0);
            if (s != LB_ERR)
            {
                SendMessage(GetDlgItem(hDlg,IDC_PARALIST),
                    LB_GETTEXT, s, long(&ParName));
This code just gets the names of the machine and parameter selected. Now we can just assign it as follows:
                g_ctrl->AssignParameter(MacName, ParName);
If no machine or no parameter was selected, we just dump the current assignment:
            else
                g_ctrl->UnassignParameter();
        }
        else
            g_ctrl->UnassignParameter();
OK, now for the procedure that handles all the Windows messages our dialog is going to receive. First the code done when the dialog is first opened:
    case WM_INITDIALOG:
        char txt[128];
        sprintf(txt, "%s track %i, current assign: %s",
            g_mi->pCB->GetMachineName(g_mi->ThisMac),
            g_tracknum,
            g_ctrl->GetAssignmentString());

        SetDlgItemText(hDlg, IDC_THISMACINFO, txt);
This just puts info on the current assign into a static text box at the top of the dialog. GetAssignmentString() returns either "No Assign", or a string in the format "Bass 3->Cutoff".

The library contains standard functions to get machine and parameter names into combo and list boxes. So let's use one of them:

        g_ctrl->GetMachineNamesToCombo(hDlg, 
            IDC_MACHINECOMBO, g_mi->pCB->GetMachineName(g_mi->ThisMac));
Now if there's already an assignment, we want to have it initially selected in the dialog. The library defines procedures for that, too:
        if (g_ctrl->GotParam())
        {
            //select machine and parameter
            if (g_ctrl->SelectMachineInCombo(hDlg, IDC_MACHINECOMBO))
            {
                g_ctrl->GetParamNamesToList(
                    g_ctrl->GetMachine(),
                    hDlg, IDC_PARALIST, ALLOW_ALL_TYPES | ALLOW_ALL_GROUPS);

                g_ctrl->SelectParameterInList(hDlg, IDC_PARALIST);
            }
        }
In the above code, we select the current machine, then get its parameters into the list box, then select the parameter in the list box.

Now we just need to react to the user selecting a machine from the combo box:

        case IDC_MACHINECOMBO:
            if (HIWORD(wParam) == CBN_SELCHANGE) //selection is changed
            {
                char MacName[128];
                
                if (GetDlgItemText(hDlg, IDC_MACHINECOMBO, MacName, 128) != 0)
                {   //ie if a machine is selected
                    //Populate parameter list
                    g_ctrl->GetParamNamesToList(
                        g_mi->pCB->GetMachine(MacName),
                        hDlg, IDC_PARALIST, ALLOW_ALL_TYPES | ALLOW_ALL_GROUPS);
                }
                else
                    SendMessage(GetDlgItem(hDlg, IDC_PARALIST), LB_RESETCONTENT, 0,0);
            }
What this does is get a list of parameters for the selected machine, or clear the list if no machine was selected. Note that it's possible to restrict what parameters are shown (using the ALLOW_ values). Here we allow everything, but you might limit it to only notes in tracks, for example. See peerctrl.h for possible values.

Now we need to get a list of current assigns into the machine's submenu when the user right-clicks our machine:

void miex::GetSubMenu(int const i, CMachineDataOutput *pout)
{
    char txt[128];
    for (int t=0; t<pmi->numTracks; t++)
    {
        sprintf(txt, "%i: %s", t, pmi->ctrl[t]->GetAssignmentString());
        pout->Write(txt);
    }
}   
Fairly self-explanatory, it uses the GetAssignmentString() procedure again. Now we just need to respond to clicks on these items:
void mi::Command(const int i)
{
    if (i>=256 && i<512)
    {
        g_mi = this;
        g_tracknum = i-256;
        g_ctrl = ctrl[i-256];

        DialogBox(dllInstance, MAKEINTRESOURCE (IDD_ASSIGN), GetForegroundWindow(), (DLGPROC) &AssignDialog);
    }
}
We need global copies of the mi and CPeerCtrl objects we are using, for the dialog box procedures.

OK, aside from some standard Buzz machine stuff you can pick up from other tutorials, that's it.


Your idea for a peer controller might not be possible with the library as-is. For example, you might want to read parameter values as well as write them, or efficiently control multiple parameters on the same machine. If that's the case, the source code is included and may be tweaked to fit your needs (please don't distribute modified versions without asking first, however).

This tutorial ©2003 Ed Powley. Email me with comments, suggestions, problems or corrections.

C++ code formatted into HTML using CPPDoc