MIDI Controller

Download the cpx-expressive-midi-controller.py file with the link below. Plug your Circuit Playground Express (CPX) into your computer via a known-good USB data cable. A flash drive named CIRCUITPY should appear in your file explorer/finder program. Copy cpx-expressive-midi-controller.py to the CIRCUITPY drive, renaming it code.py.

Scroll past the code below for two videos showing the CPX controlling some synthesizers and a discussion on selected parts of the program.

### cpx-expressive-midi-controller v1.2
### CircuitPython (on CPX) MIDI controller using the seven touch pads
### and accelerometer for modulation (cc1) and pitch bend
### Left button adjusts octave (switch left) or semitone (switch right)
### Right button adjusts scale, major or chromatic
### Switch right also disables pitch bend and modulation

### Tested with CPX and CircuitPython and 4.0.0-beta.5

### Needs recent adafruit_midi module

### copy this file to CPX as code.py

### MIT License.

### Copyright (c) 2019 Kevin J. Walters

### Permission is hereby granted, free of charge, to any person obtaining a copy
### of this software and associated documentation files (the "Software"), to deal
### in the Software without restriction, including without limitation the rights
### to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
### copies of the Software, and to permit persons to whom the Software is
### furnished to do so, subject to the following conditions:

### The above copyright notice and this permission notice shall be included in all
### copies or substantial portions of the Software.

### THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
### IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
### FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
### AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
### LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
### OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
### SOFTWARE.

import time

import digitalio
import touchio
import busio
import board
import usb_midi
import neopixel

import adafruit_lis3dh
import adafruit_midi

from adafruit_midi.note_on          import NoteOn
from adafruit_midi.control_change   import ControlChange
from adafruit_midi.pitch_bend       import PitchBend

# MIDI defines middle C as 60 and modulation wheel is cc 1 by convention
midi_note_C4 = 60
midi_cc_modwheel = 1  # was const(1)

# 0x19 is the i2c address of the onboard accelerometer
acc_i2c = busio.I2C(board.ACCELEROMETER_SCL, board.ACCELEROMETER_SDA)
acc_int1 = digitalio.DigitalInOut(board.ACCELEROMETER_INTERRUPT)
acc = adafruit_lis3dh.LIS3DH_I2C(acc_i2c, address=0x19, int1=acc_int1)
acc.range = adafruit_lis3dh.RANGE_2_G
acc.data_rate = adafruit_lis3dh.DATARATE_10_HZ

# brightness 1.0 saves memory by removing need for a second buffer
# 10 is number of NeoPixels on CPX
numpixels = 10  # was const(10)
pixels = neopixel.NeoPixel(board.NEOPIXEL, numpixels, brightness=1.0)

# Turn NeoPixel on to represent a note using RGB x 10
# to represent 30 notes - doesn't do anything with pitch bend
def noteLED(pix, pnote, pvel):
    note30 = (pnote - midi_note_C4) % (3 * numpixels)
    pos = note30 % numpixels
    r, g, b = pix[pos]
    if pvel == 0:
        brightness = 0
    else:
        # max brightness will be 32
        brightness = round(pvel / 127 * 30 + 2)
    # Pick R/G/B based on range within the 30 notes
    if note30 < 10:
        r = brightness
    elif note30 < 20:
        g = brightness
    else:
        b = brightness
    pix[pos] = (r, g, b)

# white pulse used to indicate octave changes
flashbrightness = 20
def flashLED(pix, position):
    pos = position % numpixels
    t1 = time.monotonic()
    oldcolour = pix[pos]
    while time.monotonic() - t1 < 0.25:
        for i in range(0, flashbrightness, 2):
            pix[pos] = (i, i, i)
        for i in range(flashbrightness, 0, -2):
            pix[pos] = (i, i, i)
    pix[pos] = oldcolour

midi_channel = 1
midi = adafruit_midi.MIDI(midi_out=usb_midi.ports[1],
                          out_channel=midi_channel-1)

# CPX counter-clockwise order of touch capable pads (i.e. not A0)
pads = [board.A4,
        board.A5,
        board.A6,
        board.A7,
        board.A1,
        board.A2,
        board.A3]

# The touch pads calibrate themselves as they are created, just once here
touchpads = [touchio.TouchIn(pad) for pad in pads]
del pads  # done with that

