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.
# SPDX-FileCopyrightText: 2019 Kevin J. Walters for Adafruit Industries # # SPDX-License-Identifier: MIT ### 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
andy
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.
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.
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.
# 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.
# 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.
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."
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.
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.
The next page has a program for a basic synthesizer for the CPX.
Page last edited January 21, 2025
Text editor powered by tinymce.