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.
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. The CIRCUITPY drive appears when you plug the QT Py into the computer via USB.
# SPDX-FileCopyrightText: 2023 John Park & Tod Kurt # # SPDX-License-Identifier: MIT # Tyrell Synth Distopia # based on: # 19 Jun 2023 - @todbot / Tod Kurt # - A swirling ominous wub that evolves over time # - Made for QTPy RP2040 but will work on any synthio-capable board # - wallow in the sound # # Circuit: # - QT Py RP2040 # - QTPy TX/RX pins for audio out, going through RC filter (1k + 100nF) to TRS jack # Touch io for eight pins, pairs that -/+ tempo, transpose pitch, filter rate, volume # use >1MΩ resistors to pull down to ground # # Code: # - Five detuned oscillators are randomly detuned very second or so # - A low-pass filter is slowly modulated over the filters # - The filter modulation rate also changes randomly every second (also reflected on neopixel) # - Every x seconds a new note is randomly chosen from the allowed note list import time import random import board import audiopwmio import audiomixer import synthio import ulab.numpy as np import neopixel import rainbowio import touchio from adafruit_debouncer import Debouncer touch_pins = (board.A0, board.A1, board.A2, board.A3, board.SDA, board.SCL, board.MISO, board.MOSI) touchpads = [] for pin in touch_pins: tmp_pin = touchio.TouchIn(pin) touchpads.append(Debouncer(tmp_pin)) notes = (37, 38, 35, 49) # MIDI C#, D, B note_duration = 10 # how long each note plays for num_voices = 6 # how many voices for each note lpf_basef = 300 # low pass filter lowest frequency lpf_resonance = 1.7 # filter q led = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.1) # PWM pin pair on QTPY RP2040 audio = audiopwmio.PWMAudioOut(left_channel=board.TX, right_channel=board.RX) mixer = audiomixer.Mixer(channel_count=2, sample_rate=28000, buffer_size=2048) synth = synthio.Synthesizer(channel_count=2, sample_rate=28000) audio.play(mixer) mixer.voice[0].play(synth) mixer_vol = 0.5 mixer.voice[0].level = mixer_vol # oscillator waveform, a 512 sample downward saw wave going from +/-30k wave_saw = np.linspace(30000, -30000, num=512, dtype=np.int16) # max is +/-32k gives us headroom amp_env = synthio.Envelope(attack_level=1, sustain_level=1) # set up the voices (aka "Notes" in synthio-speak) w/ initial values voices = [] for i in range(num_voices): voices.append(synthio.Note(frequency=0, envelope=amp_env, waveform=wave_saw)) lfo_panning = synthio.LFO(rate=0.1, scale=0.75) # set all the voices to the "same" frequency (with random detuning) # zeroth voice is sub-oscillator, one-octave down def set_notes(n): for voice in voices: f = synthio.midi_to_hz(n + random.uniform(0, 0.4)) voice.frequency = f voice.panning = lfo_panning voices[0].frequency = voices[0].frequency/2 # bass note one octave down # the LFO that modulates the filter cutoff lfo_filtermod = synthio.LFO(rate=0.05, scale=2000, offset=2000) # we can't attach this directly to a filter input, so stash it in the blocks runner synth.blocks.append(lfo_filtermod) note = notes[0] last_note_time = time.monotonic() last_filtermod_time = time.monotonic() # start the voices playing set_notes(note) synth.press(voices) # user input variables note_offset = (0, 1, 3, 4, 5, 7) note_offset_index = 0 lfo_subdivision = 8 print("'Prepare to wallow.' \n- Major Jack Dongle") while True: for t in range(len(touchpads)): touchpads[t].update() if touchpads[t].rose: if t == 0: note_offset_index = (note_offset_index + 1) % (len(note_offset)) set_notes(note + note_offset[note_offset_index]) elif t == 1: note_offset_index = (note_offset_index - 1) % (len(note_offset)) set_notes(note + note_offset[note_offset_index]) elif t == 2: note_duration = note_duration + 1 elif t == 3: note_duration = abs(max((note_duration - 1), 1)) elif t == 4: lfo_subdivision = 20 elif t == 5: lfo_subdivision = 0.2 elif t == 6: # volume mixer_vol = max(mixer_vol - 0.05, 0.0) mixer.voice[0].level = mixer_vol elif t == 7: # volume mixer_vol = min(mixer_vol + 0.05, 1.0) mixer.voice[0].level = mixer_vol # continuosly update filter, no global filter, so update each voice's filter for v in voices: v.filter = synth.low_pass_filter(lpf_basef + lfo_filtermod.value, lpf_resonance) led.fill(rainbowio.colorwheel(lfo_filtermod.value/20)) # show filtermod moving if time.monotonic() - last_filtermod_time > 1: last_filtermod_time = time.monotonic() # randomly modulate the filter frequency ('rate' in synthio) to make more dynamic lfo_filtermod.rate = 0.01 + random.random() / lfo_subdivision if time.monotonic() - last_note_time > note_duration: last_note_time = time.monotonic() # pick new note, but not one we're currently playing note = random.choice([n for n in notes if n != note]) set_notes(note+note_offset[note_offset_index]) print("note", note, ["%3.2f" % v.frequency for v in voices])
A swirling ominous wub that evolves over time.
-Tod Kurt
How It Works
The code is an adaptation of Tod Kurt's excellent eighties_dystopia synth. Using the synthio library in CircuitPython, it plays a set of five detuned oscillators that are modulated by a low-pass filter, with the root note changing over a certain interval of time.
The version adapted for the Tyrell Desktop Synthesizer adds user input in the form of eight touch pads. These are used as four pairs of controls that decrement/increment four parameters:
- tempo
- root note transposition
- filter rate
- output volume
Libraries
First, we import the necessary libraries.
import time import random import board import audiopwmio import audiomixer import synthio import ulab.numpy as np import neopixel import rainbowio import touchio from adafruit_debouncer import Debouncer
Touch Pins
Next, we'll set up the eight touch pins using the debouncer.
touch_pins = (board.A0, board.A1, board.A2, board.A3, board.SDA, board.SCL, board.MISO, board.MOSI) touchpads = [] for pin in touch_pins: tmp_pin = touchio.TouchIn(pin) touchpads.append(Debouncer(tmp_pin))
Synth Variables
We'll create variables to use in the synthesizer object to define a list of notes, the initial note duration in seconds, the number of synth voices, the low pass filter (LPF) frequency, and the LPF resonance.
notes = (37, 38, 35, 49) # MIDI C#, D, B note_duration = 10 # how long each note plays for num_voices = 6 # how many voices for each note lpf_basef = 300 # low pass filter lowest frequency lpf_resonance = 1.7 # filter q
Audio and Synth
We'll create the stereo PWM audio object with mixer, and the synthio synth object, then set the mixer playing with a volume of 0.5 (of a possible 1.0).
audio = audiopwmio.PWMAudioOut(left_channel=board.TX, right_channel=board.RX) mixer = audiomixer.Mixer(channel_count=2, sample_rate=28000, buffer_size=2048) synth = synthio.Synthesizer(channel_count=2, sample_rate=28000) audio.play(mixer) mixer.voice[0].play(synth) mixer_vol = 0.5 mixer.voice[0].level = mixer_vol
Additional Synth Setup
The audio rate oscillator waveform is a saw wave with a soft attack. These are stacked up in the voices[] list. Then, a synthio low frequency oscillator (LFO) object is created that will be used to modulate the left-right stereo panning.
# oscillator waveform, a 512 sample downward saw wave going from +/-30k wave_saw = np.linspace(30000, -30000, num=512, dtype=np.int16) # max is +/-32k gives us headroom amp_env = synthio.Envelope(attack_level=1, sustain_level=1) # set up the voices (aka "Notes" in synthio-speak) w/ initial values voices = [] for i in range(num_voices): voices.append(synthio.Note(frequency=0, envelope=amp_env, waveform=wave_saw)) lfo_panning = synthio.LFO(rate=0.1, scale=0.75)
set_note() Function
The set_note() function will be sued to create the set of multiple voices any time the note changes, including the translation of MIDI note value notation to Hertz frequency and stereo panning to create some movement between channels.
# set all the voices to the "same" frequency (with random detuning) # zeroth voice is sub-oscillator, one-octave down def set_notes(n): for voice in voices: f = synthio.midi_to_hz(n + random.uniform(0, 0.4)) voice.frequency = f voice.panning = lfo_panning voices[0].frequency = voices[0].frequency/2 # bass note one octave down
Filter LFO
To automate the modulation of the filter cutoff frequency, another LFO is created and attached to the synth.blocks
object.
# the LFO that modulates the filter cutoff lfo_filtermod = synthio.LFO(rate=0.05, scale=2000, offset=2000) # we can't attach this directly to a filter input, so stash it in the blocks runner synth.blocks.append(lfo_filtermod)
Final Setup Steps
State variables are created for the current note, last note time, and last filter modulation time, then the voices are set playing.
note = notes[0] last_note_time = time.monotonic() last_filtermod_time = time.monotonic() # start the voices playing set_notes(note) synth.press(voices) # user input variables note_offset = (0, 1, 3, 4, 5, 7) note_offset_index = 0 lfo_subdivision = 8
Main Loop
In the main loop of the program, first the touch pads are checked to see if any have been pressed. They then increment or decrement their associated parameters.
while True: for t in range(len(touchpads)): touchpads[t].update() if touchpads[t].rose: if t == 0: note_offset_index = (note_offset_index + 1) % (len(note_offset)) set_notes(note + note_offset[note_offset_index]) elif t == 1: note_offset_index = (note_offset_index - 1) % (len(note_offset)) set_notes(note + note_offset[note_offset_index]) elif t == 2: note_duration = note_duration + 1 elif t == 3: note_duration = abs(max((note_duration - 1), 1)) elif t == 4: lfo_subdivision = 20 elif t == 5: lfo_subdivision = 0.2 elif t == 6: # volume mixer_vol = max(mixer_vol - 0.05, 0.0) mixer.voice[0].level = mixer_vol elif t == 7: # volume mixer_vol = min(mixer_vol + 0.05, 1.0) mixer.voice[0].level = mixer_vol
Filter Sweep
The LFO that modulates the filter cutoff is updated.
# continuosly update filter, no global filter, so update each voice's filter for v in voices: v.filter = synth.low_pass_filter(lpf_basef + lfo_filtermod.value, lpf_resonance) led.fill(rainbowio.colorwheel(lfo_filtermod.value/20)) # show filtermod moving if time.monotonic() - last_filtermod_time > 1: last_filtermod_time = time.monotonic() # randomly modulate the filter frequency ('rate' in synthio) to make more dynamic lfo_filtermod.rate = 0.01 + random.random() / lfo_subdivision
Note Change
The melodic line changes to a random index selected from the note list.
# continuosly update filter, no global filter, so update each voice's filter for v in voices: v.filter = synth.low_pass_filter(lpf_basef + lfo_filtermod.value, lpf_resonance) led.fill(rainbowio.colorwheel(lfo_filtermod.value/20)) # show filtermod moving if time.monotonic() - last_filtermod_time > 1: last_filtermod_time = time.monotonic() # randomly modulate the filter frequency ('rate' in synthio) to make more dynamic lfo_filtermod.rate = 0.01 + random.random() / lfo_subdivision
Text editor powered by tinymce.