pb_midpoint = 8192
pitch_bend_value = pb_midpoint  # mid point - no bend
min_pb_change = 250

mod_wheel = 0
min_mod_change = 5

# button A is on left (usb at top)
button_left = digitalio.DigitalInOut(board.BUTTON_A)
button_left.switch_to_input(pull=digitalio.Pull.DOWN)
button_right = digitalio.DigitalInOut(board.BUTTON_B)
button_right.switch_to_input(pull=digitalio.Pull.DOWN)
switch_left = digitalio.DigitalInOut(board.SLIDE_SWITCH)
switch_left.switch_to_input(pull=digitalio.Pull.UP)

# some example scales in semitones
scale_st = {"major": [0, 2, 4, 5, 7, 9, 11],
            "chromatic": [0, 1, 2, 3, 4, 5, 6]}
scales = ["major", "chromatic"]
scale_idx = 0
base_note = midi_note_C4  # C4 middle C

def make_scale(scale_name):
    return [semitone_offset + base_note
            for semitone_offset in scale_st[scale_name]]

midi_notes = make_scale(scales[scale_idx])
keydown = [False] * 7

velocity = 127
min_octave = -3
max_octave = +3
octave = 0
min_semitone = -11
max_semitone = +11
semitone = 0

# 1/10 = 10 Hz - review data_rate setting if this is changed
acc_read_t = time.monotonic()
acc_read_period = 1/10
# For accelerometer do nothing between 0 and 1.3 (ms-2)
acc_nullzone = 1.3
acc_range = 4.0

# Convert an accelerometer reading
# from min_msm2 to min_msm2+range to an int from 0 to value_range
# or return 0 or value_range outside those values
# The conversion is applied "symmetrically" to negative numbers
def scale_acc(acc_msm2, min_msm2, range_msm2, value_range):
    if acc_msm2 >= 0.0:
        sign_a_m = 1
        magn_acc_msm2 = acc_msm2
    else:
        sign_a_m = -1
        magn_acc_msm2 = abs(acc_msm2)

    adj_msm2 = magn_acc_msm2 - min_msm2

    # deal with out of bounds values else scale value
    # pylint: disable=no-else-return
    if adj_msm2 <= 0:
        return 0
    elif adj_msm2 >= range_msm2:
        return sign_a_m * value_range
    else:
        return sign_a_m * round(adj_msm2 / range_msm2 * value_range)

# Scan each pad and look for changes by comparing
# with keystate stored in keydown boolean list
# and send note on/off messages accordingly
# Send pitch bend and mod wheel cc based on tilt from accelerometer
# Change octave and semitone based on buttons
while True:
    for idx, touchpad in enumerate(touchpads):
        if touchpad.value != keydown[idx]:
            keydown[idx] = touchpad.value
            # 12 semitones in an octave
            note = midi_notes[idx] + octave * 12 + semitone
            if keydown[idx]:
                midi.send(NoteOn(note, velocity))
                noteLED(pixels, note, velocity)
            else:
                midi.send(NoteOn(note, 0))  # Using note on 0 for off
                noteLED(pixels, note, 0)

    # Perform rate limited checks on the accelerometer
    # if switch is to left
    now_t = time.monotonic()
    if switch_left.value and now_t - acc_read_t > acc_read_period:
        acc_read_t = time.monotonic()
        ax, ay, az = acc.acceleration

        # scale from 0 to 127 (maximum cc 7bit value)
        new_mod_wheel = abs(scale_acc(ay, acc_nullzone, acc_range, 127))
        if (abs(new_mod_wheel - mod_wheel) > min_mod_change
                or (new_mod_wheel == 0 and mod_wheel != 0)):
            midi.send(ControlChange(midi_cc_modwheel, new_mod_wheel))
            mod_wheel = new_mod_wheel

        # scale from 0 to +/- 8191 (almost maximum signed 14bit values)
        new_pitch_bend_value = (pb_midpoint
                                - scale_acc(ax, acc_nullzone, acc_range,
                                            pb_midpoint - 1))
        if (abs(new_pitch_bend_value - pitch_bend_value) > min_pb_change
                or (new_pitch_bend_value == pb_midpoint
                    and pitch_bend_value != pb_midpoint)):
            midi.send(PitchBend(new_pitch_bend_value))
            pitch_bend_value = new_pitch_bend_value

    # left button increase octave / semitones shift based on switch
    # does not currently clear playing notes (buglet)
    if button_left.value:
        if switch_left.value:
            octave += 1
            if octave > max_octave:
                octave = min_octave
            flashLED(pixels, octave)
        else:
            semitone += 1
            if semitone > max_semitone:
                semitone = min_semitone
            # semitone range is more than number of pixels!
            flashLED(pixels, semitone)

        while button_left.value:
            pass  # wait for button up

    # right button cycles through scales
    if button_right.value:
        scale_idx += 1
        if scale_idx >= len(scales):
            scale_idx = 0
        flashLED(pixels, scale_idx)
        midi_notes = make_scale(scales[scale_idx])

        while button_right.value:
            pass  # wait for button up

