Drum Sequencer
Drum Sequencer
The channel #234 has been printed on the quick-start sticker, but left to be implemented later, as I thought it will give excellent opportunity to demonstrate how can you expand Gecho's functionality. It is relatively easy to do and a lot of fun!
Basic Operation
The goal here is to make a channel where you can create and play back sequences, using the four built-in drums (at least for starters). You will learn how to play back a sample stored in MCU's FLASH memory, detect triggering of proximity sensors, and control LED lights. The whole operation could be described in one sentence: Drive individual drums by four sensors, store these events into memory and play back on the next loop.
The Code
For details about what's needed for expanding Gecho's functionality, please see some of the previous tutorials which explain how to build a new channel and how to flash your new firmware to the unit. We will implement this experiment in a stand-alone function that does not rely on other channels, like it was shown here in Granular Sampler example. Later you can look at this tutorial about drum kit to see how to upload and play your own samples.
Let's add the channel first. In file Channels.cpp, function custom_program_init(), we will add this if statement to invoke our new function.
else if (prog == 234) //drum sequencer
{
drum_sequencer();
}
We will put this function into a new file, ideally within the "extensions" directory. Create a new Sequencer.cpp and .h files there. Function definition goes to .cpp file, let's create an empty function that does nothing for now. It also needs to refer to it's accompanying .h file. We will add another one, Drums.h, in order to access the functionality that was already implemented for the "drum kit" channel.
#include "Sequencer.h"
#include "Drums.h"
void drum_sequencer()
{
}
Into Sequencer.h file goes the function declaration. Your IDE can probably generate this file automatically (and add the prevention for recursive inclusion). What you need to add there is your function name, wrapped in two compiler directives that optionally open and close extern "C" block (which is a linkage specification). If looks confusing, don't worry about it too much - it's only required here as in our project we are mixing c and cpp code.
We also added a few #include directives to access functions from framework's other files.
#ifndef EXTENSIONS_SEQUENCER_H_
#define EXTENSIONS_SEQUENCER_H_
#include <string.h>
#include "hw/gpio.h"
#include "hw/leds.h"
#include "hw/sensors.h"
#include <Interface.h>
#ifdef __cplusplus
extern "C" {
#endif
void drum_sequencer();
#ifdef __cplusplus
}
#endif
#endif /* EXTENSIONS_GRANULAR_H_ */
Also, don't forget to include the newly created .h file in top of the Channels.cpp:
#include <extensions/Sequencer.h>
It is a good time now to try to recompile your project to check for possible errors or typos.
Storage for Events
First, let's define a few constants and variables, get user settings, and allocate memory. BAR_RESOLUTION defines the resolution, in which we want to capture the exact points where drums were triggered. Variables storing sequence length, bars and verses will be useful when it comes to LED indicators. Array called events covers the whole loop in desired resolution and it will record where the drums should play. The function set_number_of_chords() is used in other channels for different purpose with the same outcome - setting how long the loop is - so we can recycle it here. Finally, sample_to_events coefficient will be useful later when converting between time and position in events array.
void drum_sequencer()
{
#define BAR_RESOLUTION 256 //how many events to record per bar (which is usually 0.5s)
int sequence_length; //user-defined sequence length in bars, one bar usually corresponds to half a second
int sequence_bars, sequence_verses;
int verse = 0, bar = -1; //bar needs to be at position 0 at the first increment
char *events;
int events_n, event_position;
//this selector is recycled from custom_song_programming_mode()
//we have no chords here, but this needs to works the same way
sequence_length = set_number_of_chords(&sequence_bars, &sequence_verses);
events_n = sequence_length * BAR_RESOLUTION;
events = (char*)malloc(events_n); //allocate memory for events
memset(events,0,events_n); //clear the events array (set all elements to 0)
//mapping coefficient for determining event position by sampleCounter
float sample_to_events = (float)BAR_RESOLUTION / (float)I2S_AUDIOFREQ;
In the next block, we reset LEDs, wait till user releases buttons (this allows for precise timing of when to start), initialize the codec and reset the drum sample kit.
KEY_LED_all_off(); //turn off all LEDs
//wait till all buttons released (some may still be pressed from selecting a channel)
while(ANY_USER_BUTTON_ON);
ADC_configure_SENSORS(ADCConvertedValues); //configure IR proximity sensors
//init and configure audio codec
codec_init();
codec_ctrl_init();
I2S_Cmd(CODEC_I2S, ENABLE);
drum_kit_process(); //when run for the first time, this will init the drum kit
The rest happens in an infinite loop. First, animate the LED indicators to show where we are within verses and bars. Also, wrap these variables so they cycle within allowed range.
while(1)
{
sampleCounter++;
if (TIMING_BY_SAMPLE_EVERY_500_MS==0) //2Hz periodically, at 0ms
{
bar++;
if(bar==sequence_bars)
{
bar = 0;
verse++;
if(verse==sequence_verses)
{
verse = 0;
}
}
//light up a Red LED according to current bar
LED_R8_all_OFF();
LED_R8_set(bar, 1);
//light up an Orange LED according to current verse
LED_O4_all_OFF();
LED_O4_set(verse, 1);
}
The key thing happens here. We determine at which position we are within events, and store it to event_position variable. Then, for each drum (there is DRUM_CHANNELS_MAX of them) we first check if a drum has been already stored at that position, in which case we start playing it (by resetting the drum playback trigger and pointer). Otherwise, we check if a corresponding sensor has been triggered, and if it has, we start playing and store an event too. Drums are encoded within one byte as individual bits, this is done using "left shifting" bitwise operation (<<).
sample_mix = 0; //mix samples for all voices (left or right channel, based on sampleCounter)
event_position = (sampleCounter % (I2S_AUDIOFREQ*sequence_length)) * sample_to_events;
//process the drums
for (int i=0;i<DRUM_CHANNELS_MAX;i++)
{
if(events[event_position] & 2<<i)
{
drum_trigger[i] = 1; //flip the trigger
drum_samples_ptr[i] = 0; //set pointer to the beginning of the sample
}
//if not playing and IR Sensor threshold detected, start playing
else if ((ADC_last_result[i] > DRUM_SENSOR_THRESHOLD(i)) && !drum_trigger[i])
{
drum_trigger[i] = 1; //flip the trigger
drum_samples_ptr[i] = 0; //set pointer to the beginning of the sample
//store the event
events[event_position] += (2<<i);
}
//if playing and IR Sensor reports finger moved away, allow restart
if ((ADC_last_result[i] < DRUM_SENSOR_THRESHOLD(i)) && drum_trigger[i])
{
drum_trigger[i] = 0; //release trigger
}
}
We have all pointers where they should be, it's time to collect and mix sample data. We also progress individual sample pointers and reset them when reaching end of the drum sample data.
if (sampleCounter & 0x00000001) //left stereo channel
{
drum_kit_mixing_sample = 0; //clear the previous value
for (int i=0;i<DRUM_CHANNELS_MAX;i++)
{
if (drum_samples_ptr[i] >= 0) //if playing
{
//translate from 16-bit binary format to float
drum_kit_mixing_sample += ((float)((int16_t*)(drum_bases[i]))[drum_samples_ptr[i]])
* DRUM_SAMPLE_VOLUME;
drum_samples_ptr[i]++; //move on to the next sample
if (drum_samples_ptr[i]==drum_lengths[i]) //if reached the end of the sample
{
drum_samples_ptr[i] = -1; //reset the pointer to "stopped" state
}
}
}
}
One more thing, metronome is super handy here. We will emulate it using one of the drums, just played at higher octave. It will trigger at every 500ms, or twice per second.
//add metronome at 120bpm
#define METRONOME_SAMPLE 1
#define METRONOME_SAMPLE_VOLUME (DRUM_SAMPLE_VOLUME/2)
if (TIMING_BY_SAMPLE_EVERY_500_MS == 0)
{
metronome_ptr = 0; //start playing
}
if(metronome_ptr != -1)
{
drum_kit_mixing_sample += ((float)((int16_t*)(drum_bases[METRONOME_SAMPLE]))[metronome_ptr])
* METRONOME_SAMPLE_VOLUME;
//metronome_ptr += 4; //play at two octaves higher
metronome_ptr += 8; //play at three octaves higher
if (metronome_ptr >= drum_lengths[METRONOME_SAMPLE]) //if reached the end of the sample
{
metronome_ptr = -1; //reset the pointer to "stopped" state
}
}
The rest is pretty straightforward, as seen in previous tutorials. We transmit the mixed sample value to codec, read sensors, and execute functions requred to make buttons work. These actions are sometimes timed to occur on certain sample (e.g. 1234 - but this particular value doesn't matter) to avoid consuming too much CPU time if too many things happened together. And yes, the sensor processing block occurs many times in the code base without modifications, it should be converted to a macro.
sample_mix += (int16_t)(drum_kit_mixing_sample * FLASH_SAMPLES_MIXING_VOLUME); //return sample
//send data to codec
while (!SPI_I2S_GetFlagStatus(CODEC_I2S, SPI_I2S_FLAG_TXE));
SPI_I2S_SendData(CODEC_I2S, (int16_t)(sample_mix));
if (TIMING_BY_SAMPLE_EVERY_10_MS == 0) //100Hz periodically, at 0ms
{
if (ADC_process_sensors()==1) //process the sensors
{
//values from S3 and S4 are inverted (by hardware)
ADC_last_result[2] = -ADC_last_result[2];
ADC_last_result[3] = -ADC_last_result[3];
CLEAR_ADC_RESULT_RDY_FLAG;
sensors_loop++;
//enable indicating of sensor levels by LEDs
IR_sensors_LED_indicators(ADC_last_result);
}
}
//we will enable default controls too (user buttons B1-B4 to control volume, inputs and echo on/off)
if (TIMING_BY_SAMPLE_EVERY_100_MS==1234) //10Hz periodically, at sample #1234
{
buttons_controls_during_play();
}
//process any I2C commands in queue (e.g. volume change by buttons)
queue_codec_ctrl_process();
}
}
You can download the complete source - Sequencer.cpp and .h files here.
Curious how it plays?
Check it out! :)