Download the cpx-basic-synth.py
file with the link below and copy it to the CIRCUITPY drive renaming it code.py.
Scroll past the code below for a discussion on selected parts of the program and a video showing the CPX synthesizer being controlled by another CPX.
# SPDX-FileCopyrightText: 2019 Kevin J. Walters for Adafruit Industries # # SPDX-License-Identifier: MIT ### cpx-basic-synth v1.4 ### CircuitPython (on CPX) synth module using internal speaker ### Velocity sensitive monophonic synth ### with crude amplitude modulation (cc1) and choppy pitch bend ### Tested with CPX and CircuitPython and 4.0.0-beta.7 ### 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 array import time import math import digitalio import audioio import audiocore import board import usb_midi import neopixel import adafruit_midi from adafruit_midi.midi_message import note_parser from adafruit_midi.note_on import NoteOn from adafruit_midi.note_off import NoteOff from adafruit_midi.control_change import ControlChange from adafruit_midi.pitch_bend import PitchBend # Turn the speaker on speaker_enable = digitalio.DigitalInOut(board.SPEAKER_ENABLE) speaker_enable.direction = digitalio.Direction.OUTPUT speaker_on = True speaker_enable.value = speaker_on dac = audioio.AudioOut(board.SPEAKER) # 440Hz is the standard frequency for A4 (A above middle C) # MIDI defines middle C as 60 and modulation wheel is cc 1 by convention A4refhz = 440 # was const(440) midi_note_C4 = note_parser("C4") midi_note_A4 = note_parser("A4") midi_cc_modwheel = 1 # was const(1) twopi = 2 * math.pi # A length of 12 will make the sawtooth rather steppy sample_len = 12 base_sample_rate = A4refhz * sample_len max_sample_rate = 350000 # a CPX / M0 DAC limitation midpoint = 32768 # A sawtooth function like math.sin(angle) # 0 returns 1.0, pi returns 0.0, 2*pi returns -1.0 def sawtooth(angle): return 1.0 - angle % twopi / twopi * 2 # make a sawtooth wave between +/- each value in volumes # phase shifted so it starts and ends near midpoint # "H" arrays for RawSample looks more memory efficient # see https://forums.adafruit.com/viewtopic.php?f=60&t=150894 def waveform_sawtooth(length, waves, volumes): for vol in volumes: waveraw = array.array("H", [midpoint + round(vol * sawtooth((idx + 0.5) / length * twopi + math.pi)) for idx in list(range(length))]) waves.append((audiocore.RawSample(waveraw), waveraw)) # Make some square waves of different volumes volumes, generated with # n=10;[round(math.sqrt(x)/n*32767*n/math.sqrt(n)) for x in range(1, n+1)] # square root is for mapping velocity to power rather than signal amplitude # n=15 throws MemoryError exceptions when a note is played :( waveform_by_vol = [] waveform_sawtooth(sample_len, waveform_by_vol, [10362, 14654, 17947, 20724, 23170, 25381, 27415, 29308, 31086, 32767]) # 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) # Calculate the note frequency from the midi_note with pitch bend # of pb_st (float) semitones # Returns float def note_frequency(midi_note, pb_st): # 12 semitones in an octave return A4refhz * math.pow(2, (midi_note - midi_note_A4 + pb_st) / 12.0) midi_channel = 1 midi = adafruit_midi.MIDI(midi_in=usb_midi.ports[0], in_channel=midi_channel-1) # pitchbendrange in semitones - often 2 or 12 pb_midpoint = 8192 pitch_bend_multiplier = 2 / pb_midpoint pitch_bend_value = pb_midpoint # mid point - no bend wave = [] # current or last wave played last_note = None # Amplitude modulation frequency in Hz am_freq = 16 mod_wheel = 0 # Read any incoming MIDI messages (events) over USB # looking for note on, note off, pitch bend change # or control change for control 1 (modulation wheel) # Apply crude amplitude modulation using speaker enable while True: msg = midi.receive() if isinstance(msg, NoteOn) and msg.velocity != 0: last_note = msg.note # Calculate the sample rate to give the wave form the frequency # which matches the midi note with any pitch bending applied pitch_bend = (pitch_bend_value - pb_midpoint) * pitch_bend_multiplier note_freq = note_frequency(msg.note, pitch_bend) note_sample_rate = round(base_sample_rate * note_freq / A4refhz) # Select the wave with volume for the note velocity # Value slightly above 127 together with int() maps the velocities # to equal intervals and avoids going out of bound wave_vol = int(msg.velocity / 127.01 * len(waveform_by_vol)) wave = waveform_by_vol[wave_vol] if note_sample_rate > max_sample_rate: note_sample_rate = max_sample_rate wave[0].sample_rate = note_sample_rate # must be integer dac.play(wave[0], loop=True) noteLED(pixels, msg.note, msg.velocity) elif (isinstance(msg, NoteOff) or isinstance(msg, NoteOn) and msg.velocity == 0): # Our monophonic "synth module" needs to ignore keys that lifted on # overlapping presses if msg.note == last_note: dac.stop() last_note = None noteLED(pixels, msg.note, 0) # turn off NeoPixel elif isinstance(msg, PitchBend): pitch_bend_value = msg.pitch_bend # 0 to 16383 if last_note is not None: pitch_bend = (pitch_bend_value - pb_midpoint) * pitch_bend_multiplier note_freq = note_frequency(last_note, pitch_bend) note_sample_rate = round(base_sample_rate * note_freq / A4refhz) if note_sample_rate > max_sample_rate: note_sample_rate = max_sample_rate wave[0].sample_rate = note_sample_rate # must be integer dac.play(wave[0], loop=True) elif isinstance(msg, ControlChange): if msg.control == midi_cc_modwheel: mod_wheel = msg.value # msg.value is 0 (none) to 127 (max) if mod_wheel > 0: t1 = time.monotonic() * am_freq # Calculate a form of duty_cycle for enabling speaker for crude # amplitude modulation. Empirically the divisor needs to greater # than 127 as can't hear much when speaker is off more than half # 220 works reasonably well new_speaker_on = (t1 - int(t1)) > (mod_wheel / 220) else: new_speaker_on = True if speaker_on != new_speaker_on: speaker_enable.value = new_speaker_on speaker_on = new_speaker_on
Basic Synthesizer Example
The video below shows one CPX running the MIDI Controller program from the previous page and the second running the synthesizer code on this page. Tilting to the left (or right) bends the pitch and tilting up or down increases the mod wheel.
The video shows the limitations of the approach for pitch bending. There's a very perceivable delay when the sample is played at a new sample rate to change the frequency. This gives the pitch bend a certain unpleasant choppiness and means it can lag behind a rapid burst of pitch bend change messages. Attempts at portamento are going to sound like a low quality glissando!
A compiled, mid-level language like C is less easy to use than Python but can offer faster, more predictable performance and often allows more direct control over the audio hardware. The Soulsby miniATMEGATRON shows what can be done with C code using an Arduino Uno based on the slower ATmega328P processor and an external low-pass filter.
Code Discussion
The main part of the program is a loop checking for any incoming MIDI messages and acting on these. At the end of the loop it applies an innovative form of crude amplitude modulation by rapidly disabling/enabling the speaker. The MIDI message actions are:
- note on - plays a pre-constructed sample at a sample rate which matches the note's pitch and a volume which approximates the note's velocity, note pitch is shown on the NeoPixels,
- note off - ends playing of the current sample and turns off the NeoPixel,
- pitch bend change - if a note is playing then the sample rate is adjusted for the new value of pitch bend,
- control change for cc1 - this represents the mod wheel and is used to control the duty cycle of the amplitude modulation for speaker output.
The sample used to play the note is a short list of values representing a sawtooth wave. The short length makes it low resolution and gives it a "steppy" appearance, see the next page for an in-depth look at waveforms. This single cycle wave is generated for a small range of volumes before the program's main loop with the code below. This pre-calculation uses a bit more memory but minimises the time it takes (latency) to start playing a note.
def waveform_sawtooth(length, waves, volumes): for vol in volumes: waveraw = array.array("H", [midpoint + round(vol * sawtooth((idx + 0.5) / length * twopi + math.pi)) for idx in list(range(length))]) waves.append((audioio.RawSample(waveraw), waveraw)) waveform_by_vol = [] waveform_sawtooth(sample_len, waveform_by_vol, [10362, 14654, 17947, 20724, 23170, 25381, 27415, 29308, 31086, 32767])
The list of numbers representing volumes has been generated externally with:
n=10 [round(math.sqrt(x) / math.sqrt(n) * 32767) for x in range(1, n + 1)]
The numbers are not evenly spaced because they represent the maximum amplitude of the wave which is output as a voltage. If the voltage is doubled, then broadly speaking the current will double and since power is the product of these two quantities the power will quadruple. This explains the role of the math.sqrt()
to provide amplitudes which represent a linear increase in power. These will correspond to an increase in volume (sound pressure level) when the velocity of a note is mapped linearly to a list element.
The array type used for samples is a compact representation for numbers. "H" selects unsigned 16 bit integers which is the representation most closely matching the native DAC values - this helps with memory efficiency.
The NeoPixels are used to show the note playing with the noteLED()
function shown below.
# 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)
Modular division is used to map the note to 30 values, the first ten notes will be red starting at middle C (C4), the next ten green, the next ten blue and this then repeats both above and below that range. Multiple values will be merged, e.g. C4 and A#4 keys pressed together will show as yellow.
The NeoPixels have been created with brightness=1.0
. For the current library implementation this makes updates faster and uses less memory. This explains why the maximum value based on the note's velocity is set to just 32 and not the maximum value, 255.
The same noteLED()
is used in the MIDI controller. The code could be kept in a separate file and that could be import
'ed. This would make the code easier to maintain, promote reusability and is a step towards creating a library.
At the end of the main loop is the code that applies amplitude modulation to the speaker output (only).
if mod_wheel > 0: t1 = time.monotonic() * am_freq # Calculate a form of duty_cycle for enabling speaker for crude # amplitude modulation. Empirically the divisor needs to greater # than 127 as can't hear much when speaker is off more than half # 220 works reasonably well new_speaker_on = (t1 - int(t1)) > (mod_wheel / 220) else: new_speaker_on = True
Any MIDI control change values received for cc1 are stored in the mod_wheel
variable. For non-zero values this is used to control when the speaker is on or off. The time measurement is used to set a regular period of on and off with am_freq
set to a fixed value of 16 (Hz). The mod_wheel
value then determines the duration of the off part. In synthesis terminology, this would be referred to as a free-running, square wave LFO modulating the amplitude with maximum depth with the mod wheel determining the duty cycle.
External Audio
The CPX can be connected from touchpad A0 to an amplifier or headphones but cannot drive a loudspeaker without amplification. Using amplified speakers or a pair of headphones, connect your Circuit Playground Express as shown below. The speakers from Adafruit have a volume knob for easy adjustment. You can use a cell phone wall charger, computer, or cell phone external battery to power the speakers (and the Circuit Playground Express).



Page last edited January 21, 2025
Text editor powered by tinymce.