MIDI Controller Examples

The first video below shows the program running on a CPX board controlling Nikolay Tsenkov's Viktor NV-1 free software synthesizer. Note the movement of the pitch wheel and modulation wheel just left of the keyboard.

The second video uses the CPX to control the Gakken Pocket Miku (NSX-39) synthesizer. This synthesizer is normally used for its Vocaloid functionality but here MIDI channel 2 is being used for its General MIDI implementation of a piano, part of its Yamaha XG functionality.

Code Discussion

The main part of the program is a loop which sends certain MIDI messages based on various inputs, like:

  • a change in touching of the touch pads which causes note on or note off messages to be sent,
  • any significant change in accelerometer x and y values which sends pitch bend change or change control messages, these are limited by rate and inhibited by the switch in right position.

The loop also checks the button and switch values. These are used to change octave/semitone range and scale (between major and chromatic modes).

The program starts with a major scale at middle C. The touch pad at the top left of CPX will initially send a C4 (MIDI note 60) and next one (counterclockwise) will send a D4 (MIDI note 62) and so on. The code excerpt below shows this code, the MIDI note values for each touch pad are stored in midi_notes[idx], these are based on the middle C value plus offset per scale. This is further adjusted by the current octave and semitone offset selected - a comment informs us of the significance of the "magic value" of 12. The semitone offset can be used to change the key or in chromatic scale to offset a second CPX by +7 semitones to allow two CPXs with 14 notes to cover just over a full octave.

Download: file
for idx, touchpad in enumerate(touchpads):
    if touchpad.value != keydown[idx]:
        keydown[idx] = touchpad.value
        # 12 semitones in an octave
        note = midi_notes[idx] + octave * 12 + semitone
        if keydown[idx]:
            midi.send(NoteOn(note, velocity))
            noteLED(pixels, note, velocity)
        else:
            midi.send(NoteOn(note, 0))  # Using note on 0 for off
            noteLED(pixels, note, 0)

The keydown list is tracking the previous state of the touchpad. This is needed to avoid sending unnecessary duplicate MIDI messages if nothing has changed.

One surprise may be the use of note on message for the user lifting their finger from the touch pad. When velocity is set to 0 this becomes equivalent to a note off for most MIDI devices. This is a useful memory economisation on an M0 processor-based board as each message is a separate python class and these consume memory when they are import'ed. The technique for importing the library with only the messages needed is shown below.

Download: file
import adafruit_midi

from adafruit_midi.note_on          import NoteOn
from adafruit_midi.control_change   import ControlChange
from adafruit_midi.pitch_bend       import PitchBend

When a button is used to change a value the new value is shown by briefly flashing a NeoPixel white a few times with the flashLED() function and then restoring the NeoPixel to its previous value.

Download: file
# white pulse used to indicate octave changes
flashbrightness = 20
def flashLED(pix, position):
    pos = position % numpixels
    t1 = time.monotonic()
    oldcolour = pix[pos]
    while time.monotonic() - t1 < 0.25:
        for i in range(0, flashbrightness, 2):
            pix[pos] = (i, i, i)
        for i in range(flashbrightness, 0, -2):
            pix[pos] = (i, i, i)
    pix[pos] = oldcolour

This code works but it's worth discussing some minor issues with it. The while loop is running for a quarter (0.25) of a second, this would be more flexible if it was a function argument with a default value. The same could be said for the global variable flashbrightness. This would also make it easier to test.

Perhaps more importantly, inside the while loop there are two for loops which ramp the brightness of pix[pos] up and then down. These are not constrained by time but happen to execute at a desirable rate on the CPX board. This means the flashing rate of the NeoPixel is subject to the performance of the CircuitPython interpreter, the neopixel library and the processor - changes to any of those could alter the flash rate. A board using the faster M4 processor will inevitably make this flash much faster.

