Better Raspberry Pi Pico MIDI controller
adding modifier keys and chord support
To expand the number of notes/chords my RP2040 based MIDI controller can play we’ll turn two keys into modifiers. This way the other 7 buttons can actually each be assigned 4 different notes/chords (a default, pressed with modifier one, pressed with modifier two and pressed with both modifiers active). So we go from 9 notes/chords to 28 different options, which should expand the number of songs that can be played substantially.
While at first glance this seems like an easy task, there are a few things that need to be taken into consideration. A MIDI device sends a signal once a note is pressed and one when it is released. In the previous post this was straightforward; each button was playing two notes one octave apart, and there was no overlap between buttons. So when a button goes down, the corresponding notes can start, if that button goes up those notes can be started again, easy …
When assigning proper chords to buttons, things get more complex. Imagine a key that plays a C chord, once pressed a signal is sent to play notes C, E and G. If that key is released a signal to stop those notes is needed. However, if another key playing an E chord (E-G#-Bb) is pressed without releasing the former, there is an overlapping note. So releasing the C chord button should not stop playing the E note. To do this we need to keep track of a few things: which modifiers were active when a button was pressed and which notes are currently playing. That way we can figure out at any given time which notes should be playing and which need to be stopped. This is a bit of extra overhead, fortunately the RP2040 chip has plenty of power to do this without much issue.
How to build the keypad is discussed here. This post includes a part list, all schematics you’ll need along with STL files for the 3D printed parts. If you are new to MIDI devices you might want to start with the previous post as this is an easier starting point.
MIDIPad v2.0 - the code
Here is the full code which can also be downloaded here. Note that this is intended to work with CircuitPython
and requires the AdaFruit MIDI library to be installed on the device. Below the code some major changes will
be highlighted. This file needs to be renamed to code.py
and put in the root directory of the Pi Pico.
import board
import digitalio
import pwmio
import time
import usb_midi
import adafruit_midi
from adafruit_midi.note_on import NoteOn
from adafruit_midi.note_off import NoteOff
midi = adafruit_midi.MIDI(midi_out=usb_midi.ports[1], out_channel=0)
print("MacroPad MIDI Board")
print("Default output MIDI channel:", midi.out_channel + 1)
# Configuration, which LED pins are used, which buttons, how buttons map to notes
led_pins = [board.GP18,board.GP17,board.GP16,board.GP21,board.GP20,board.GP19, board.GP27, board.GP26,board.GP22]
button_pins = [board.GP13,board.GP14,board.GP15, board.GP10,board.GP11,board.GP12,board.GP7]
button_led_ix = [0,1,2,3,4,5,6]
modifier_pins = [board.GP8,board.GP9]
modifier_led_ix = [7, 8]
note_mapping = {
# Notes when no modifier is pressed (major chord)
0: [
["C3", "E3", "G3"],
["D3", "F#3", "A3"],
["E3", "G#3", "B3"],
["F3", "A3", "C4"],
["G3", "B3", "D4"],
["A3", "C#4", "E4"],
["B3", "D#4", "F#4"]],
# Notes when modifier one is pressed (minor chords)
1: [
["C3", "Eb3", "G3"],
["D3", "F3", "A3"],
["E3", "G3", "B3"],
["F3", "Ab3", "C4"],
["G3", "Bb3", "D4"],
["A3", "C4", "E4"],
["B3", "D4", "F#4"]],
# Notes when modifier two is pressed (sus2 chords)
2: [
["C3", "D3", "G3"],
["D3", "E3", "A3"],
["E3", "F#3", "B3"],
["F3", "G3", "C4"],
["G3", "A3", "D4"],
["A3", "B3", "E4"],
["B3", "C#4", "F#4"]],
# Notes when both modifiers are pressed (sus4 chords)
3: [
["C3", "F3", "G3"],
["D3", "G3", "A3"],
["E3", "A3", "B3"],
["F3", "A#3", "C4"],
["G3", "C4", "D4"],
["A3", "D4", "E4"],
["B3", "E4", "F#4"]]
}
# Set up buttons
buttons = [digitalio.DigitalInOut(bp) for bp in button_pins]
for btn in buttons:
btn.direction = digitalio.Direction.INPUT
btn.pull = digitalio.Pull.UP
modifiers = [digitalio.DigitalInOut(bp) for bp in modifier_pins]
for modif in modifiers:
modif.direction = digitalio.Direction.INPUT
modif.pull = digitalio.Pull.UP
# Set up LEDs
leds = [pwmio.PWMOut(lp, frequency=1000, duty_cycle=0) for lp in led_pins]
# Set Initial Duty Cycles to 0 for each LED
duty_cycles = [0 for _ in led_pins]
# Last Pressed
pressed_keys = [False for _ in button_pins]
triggered_keys = [-1 for _ in button_pins]
pressed_modifiers = [False for _ in modifier_pins]
# Notes playing
notes_playing = []
print("started loop")
while True:
# Handle modifier buttons
modifier_value = 0
for ix, (led_ix, modif) in enumerate(zip(modifier_led_ix, modifiers)):
pressed_modifiers[ix] = not modif.value
if not modif.value:
modifier_value += 2 ** ix
# print(f"pressed modifier {ix}")
# print(f"current modifier {modifier_value}")
duty_cycles[led_ix] = 65025
# Handle buttons pressed
for ix, (led_ix, btn) in enumerate(zip(button_led_ix, buttons)):
pressed_keys[ix] = not btn.value
if not btn.value:
# print(f"pressed button {ix}")
duty_cycles[led_ix] = 65025
for ix, (pk, tk) in enumerate(zip(pressed_keys, triggered_keys)):
if pk and tk < 0:
print(f"note {ix} started with modifier {modifier_value}")
triggered_keys[ix] = modifier_value
# Start all notes in the chord
midi.send([NoteOn(a, 60) for a in note_mapping[modifier_value][ix]])
for a in note_mapping[modifier_value][ix]:
notes_playing.append(a)
elif not pk and 0 <= tk:
print(f"note {ix} stopped")
triggered_keys[ix] = -1
# Check which notes/chords are currently playing after handling buttons
notes_playing_updated = []
for ix, tk in enumerate(triggered_keys):
if 0 <= tk:
notes_playing_updated = notes_playing_updated + note_mapping[tk][ix]
# Stop notes no longer playing
notes_to_stop = set(notes_playing) - set(notes_playing_updated)
midi.send([NoteOff(a, 0) for a in notes_to_stop])
# Move updated list to notes_playing for next cycle
notes_playing = notes_playing_updated
# Fade effect on LEDs
for ix, led in enumerate(leds):
led.duty_cycle = duty_cycles[ix]
duty_cycles[ix] = max(duty_cycles[ix] - 900, 0)
time.sleep(0.01)
Adding in modifier keys
Two buttons have been used as modifier keys, in the program logic these are now handled differently from regular buttons that will play some notes. There are additional changes required to make sure we can still match the right LED to each button correctly. So to do this, a list of pins with buttons is defined as well as a list of the index of the matching LED. The same is done for the modifier buttons.
# Configuration, which LED pins are used, which buttons, how buttons map to notes
led_pins = [board.GP18,board.GP17,board.GP16,board.GP21,board.GP20,board.GP19, board.GP27, board.GP26,board.GP22]
button_pins = [board.GP13,board.GP14,board.GP15, board.GP10,board.GP11,board.GP12,board.GP7]
button_led_ix = [0,1,2,3,4,5,6]
modifier_pins = [board.GP8,board.GP9]
modifier_led_ix = [7, 8]
To accommodate these changes, additional changes throughout the code were necessary. Nothing to complicated, the
modifier buttons need to be set up separately and when changing the duty cycle for a specific LED, the index from
either button_led_ix
or modifier_led_ix
as there is no longer a 1-to-1 mapping of keys and LEDs.
The modifier keys itself include an old trick. Essentially each modifier key is assigned a value which is a power of two, so the first on is 1 (2^0), the next 2 (2^1), then 4 (2^2), … that way if we add the values of all pressed modifiers together you get a unique number for each possible combination. Here only two buttons are available so the modifier value can be 0 (no modifier), 1 (first button pressed), 2 (second button pressed) or 3 (both buttons pressed). This is done in the bit of code below and is the first step of the main loop.
# Handle modifier buttons
modifier_value = 0
for ix, (led_ix, modif) in enumerate(zip(modifier_led_ix, modifiers)):
pressed_modifiers[ix] = not modif.value
if not modif.value:
modifier_value += 2 ** ix
# print(f"pressed modifier {ix}")
# print(f"current modifier {modifier_value}")
duty_cycles[led_ix] = 65025
Playing chords
The biggest hurdle with playing chords is that notes within different chords can overlap. So the logic to start and stop playing certain notes needed to be upgraded.
In the previous version we tracked which keys were pressed (so we could release them correctly) and if the notes associated with that key were playing already (or triggered). Now we’ll need to keep track of which modifiers were active when a key is triggered. So rather than using boolean logic we’ll set the trigger to -1 (not triggered) or the modifier value (0-3) if a button is pressed.
for ix, (pk, tk) in enumerate(zip(pressed_keys, triggered_keys)):
if pk and tk < 0:
print(f"note {ix} started with modifier {modifier_value}")
triggered_keys[ix] = modifier_value
# Start all notes in the chord
midi.send([NoteOn(a, 60) for a in note_mapping[modifier_value][ix]])
for a in note_mapping[modifier_value][ix]:
notes_playing.append(a)
elif not pk and 0 <= tk:
print(f"note {ix} stopped")
triggered_keys[ix] = -1
# Check which notes/chords are currently playing after handling buttons
notes_playing_updated = []
for ix, tk in enumerate(triggered_keys):
if 0 <= tk:
notes_playing_updated = notes_playing_updated + note_mapping[tk][ix]
# Stop notes no longer playing
notes_to_stop = set(notes_playing) - set(notes_playing_updated)
midi.send([NoteOff(a, 0) for a in notes_to_stop])
# Move updated list to notes_playing for next cycle
notes_playing = notes_playing_updated
Which chords to pick ? And how to define them ?
The short answer is, you add in the chords you need. I picked from a major scale the major chords (C, D, E, F, G, A
and B) and modifiers can be used to turn these into minor chords, sus2 chords or sus4 chords. These are defined in
a dictionary in the beginning of the code called note_mapping
, the key is the modifier which is active and the
value is a list of chords matching the buttons defined.
Note that it is extremely easy to modify the chords to match your preferences. Simply add/remove/change notes to this list and done!
note_mapping = {
# Notes when no modifier is pressed (major chord)
0: [
["C3", "E3", "G3"],
["D3", "F#3", "A3"],
["E3", "G#3", "B3"],
["F3", "A3", "C4"],
["G3", "B3", "D4"],
["A3", "C#4", "E4"],
["B3", "D#4", "F#4"]],
# Notes when modifier one is pressed (minor chords)
1: [
["C3", "Eb3", "G3"],
["D3", "F3", "A3"],
["E3", "G3", "B3"],
["F3", "Ab3", "C4"],
["G3", "Bb3", "D4"],
["A3", "C4", "E4"],
["B3", "D4", "F#4"]],
# Notes when modifier two is pressed (sus2 chords)
2: [
["C3", "D3", "G3"],
["D3", "E3", "A3"],
["E3", "F#3", "B3"],
["F3", "G3", "C4"],
["G3", "A3", "D4"],
["A3", "B3", "E4"],
["B3", "C#4", "F#4"]],
# Notes when both modifiers are pressed (sus4 chords)
3: [
["C3", "F3", "G3"],
["D3", "G3", "A3"],
["E3", "A3", "B3"],
["F3", "A#3", "C4"],
["G3", "C4", "D4"],
["A3", "D4", "E4"],
["B3", "E4", "F#4"]]
}
Conclusion
The MIDIpad now can play 28 different chords, which is a substantial increase for the 9 from the last version. Also overlapping notes between chords are now handled correctly. So with a little extra effort the little keypad suddenly gained a few new tricks. Though there is still an issue with using normal switches, they don’t detect how hard a note is pressed … This is still something I’d like so solve in a future post!
Liked this post ? You can buy me a coffee