Basic Synthesizer

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.

### 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 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((audioio.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.

Download: file
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:

Download: file
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.

Download: file
# 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).

Download: file
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).

USB Powered Speakers

PRODUCT ID: 1363
Add some extra boom to your audio project with these powered loudspeakers. We sampled half a dozen different models to find ones with a good frequency response, so you'll get...
OUT OF STOCK

Small Alligator Clip Test Lead (set of 6)

PRODUCT ID: 4100
Connect this to that without soldering using these small alligator clip test leads. 18" long cables with color-coded alligator clips on both ends. You get 6 pieces in 6...
$2.95
IN STOCK

USB Battery Pack for Raspberry Pi - 10000mAh - 2 x 5V outputs

PRODUCT ID: 1566
A large-sized rechargeable battery pack for your Raspberry Pi (or Arduino, or
$39.95
IN STOCK
This guide was first published on May 14, 2019. It was last updated on May 14, 2019. This page (Basic Synthesizer) was last updated on Nov 21, 2019.