The code which sends the control change message is shown below. The scale_acc() function is taking the accelerometer's ay (in ms-2) value and turning it into an integer value between 0 and 127. The acc_nullzone variable (set to 1.3) keeps the value at 0 even when the board isn't quite flat or is gently nudged. The acc_range variable (set to 4.0) determines the end of the range. i.e. an ay value of acc_nullzone + acc_range will return 127. It might be more natural to calculate and use the angle of the board instead of ay, but with the current ranges the movement feels appropriate for the modulation output. The use of abs() on the scale_acc() return value makes forward and backward tilt equivalent.

Download: file
# scale from 0 to 127 (maximum cc 7bit value)
new_mod_wheel = abs(scale_acc(ay, acc_nullzone, acc_range, 127))
if (abs(new_mod_wheel - mod_wheel) > min_mod_change
        or (new_mod_wheel == 0 and mod_wheel != 0)):
    midi.send(ControlChange(midi_cc_modwheel, new_mod_wheel))
    mod_wheel = new_mod_wheel

The if statement is determining whether the value has changed by a significant amount since the last value was sent. The value of 0 will always be sent to allow the modulation wheel to return to exactly 0 which often equates to no modulation.

The checks and message sending code for control change and pitch bend change messages are wrapped in an if statement to ensure they are only sent at a maximum frequency, currently fixed at 10Hz. This keeps the MIDI message rate to a low rate suitable for all devices. This could be increased or made controllable but care would be needed to ensure the accelerometer readings are not noisy and give smooth variations.

The code for switch and button handling is getting a little lengthy and making the contents of the main while loop rather large. This can be a bit of a trap as further small additions accumulate and the code can get larger and larger. This can lead to a segment of code that's difficult to understand and more likely to be or become buggy. Moving some of the code into one or more functions is likely to make the code less unwieldy and more maintainable.

Capacitive Touch

The capacitive touch pads calibrate themselves when the program starts. The code may need to be restarted if the CPX board is moved to a different surface or if an external capactive keyboard is added.

From CircuitPython Cap Touch:

If you get too many touch responses or not enough, reload your code through the serial console or eject the board and tap the reset button!

It's interesting to note that capacitive keyboards are far from a new invention. From the book Analog Days, referring to early 1960s Buchla 100 Series:

"They [the ports] were all capacitance-sensitive touch-plates, or resistance-sensitive in some cases, organized in various sorts of array.

"I saw no reason to borrow from a keyboard, which is a device invented to throw hammers at strings, later on, for operating switches for electronic organs and so-on."

Don Buchla

MIDI routing

MIDI messages between different USB devices are not automatically forwarded by the operating system. An application is required to forward or route the messages. Tommy van Leeuwen has written a very useful Web MIDI Sequencer, Router & Drum Machine application which can be used for this.

Web MIDI allows browser-based applications to communicate with MIDI devices. Web MIDI is only implemented in some browsers, for example Chrome and Opera.

The example in the video above shows the CircuitPython MIDI's channel 1 being sent to the NSX-39's channel 2.

The example in the screenshot below shows an Axiom 25 MIDI In's channel 1 being sent to the CircuitPython MIDI's channel 1.

circuitpython_midi.tomarus.io-router-example-largenwide-trimmed.png
Router panel of http://midi.tomarus.io/ Web MIDI sequencer.

Synthesizers

Everyone needs a synthesizer! If you don't have an EMS Synthi A at hand (picture below), there's a useful list of software synthesizers on Trellis M4 Expressive MIDI Controller guide including some free ones.

MuTools MuLab is another digital audio workstation (DAW) which can be used in a restricted mode for free.

Plogue chipsounds is a VST plugin and standalone synthesizer with meticulous recreations of 1970s and 1980s era sound chips. It can be used for 4 minutes per session for free.

circuitpython_every-nun-needs-an-ems-synthi-advert-trimmed.jpg
EMS advertisement for a Synthi A synthesizer from the 1970s.

The next page has a program for a basic synthesizer for the CPX.

This guide was first published on May 14, 2019. It was last updated on May 14, 2019. This page (MIDI Controller) was last updated on Nov 16, 2019.