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)
Text editor powered by tinymce.