Text Editor
Adafruit recommends using the Mu editor for editing your CircuitPython code. You can get more info in this guide.
Alternatively, you can use any text editor that saves simple text files.
Download the Project Bundle
Your project will use a specific set of CircuitPython libraries, and the code.py file. To get everything you need, click on the Download Project Bundle link below, and uncompress the .zip file.
Connect your computer to the M7 board via a known good USB power+data cable. A new flash drive should show up as CIRCUITPY.
Drag the contents of the uncompressed bundle directory onto your board's CIRCUITPY drive, replacing any existing files or directories with the same names, and adding any new ones that are necessary.
# SPDX-FileCopyrightText: 2023 John Park, Jeff Epler, and Tod Kurt for Adafruit Industries
# SPDX-License-Identifier: MIT
# Computer Perfection Synth
# * 10 numbered buttons play notes
# * SET button to increase LFO rate, long press to decrease LFO rate
# * SCORE button to add lower octave
# * MODE switch changes wavetable set
# * SKILL switch toggles sustain
# * GAME switch must stay in position 1 or it messes with the other switches
import time
import random
import board
import audiobusio
import audiomixer
import synthio
import ulab.numpy as np
import neopixel
import keypad
# NeoPixel setup
num_pixels = 34
pixels = neopixel.NeoPixel(board.D11, num_pixels, brightness=0.7, auto_write=False)
pixels.fill(0x0)
pixels.show()
time.sleep(0.25)
pix_map = [26, 23, 19, 16, 13, 10, 7, 4, 32, 29] # map the LEDs to the numbered panel sections 0-9
for p in range(len(pix_map)):
pixels[pix_map[p]] = 0xff0000
pixels.show()
time.sleep(0.1)
note_buttons = keypad.Keys(
(board.D0, board.D1, board.D2, board.D3, board.D4,
board.D5, board.D6, board.D7, board.D8, board.A5),
value_when_pressed=False,
pull=True
)
switches = keypad.Keys(
(board.A1, board.A0),
value_when_pressed=False,
pull=True
)
octave = 3 # octave multiplier
note_list = (0, 4, 6, 7, 9, 12, 16, 18, 19, 21) # Lydian scale
mod_buttons = keypad.Keys(
(board.A4, board.A3), # SET and SCORE buttons
value_when_pressed=False,
pull=True
)
SAMPLE_RATE = 48000 # clicks @ 36kHz & 48kHz on rp2040
SAMPLE_SIZE = 200
VOLUME = 12000
# Metro M7 pins for the I2S amp:
lck_pin, bck_pin, dat_pin = board.D9, board.D10, board.D12
# synth engine setup
waveform = np.zeros(SAMPLE_SIZE, dtype=np.int16) # intially all zeros (silence)
amp_env = synthio.Envelope( # default (0.1, 0.05, 0.2, 1, 0.8)
attack_time=1.0,
decay_time=0.05,
release_time=3.0,
attack_level=1.0,
sustain_level=0.8
)
synth = synthio.Synthesizer(sample_rate=SAMPLE_RATE, waveform=waveform, envelope=amp_env)
audio = audiobusio.I2SOut(bit_clock=bck_pin, word_select=lck_pin, data=dat_pin)
mixer = audiomixer.Mixer(voice_count=1, sample_rate=SAMPLE_RATE, channel_count=1,
bits_per_sample=16, samples_signed=True, buffer_size=8192)
audio.play(mixer)
mixer.voice[0].level = 0.55
mixer.voice[0].play(synth)
led = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.3) # on board neopixel
# waveforms setup
wave_sine = np.array(np.sin(np.linspace(0, 2*np.pi, SAMPLE_SIZE, endpoint=False)) * VOLUME,
dtype=np.int16)
wave_saw = np.linspace(VOLUME, -VOLUME, num=SAMPLE_SIZE, dtype=np.int16)
wave_weird1 = np.array((198,2776,5441,8031,10454,12653,14609,16333,17824,19130,20260,21227,22043,
22721,23269,23699,24019,24243,24385,24461,18630,-26956,-28048,-29175,-30249,
-31227,-32073,-32631,-32359,-31817,-30941,-29663,-27900,-25596,-22591,
-18834,-14291,-9016,-3212,2794,8624,13943,18544,22353,25408,27780,29553,
30855,31751,32315,32611,32687,32593,32351,31983,31491,30871,30097,28895,
-28240,-30489,-31343,-31975,-32431,-32697,-32767,-32615,-32217,-31525,
-30489,-29035,-27090,-24519,-21237,-17178,-12339,-6829,-902,5081,10748,
15805,20102,23615,26396,28510,30109,31245,31995,31955,31437,30729,29887,
28943,27908,26784,25560,24077,22781,-22207,-22735,-22709,-22471,-22065,
-21497,-20773,-19896,-18872,-17698,-16361,-14857,-13141,-11206,-9054,-6717,
-4259,-1796,522,2548,4167,5339,6079,6445,6503,6319,5949,5449,4847,4183,
3480,2756,2028,1304,590,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-478,-1168,-1882,-2596,
-3336,-4074,-4795,-5487,-6119,-6669,-7095,-7357,-7399,-7157,-6559,-5543,
-4076,-2132,), dtype=np.int16)
wave_noise = np.array([random.randint(-VOLUME, VOLUME) for i in range(SAMPLE_SIZE)], dtype=np.int16)
# map s range a1-a2 to b1-b2
def map_range(s, a1, a2, b1, b2):
return b1 + ((s - a1) * (b2 - b1) / (a2 - a1))
# mix between values a and b, works with numpy arrays too, t ranges 0-1
def lerp(a, b, t):
return (1-t)*a + t*b
waveform[:] = wave_saw
wave_mix = 0.0
lfo_rates = (0.1, 0.5, 0.8, 1.5, 3.0, 6.0, 7.0, 8.0)
lfo_index = 0
lfo1 = synthio.LFO(rate=(lfo_rates[lfo_index]), waveform=wave_sine) # rate is in Hz
synth.lfos.append(lfo1)
hold = False # state of note hold
octaves = False
def light_button_pixels(button_number):
pixels[pix_map[button_number]+1] = 0xFF0000
pixels[pix_map[button_number]-1] = 0xFF0000
pixels.show()
def reset_button_pixels(button_number):
pixels[pix_map[button_number]+1] = 0x000000
pixels[pix_map[button_number]-1] = 0x000000
pixels.show()
def clamp(v, low, high):
return min(max(v, low), high)
print("-Computer Perfection Synth-")
note = None
mod_key = 0
last_mod_button_event_time = 0
waveset = 0
while True:
# watch for mod buttons to be pressed
mod_button_event = mod_buttons.events.get()
if mod_button_event:
mod_key = mod_button_event.key_number
if mod_button_event.pressed:
if mod_key == 0: # SET switch
last_mod_button_event_time = time.monotonic()
if mod_key == 1: # enable octaves
octaves = True
if mod_button_event.released:
if last_mod_button_event_time and mod_key == 0: # short press-release increase LFO rate
lfo_index = clamp(lfo_index+1, 0, len(lfo_rates)-1)
print(lfo_index)
lfo_rate = lfo_rates[lfo_index]
lfo1.rate = lfo_rate
last_mod_button_event_time = 0
if mod_key == 1: # disable octaves
octaves = False
# long press slows the LFO rate
if last_mod_button_event_time != 0 and time.monotonic() - last_mod_button_event_time > 1.0:
last_mod_button_event_time = 0
lfo_index = clamp(lfo_index-1, 0, len(lfo_rates)-1)
lfo_rate = lfo_rates[lfo_index]
lfo1.rate = lfo_rate
# watch for note buttons to be pressed
note_button_event = note_buttons.events.get()
if note_button_event:
i = note_button_event.key_number
if note_button_event.pressed:
if octaves:
synth.press((note_list[i]+(octave*12), note_list[i]+(octave*12)-12))
else:
synth.press((note_list[i]+(octave*12),))
light_button_pixels(i)
if note_button_event.released:
if not hold:
reset_button_pixels(i)
synth.release((note_list[i]+(octave*12), note_list[i]+(octave*12)-12))
reset_button_pixels(i)
# watch for switches to be changed
switch_event = switches.events.get()
if switch_event:
sw = switch_event.key_number
if switch_event.pressed:
if sw == 0: # MODE toggle right
mixer.voice[0].level = 0.45
# wave_mix = 0.5
waveset = 0
if sw == 1: # SKILL toggle center
hold = True
if switch_event.released:
if sw == 0: # MODE toggle center
mixer.voice[0].level = 0.95
waveset = 1
if sw == 1: # SKILL toggle right or left
hold = False
for r in range(len(note_list)): # turn off all notes
# if octaves:
synth.release((note_list[r]+(octave*12), note_list[r]+(octave*12)-12))
for h in range(len(pix_map)): # turn off held pixels
reset_button_pixels(h)
lfo_val_for_lerp = map_range(lfo1.value, -1, 1, 0, 1)
if waveset == 0:
waveform[:] = lerp(wave_sine, wave_weird1, lfo_val_for_lerp)
else:
waveform[:] = lerp(wave_saw, wave_noise, lfo_val_for_lerp)
import time import random import board import audiobusio import audiomixer import synthio import ulab.numpy as np import neopixel import keypad
NeoPixels
Next we initialize the NeoPixel strip with 34 pixels connected to pin D11 on the board.
We also set up a list of the physical pixels that correspond to the game panel's 0-9 locations.
# NeoPixel setup
num_pixels = 34
pixels = neopixel.NeoPixel(board.D11, num_pixels, brightness=0.7, auto_write=False)
pixels.fill(0x0)
pixels.show()
time.sleep(0.25)
pix_map = [26, 23, 19, 16, 13, 10, 7, 4, 32, 29] # map the LEDs to the numbered panel sections 0-9
for p in range(len(pix_map)):
pixels[pix_map[p]] = 0xff0000
pixels.show()
time.sleep(0.1)
Keypad
We'll use the keypad library to read the ten note buttons, two modifier buttons, and two switches.
note_buttons = keypad.Keys(
(board.D0, board.D1, board.D2, board.D3, board.D4,
board.D5, board.D6, board.D7, board.D8, board.A5),
value_when_pressed=False,
pull=True
)
switches = keypad.Keys(
(board.A1, board.A0),
value_when_pressed=False,
pull=True
)
mod_buttons = keypad.Keys(
(board.A4, board.A3), # SET and SCORE buttons
value_when_pressed=False,
pull=True
)
Notes
The synthio library can use MIDI note numbers or frequency to specify a note to play. In this project we'll use MIDI note numbers as they're easier to adjust for interval/scale choices.
This note_list contains the ten notes we'll play, and the octave variable specifies which octave to play them in.
octave = 3 # octave multiplier note_list = (0, 4, 6, 7, 9, 12, 16, 18, 19, 21) # Lydian scale
lck_pin, bck_pin, dat_pin = board.D9, board.D10, board.D12
Synth Setup
The synthio object is set up with the sample rate, sample size, volume, initial waveform, and audiobus/audiomixer objects.
SAMPLE_RATE = 48000 # clicks @ 36kHz & 48kHz on rp2040
SAMPLE_SIZE = 200
VOLUME = 12000
# synth engine setup
waveform = np.zeros(SAMPLE_SIZE, dtype=np.int16) # intially all zeros (silence)
amp_env = synthio.Envelope( # default (0.1, 0.05, 0.2, 1, 0.8)
attack_time=1.0,
decay_time=0.05,
release_time=3.0,
attack_level=1.0,
sustain_level=0.8
)
synth = synthio.Synthesizer(sample_rate=SAMPLE_RATE, waveform=waveform, envelope=amp_env)
audio = audiobusio.I2SOut(bit_clock=bck_pin, word_select=lck_pin, data=dat_pin)
mixer = audiomixer.Mixer(voice_count=1, sample_rate=SAMPLE_RATE, channel_count=1,
bits_per_sample=16, samples_signed=True, buffer_size=8192)
audio.play(mixer)
mixer.voice[0].level = 0.55
mixer.voice[0].play(synth)
Waveforms
Different waveforms have different harmonics, which is what provides the "character" or timbre of an instrument. We'll use sine, saw, and the weird1 wavetable, and noise waveforms, which can be mixed between using the LFO modulator.
All of these waveforms are defined mathematically, except for the weird1 which is defined with an array of discreet points.
# waveforms setup
wave_sine = np.array(np.sin(np.linspace(0, 2*np.pi, SAMPLE_SIZE, endpoint=False)) * VOLUME,
dtype=np.int16)
wave_saw = np.linspace(VOLUME, -VOLUME, num=SAMPLE_SIZE, dtype=np.int16)
wave_weird1 = np.array((198,2776,5441,8031,10454,12653,14609,16333,17824,19130,20260,21227,22043,
22721,23269,23699,24019,24243,24385,24461,18630,-26956,-28048,-29175,-30249,
-31227,-32073,-32631,-32359,-31817,-30941,-29663,-27900,-25596,-22591,
-18834,-14291,-9016,-3212,2794,8624,13943,18544,22353,25408,27780,29553,
30855,31751,32315,32611,32687,32593,32351,31983,31491,30871,30097,28895,
-28240,-30489,-31343,-31975,-32431,-32697,-32767,-32615,-32217,-31525,
-30489,-29035,-27090,-24519,-21237,-17178,-12339,-6829,-902,5081,10748,
15805,20102,23615,26396,28510,30109,31245,31995,31955,31437,30729,29887,
28943,27908,26784,25560,24077,22781,-22207,-22735,-22709,-22471,-22065,
-21497,-20773,-19896,-18872,-17698,-16361,-14857,-13141,-11206,-9054,-6717,
-4259,-1796,522,2548,4167,5339,6079,6445,6503,6319,5949,5449,4847,4183,
3480,2756,2028,1304,590,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,-478,-1168,-1882,-2596,
-3336,-4074,-4795,-5487,-6119,-6669,-7095,-7357,-7399,-7157,-6559,-5543,
-4076,-2132,), dtype=np.int16)
wave_noise = np.array([random.randint(-VOLUME, VOLUME) for i in range(SAMPLE_SIZE)], dtype=np.int16)
waveform[:] = wave_saw
wave_mix = 0.0
Low Frequency Oscillator
A low frequency oscillator, or LFO, is a waveform with a frequency below the audible threshold. LFOs are often used to modulate other synthesizer parameters, such as the pitch or amplitude of an audio oscillator's waveform.
We'll run an LFO at different rates by pressing the SET button to cycle among them in the lfo_rates list.
The lfo1 object is created with a rate and waveshape, in this case a sine, but this could be any shape you like, such as a triangle or saw.
lfo_rates = (0.1, 0.5, 0.8, 1.5, 3.0, 6.0, 7.0, 8.0) lfo_index = 0 lfo1 = synthio.LFO(rate=(lfo_rates[lfo_index]), waveform=wave_sine) # rate is in Hz synth.lfos.append(lfo1)
Helper Functions
We create a number of helper functions to control NeoPixels when buttons are pressed, remap and clamp values, and provide linear interpolation (lerp) between values.
# map s range a1-a2 to b1-b2
def map_range(s, a1, a2, b1, b2):
return b1 + ((s - a1) * (b2 - b1) / (a2 - a1))
# mix between values a and b, works with numpy arrays too, t ranges 0-1
def lerp(a, b, t):
return (1-t)*a + t*b
def light_button_pixels(button_number):
pixels[pix_map[button_number]+1] = 0xFF0000
pixels[pix_map[button_number]-1] = 0xFF0000
pixels.show()
def reset_button_pixels(button_number):
pixels[pix_map[button_number]+1] = 0x000000
pixels[pix_map[button_number]-1] = 0x000000
pixels.show()
def clamp(v, low, high):
return min(max(v, low), high)
Main Loop
The main loop of the program checks for button presses and switch events.
When a note button is pressed a corresponding synth note is played (and the NeoPixels surrounding the button position light up). You'll notice the notes have a short attack and a long release, thanks to the ADSR envelope values we created initially.
If the SCORE button is held, the note buttons will also play a unison note one octave down.
Pressing the SET button increments the LFO rate, while a long press decrements it.
Flipping the MODE switch changes the waveform pair that's mixed between.
When the SKILL switch is in the center position, all pressed notes will sustain indefinitely -- perfect for nice drone chords! In the non-center positions the notes will release when the note buttons are released.
# watch for mod buttons to be pressed
mod_button_event = mod_buttons.events.get()
if mod_button_event:
mod_key = mod_button_event.key_number
if mod_button_event.pressed:
if mod_key == 0: # SET switch
last_mod_button_event_time = time.monotonic()
if mod_key == 1: # enable octaves
octaves = True
if mod_button_event.released:
if last_mod_button_event_time and mod_key == 0: # short press-release increase LFO rate
lfo_index = clamp(lfo_index+1, 0, len(lfo_rates)-1)
print(lfo_index)
lfo_rate = lfo_rates[lfo_index]
lfo1.rate = lfo_rate
last_mod_button_event_time = 0
if mod_key == 1: # disable octaves
octaves = False
# long press slows the LFO rate
if last_mod_button_event_time != 0 and time.monotonic() - last_mod_button_event_time > 1.0:
last_mod_button_event_time = 0
lfo_index = clamp(lfo_index-1, 0, len(lfo_rates)-1)
lfo_rate = lfo_rates[lfo_index]
lfo1.rate = lfo_rate
# watch for note buttons to be pressed
note_button_event = note_buttons.events.get()
if note_button_event:
i = note_button_event.key_number
if note_button_event.pressed:
if octaves:
synth.press((note_list[i]+(octave*12), note_list[i]+(octave*12)-12))
else:
synth.press((note_list[i]+(octave*12),))
light_button_pixels(i)
if note_button_event.released:
if not hold:
reset_button_pixels(i)
synth.release((note_list[i]+(octave*12), note_list[i]+(octave*12)-12))
reset_button_pixels(i)
# watch for switches to be changed
switch_event = switches.events.get()
if switch_event:
sw = switch_event.key_number
if switch_event.pressed:
if sw == 0: # MODE toggle right
mixer.voice[0].level = 0.45
# wave_mix = 0.5
waveset = 0
if sw == 1: # SKILL toggle center
hold = True
if switch_event.released:
if sw == 0: # MODE toggle center
mixer.voice[0].level = 0.95
waveset = 1
if sw == 1: # SKILL toggle right or left
hold = False
for r in range(len(note_list)): # turn off all notes
# if octaves:
synth.release((note_list[r]+(octave*12), note_list[r]+(octave*12)-12))
for h in range(len(pix_map)): # turn off held pixels
reset_button_pixels(h)
lfo_val_for_lerp = map_range(lfo1.value, -1, 1, 0, 1)
if waveset == 0:
waveform[:] = lerp(wave_sine, wave_weird1, lfo_val_for_lerp)
else:
waveform[:] = lerp(wave_saw, wave_noise, lfo_val_for_lerp)
Page last edited January 21, 2025
Text editor powered by tinymce.