In this tutorial, we will see how to handle events with the VoidX framework. Events are used to create user interactions and exchange synchronization messages between one or more classes within an application. A practical example is reading the encoder of Mini Stomp-X. Suppose we want to change a parameter by rotating the encoder. Clearly, we could read the state of the GPIOs, evaluate it, and act accordingly, but it would be very burdensome for the programmer and, above all, we would have to rewrite the same code for each parameter to be changed, making the management logic very complicated.
EventDispatcher and Listeners
Before diving into coding, let’s examine the architecture responsible for event management. We define an event as a specific action, assigning it a unique numerical identifier. Consequently, every event we define will have a distinct code. Here an example of events from Event.hpp:
#define EVENT_ENC_UP 10
#define EVENT_ENC_DOWN 11
#define EVENT_ENC_PRESSED 12
To simplify event management, an event manager (EventDispatcher) has been included within the VoidX framework, which is automatically initialized at startup. As you can see from the following image, the EventDispatcher’s task is to deliver incoming events to receivers (Listeners).

The Listener class, defined within Event.hpp, facilitates automatic event management. Specifically, upon construction, a Listener object registers itself with the EventDispatcher’s internal stack. Conversely, upon object destruction, it automatically unregisters. Consequently, developers can implement event handling by inheriting from this class, which provides the following three virtual methods:
draw
: triggered by the EventDispatcher when thedraw
event occurs;onEvent
: invoked when a specific event is dispatched;onBroadcast
: activated when a broadcast event is dispatched. Not mandatory to implement in derived class.
The BSP as Event Source
Now that we understand the VoidX event management mechanism, we can move on to application coding. Since events and ‘business logic’ can now be separated, it’s beneficial to create a file where we gather all possible user commands (button presses, encoder rotations, and so on). We’ve chosen to structure this part with a specific class: the BSP (Board Support Package). The BSP for Mini Stomp-X is included in the VoidX-Public-App repository (in the MiniStompX.hpp file). The following code snippet shows an excerpt from the BSP that demonstrates how events are generated.
void eventLoop(){
int evt;
while((evt = IOArray::has_data())){
if(evt == IO_ARRAY_DATA_CHANGED) EventDispatcher::broadcast(EVENT_INPUT);
//encoder rotate
ENCODER(IOArray::get_bit(1), IOArray::get_bit(3), EventDispatcher::addEvent(EVENT_ENC_UP), EventDispatcher::addEvent(EVENT_ENC_DOWN), 1, ENCODER_IO_ARRAY_DEBOUNCE);
//encoder push
PRESS(IOArray::get_bit(0), EventDispatcher::addEvent(EVENT_ENC_PRESSED), EventDispatcher::addEvent(EVENT_ENC_RELEASED), EventDispatcher::addEvent(EVENT_ENC_LONG_PRESSED), EventDispatcher::addEvent(EVENT_ENC_LONG_RELEASED));
//footswitch push
PRESS(gpio_get_level(GPIO_NUM_10), EventDispatcher::addEvent(EVENT_FSW_PRESSED), EventDispatcher::addEvent(EVENT_FSW_RELEASED), EventDispatcher::addEvent(EVENT_FSW_LONG_PRESSED), EventDispatcher::addEvent(EVENT_FSW_LONG_RELEASED));
}
}
As seen in the code, the EventDispatcher::addEvent(int event)
function is called with the respective event code. The source, in this case, is the encoder rotation for the EVENT_ENC_UP
and EVENT_ENC_DOWN
events, and a button press for the press, release, and long press events. The PRESS
and ENCODER
functions are simply convenient macros for handling debounce and encoder quadrature. They can be found in Event.hpp.
Add Events to BasicDelay
Having understood the event management system, we will now implement a personalized event response within the BasicDelay
class, which is part of the VoidX-Public-App repository. We begin by creating a new application, BasicDelayEvents
, and, drawing from the previous tutorial, enable event handling by declaring BasicDelayEvents
as a Listener in BasicDelayEvents.hpp.
#pragma once
#include "dsp/AudioBlock.hpp"
#include "NodeFloat.hpp"
//include Event.hpp to handle events
#include "Event.hpp"
class BasicDelayEvents: public AudioBlock, public Listener {
private:
//Parameters
NodeFloat * mix;
NodeFloat * feedback;
NodeFloat * timeNode;
//Low-level DSP parameters.
float * delayLine; //Delay circular buffer
int delayLineWritePointer; //Write index for the delay line.
float mixDsp; //Blend level
float feedbackDsp; //Feedback amount
int delaySamplesDsp; //Delay length in samples
public:
BasicDelayEvents(Node * parent, Node *root); //Class constructor
bool compile(); //Inherited from AudioBlock
static bool exec(BasicDelayEvents * ptr, float data[SAMPLING_CHANNELS][SAMPLING_FRAME]); //DSP function is a static function
//Listener methods
bool onEvent(int event);
bool draw();
};
Once the class is declared as a Listener, it can respond to events. Let’s see how the VoidX event implementation looks. By opening the BasicDelayEvents.cpp file, you’ll find the two required methods at the end.
#include "BasicDelayEvents.hpp"
#include "../bsp/MiniStompX.hpp"
#include <malloc.h>
#include "DrawerHome.hpp"
#include "DrawerNode.hpp"
/* DELAY_LINE_SIZE: length of the delay buffer */
#define DELAY_LINE_SIZE ((int)(2 * SAMPLING_FREQ))
/*
parent: application node pointer (typically root/app).
root: root node pointer.
*/
BasicDelayEvents::BasicDelayEvents(Node * parent, Node *root) : AudioBlock("Basic Delay", parent, (bool (*)(void*, float [SAMPLING_CHANNELS][SAMPLING_FRAME]))BasicDelayEvents::exec){
/* Initialize the MiniStompX hardware interface. */
/* BSP classes encapsulate hardware interactions, triggering hardware events, and controlling hardware devices. */
new MiniStompX();
/* Define application metadata: name, author, and logo. */
NodeItem * name = (NodeItem *)root->pathToNode("root\\sys\\_name"); //Access the node using its predefined path.
if(name != NULL) name->setValue("Basic Delay"); //Assign a value to the node.
NodeItem * author = (NodeItem *)root->pathToNode("root\\sys\\_author");
if(author != NULL) author->setValue("VoidX-DevTeam");
NodeItem * logo = (NodeItem *)root->pathToNode("root\\sys\\_logo");
if(logo != NULL) logo->setValue("https://www.voidxdevteam.com/wp-content/uploads/2024/07/logo_square.png");
/* Configure parameter nodes. */
/* For example, the mix parameter is defined as follows:
-"mix" as system name (locate the node at root/app/mix).
-Assign "Mix" as the display name, visible to the user.
-Range: 0% to 100%, default: 50%.
-Linear curve (shape = 0.5f). Log-curve (<0.5), anti-log-curve (>0.5).
-Inverted = false: parameter increases with value.
-Displayed as integer (0 decimal values) */
this->mix = new NodeFloat(parent, "mix", "Mix", 0.0f, 100.0f, 50.0f, "%", 0.5f, false, 0);
this->feedback = new NodeFloat(parent, "fdbk", "Feedback", 0.0f, 1.0f, 0.5, "", 0.5f, false, 2);
this->timeNode = new NodeFloat(parent, "time", "Time", 20.0f, 1000.0f, 100.0f, "ms", 0.5f, false, 0);
/* Initialize the graphical engine */
//DrawerHome * drw = new DrawerHome(NULL, NULL, NULL);
//drw->setNodes(parent, System::systemNode(), NULL);
/* Allocate and reset the circular buffer */
this->delayLine = (float *) memalign(16, DELAY_LINE_SIZE * sizeof(float)); //16 byte memory aligned
for(int i = 0; i < DELAY_LINE_SIZE; i++) this->delayLine[i] = 0;
this->delayLineWritePointer = 0;
this->compile();
}
bool BasicDelayEvents::compile(){
/* Mix: Convert the percentage value to a 0-1 representation. */
this->mixDsp = this->mix->getValue() / 100.0f;
/* Feedback: for stability, the actual feedback needs to be strictly less than 1. */
this->feedbackDsp = 0.999f * this->feedback->getValue();
/* Time: get the amount of delay samples. */
float msDelay = this->timeNode->getValue();
this->delaySamplesDsp = (msDelay / 1000.0f) * SAMPLING_FREQ; //SAMPLING_FREQ is 44100 or 48000
//return value
return false;
}
/*
The data bi-dimensional array stores the raw audio samples.
-The SAMPLING_CHANNELS constant defines the number of audio channels, usually 2 for stereo sound.
-The SAMPLING_FRAME constant defines the number of audio samples processed together, usually 16 samples per batch.
Input is taken from the Left channel only (Right channel is ignored). Output is equal on both channels.
*/
bool IRAM_ATTR BasicDelayEvents::exec(BasicDelayEvents * ptr, float data[SAMPLING_CHANNELS][SAMPLING_FRAME]){
/* Sample-by-sample unoptimized processing */
/* Vectorization can significantly improve processing speed. */
for(int i = 0; i < SAMPLING_FRAME; i++){
/* Determine the readIndex for retrieving samples from the delay line. */
int delayLineReadIndex = ptr->delayLineWritePointer - ptr->delaySamplesDsp;
if(delayLineReadIndex < 0) delayLineReadIndex += DELAY_LINE_SIZE; //circular buffer
/* Add a new sample to the delay line. */
/* The new sample is formed by adding the current input to a scaled version of a past output (feedback). */
ptr->delayLine[ptr->delayLineWritePointer] = data[0][i] + ptr->feedbackDsp * ptr->delayLine[delayLineReadIndex];
ptr->delayLineWritePointer++;
if(ptr->delayLineWritePointer >= DELAY_LINE_SIZE) ptr->delayLineWritePointer = 0; //circular buffer
/* Mix dry and wet signals, ensuring equal left and right outputs. */
data[0][i] = data[0][i] + ptr->mixDsp * ptr->delayLine[delayLineReadIndex];
data[1][i] = data[0][i];
}
//return value
//if there is more than one block, returning true will stop the dsp chain execution to this point
return false;
}
/*
This method is called to handle incoming events
in this example EVENT_ENC_UP will increase the mix parameter
in this example EVENT_ENC_DOWN will decrease the mix parameter
in this example EVENT_ENC_RELEASED will open the parameter list starting from the application node
*/
bool BasicDelayEvents::onEvent(int event){
switch(event){
case EVENT_ENC_UP:
this->mix->edit(true);
break;
case EVENT_ENC_DOWN:
this->mix->edit(false);
break;
case EVENT_ENC_RELEASED:
new DrawerNode(this->parent);
break;
}
return true;
}
/*
This method is called when the LCD need to be drawn
in this example we draw a label and the value of the parameter mix
*/
bool BasicDelayEvents::draw(){
glcd_drawStringFont(10, 10, (char *)"Mix", Arial14, 1);
glcd_drawStringFont(10, 30, (char *)this->mix->toString().c_str(), Arial_bold_14, 1);
return true;
}
The changes made are minimal, as is the code for event handling. Indeed, the separation of source and action has the advantage of making the application more readable. In this case, the BasicDelayEvents
class has no idea how the hardware is made; it only responds to the press/rotation of an encoder. The same applies to the EVENT_DRAW
event handling. The class doesn’t know when and how its content will be drawn on the screen, but leaves this task to the EventDispatcher
.
In this example, the DrawerNode
class was also used. This is a system Listener that, given a node, prints all its child nodes. It’s a particularly useful class for displaying all parameters without having to write code. In this example, this feature was added to show the stack structure of the EventDispatcher
. Once the new DrawerNode
Listener is created, it’s placed at the top of the stack and takes control of the system. Only after closing the screen will events be routed to BasicDelayEvents
.
Customize your app!
The BasicDelayEvents application is now fully functional. You can run it on your Mini Stomp-X platform and tweak the parameters with VoidX-Control! We’ve created a fully-functional modern system (and now personalized) in record time.