### synthio-patch-development.py v1.1 ### Synthio patch with increasing complexity playable via MIDI ### Tested with Pimoroni PGA2350 and 9.2.0-beta.1 ### copy this file to Pimoroni PGA2350 as code.py ### MIT License ### Copyright (c) 2024 Kevin J. Walters ### Permission is hereby granted, free of charge, to any person obtaining a copy ### of this software nd 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 math import os import random import time import board import audiomixer import synthio import audiopwmio import ulab.numpy as np import usb_midi import adafruit_midi 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 from adafruit_midi.channel_pressure import ChannelPressure from adafruit_midi.program_change import ProgramChange from adafruit_midi import control_change_values debug = 2 if os.uname().machine.find("EDU PICO"): LEFT_AUDIO_PIN = board.GP20 RIGHT_AUDIO_PIN = board.GP21 elif os.uname().sysname == "rp2040": ### Cytron Maker Pi Pico LEFT_AUDIO_PIN = board.GP18 RIGHT_AUDIO_PIN = board.GP19 else: ### Custom RP2350B board LEFT_AUDIO_PIN = board.GP36 RIGHT_AUDIO_PIN = board.GP37 MIDI_KEY_CHANNEL = 1 MIDI_PAD_CHANNEL = 10 ### Wire protocol values MIDI_KEY_CHANNEL_WIRE = MIDI_KEY_CHANNEL - 1 MIDI_PAD_CHANNEL_WIRE = MIDI_PAD_CHANNEL - 1 SAMPLE_RATE = 64_000 MIXER_BUFFER_SIZE = 2048 ### TODO - how to choose this value? WAVEFORM_PEAK = 28_000 WAVEFORM_MAX = 2**15 - 1 WAVEFORM_LEN = 8 WAVEFORM_HALFLEN = WAVEFORM_LEN // 2 PRODUCT = synthio.MathOperation.PRODUCT SUM = synthio.MathOperation.SUM midi_usb = adafruit_midi.MIDI(midi_in=usb_midi.ports[0], in_channel=(MIDI_KEY_CHANNEL_WIRE, MIDI_PAD_CHANNEL_WIRE)) audio = audiopwmio.PWMAudioOut(LEFT_AUDIO_PIN, right_channel=RIGHT_AUDIO_PIN) mixer = audiomixer.Mixer(channel_count=1, sample_rate=SAMPLE_RATE, buffer_size=MIXER_BUFFER_SIZE) synth = synthio.Synthesizer(channel_count=1, sample_rate=SAMPLE_RATE) audio.play(mixer) mixer.voice[0].play(synth) mixer.voice[0].level = 0.75 def d_print(level, *args, **kwargs): """A simple conditional print for debugging based on global debug level.""" if not isinstance(level, int): print(level, *args, **kwargs) elif debug >= level: print(*args, **kwargs) patches = (("One osc",), ### 0 ("Two osc",), ### 1 ("Three osc",), ### 2 ("Three osc ADSR",), ### 3 ("Three osc ADSR LPF",), ### 4 ("Three osc ADSR LPF velocity ",), ### 5 ("Three osc ADSR LPF velocity pitch-bend vibrato",), ### 6 ("Three osc ADSR LPF velocity pitch-bend aftertouch-vibrato",), ### 7 ("Three osc ADSR LPF velocity pitch-bend aftertouch-vibrato pitch-slides")) ### 8 note_to_button = {36: 1, 38: 2, 42: 3, 46: 4, 50: 5, 45: 6, 51: 7, 49: 8} ## oscs_per_note = 3 # how many oscillators for each note oscs_per_note = 2 # TODO - clean this all up for 2 osc osc_detune = 0.001 # how much to detune oscillators for phatness filter_freq_lo = 100 # filter lowest freq filter_freq_hi = 4500 # filter highest freq filter_note_offset_low = -12 filter_note_offset_high = 60 + 1 filter_res_lo = 0.1 # filter q lowest value filter_res_hi = 2.0 # filter q highest value vibrato_note_lfo_hi = 0.5/12 # vibrato amount when modwheel is maxxed out vibrato_note_rate = 5 # vibrato frequency amp_env_attack_time_lo = 0.080 amp_env_attack_time_hi = 2.000 amp_env_attack_level_lo = 0.85 amp_env_attack_level_hi = 1.0 waveform_saw = np.linspace(WAVEFORM_PEAK, 0 - WAVEFORM_PEAK, num=WAVEFORM_LEN, dtype=np.int16) lfo_note_vibrato = synthio.LFO(rate=vibrato_note_rate, scale=vibrato_note_lfo_hi) lfo_slowstart_note_vibrato = synthio.LFO(waveform=np.array([0, 0, 4096, 8192, 16384, 24576, 32767], dtype=np.int16), interpolate=True, once=True, rate=1/1) lfo_pitch_slide = synthio.LFO(waveform=np.array([0, 32767], dtype=np.int16), interpolate=True, once=True, scale=0, rate=0) ### The value is set in property "a" pitch_bend_control_math = synthio.Math(SUM, 0.0, 0.0, 0.0) aftertouch_math = synthio.Math(SUM, 0.0, 0.0, 0.0) modwheel_math = synthio.Math(SUM, 0.0, 0.0, 0.0) pressed = [] ### pressed keys oscs = [] ### holds currently sounding oscillators filter_freq = 2000 # current setting of filter filter_note_offset = 37 filter_res = 1.0 # current setting of filter amp_env_attack_time = 0.300 amp_env_decay_time = 0.100 amp_env_sustain = 0.8 amp_env_release_time = 1.100 last_note = None last_portamento_note = None osc_note = None osc_target_note = None current_patch = 0 ### Simple range mapper, like Arduino map() def map_range(s, a1, a2, b1, b2): return b1 + ((s - a1) * (b2 - b1) / (a2 - a1)) ### pylint: disable=consider-using-in,too-many-branches def note_on(notenum, vel, patch_no): new_osc = [] level = 0.65 if patch_no >=3 else 0.50 f1 = synthio.midi_to_hz(notenum + random.uniform(-0.003, 0.003)) new_osc.append(synthio.Note(frequency=f1, waveform=waveform_saw, amplitude=level)) if patch_no >= 1: ### Detune of 0.03 (3 cents) f2 = synthio.midi_to_hz(notenum + random.uniform(-0.003, 0.003) + random.uniform(0.034, 0.036)) new_osc.append(synthio.Note(frequency=f2, waveform=waveform_saw, amplitude=level * 0.9)) if patch_no >= 2: ### Detune of -0.045 (-4.5 cents) f3 = synthio.midi_to_hz(notenum + random.uniform(-0.003, 0.003) + random.uniform(-0.044, -0.046)) new_osc.append(synthio.Note(frequency=f3, waveform=waveform_saw, amplitude=level * 0.9)) amp_env = None if patch_no == 3 or patch_no == 4: amp_env = synthio.Envelope(attack_time=amp_env_attack_time, decay_time=amp_env_decay_time, sustain_level=amp_env_sustain, release_time=amp_env_release_time) elif patch_no >= 5: amp_level = map_range(vel, 0, 127, amp_env_attack_level_lo, amp_env_attack_level_hi) attack_time = map_range(math.sqrt(vel), 0, math.sqrt(127), amp_env_attack_time_hi, amp_env_attack_time_lo) amp_env = synthio.Envelope(attack_time=attack_time, attack_level=amp_level, decay_time=amp_env_decay_time, sustain_level=amp_level*amp_env_sustain, release_time=amp_env_release_time) if amp_env: for osc in new_osc: osc.envelope = amp_env if patch_no >= 4: lpf = synth.low_pass_filter(synthio.midi_to_hz(notenum + filter_note_offset), filter_res) for osc in new_osc: osc.filter = lpf pitch_bend = None if patch_no == 5: ### Key-triggered vibrato with delay pitch_bend = synthio.Math(PRODUCT, lfo_slowstart_note_vibrato, lfo_note_vibrato) elif patch_no == 6: ### Vibrato via mod wheel and after touch with pitch bend pitch_bend = synthio.Math(SUM, synthio.Math(PRODUCT, lfo_slowstart_note_vibrato, modwheel_math, lfo_note_vibrato), pitch_bend_control_math, 0.0) elif patch_no >= 7: ### Vibrato via mod wheel and after touch with pitch bend ### and pitch slide using Axiom pad buttons pitch_bend = synthio.Math(SUM, synthio.Math(PRODUCT, synthio.Math(SUM, synthio.Math(PRODUCT, aftertouch_math, 0.8), modwheel_math, 0.0), lfo_note_vibrato), pitch_bend_control_math, lfo_pitch_slide if patch_no >= 8 else 0.0) if pitch_bend: for osc in new_osc: osc.bend = pitch_bend oscs.clear() pressed.clear() lfo_slowstart_note_vibrato.retrigger() ### Add oscillattors to list and presss the 'note' (a collection ### of oscs acting in concert) oscs.extend(new_osc) synth.press(oscs) pressed.append(notenum) def notes_off(): synth.release(oscs) oscs.clear() pressed.clear() start_ns = time.monotonic_ns() while True: msg = midi_usb.receive() if msg: if isinstance(msg, ProgramChange): d_print(2, "PC: ", msg.patch) if 0 <= msg.patch < len(patches): current_patch = msg.patch elif msg.channel == MIDI_KEY_CHANNEL_WIRE and isinstance(msg, NoteOn) and msg.velocity != 0: d_print(2, "Note:", msg.note, "vel={:d}".format(msg.velocity)) if last_note is not None: notes_off() note_on(msg.note, msg.velocity, current_patch) last_note = msg.note elif msg.channel == MIDI_KEY_CHANNEL_WIRE and (isinstance(msg, NoteOff) or isinstance(msg, NoteOn) and msg.velocity == 0): d_print(2, "Note:", msg.note, "vel={:d}".format(msg.velocity)) if msg.note in pressed: # only release note that's sounding notes_off() elif isinstance(msg, ControlChange): d_print(2, "CC:", msg.control, "=", msg.value) if msg.control == control_change_values.MOD_WHEEL: ### 1 mod wheel modwheel_math.a = msg.value / 127.0 elif msg.control == control_change_values.CUTOFF_FREQUENCY: ### 74 ##filter_freq = map_range( msg.value, 0,127, filter_freq_lo, filter_freq_hi) filter_note_offset = map_range(msg.value, 0, 127, filter_note_offset_low, filter_note_offset_high) elif msg.control == control_change_values.FILTER_RESONANCE: ### 71 filter_res = map_range(msg.value, 0, 127, filter_res_lo, filter_res_hi) elif msg.control == control_change_values.RELEASE_TIME: ### 72 amp_env_release_time = map_range(msg.value, 0, 127, 0.05, 3) elif isinstance(msg, PitchBend): d_print(2, "PB:", msg.pitch_bend) pitch_bend_control_math.a = (msg.pitch_bend - 8192) / 8192 elif isinstance(msg, ChannelPressure): d_print(2, "AT:", msg.pressure) aftertouch_math.a = msg.pressure / 127.0 elif msg.channel == MIDI_PAD_CHANNEL_WIRE: if isinstance(msg, NoteOn) and msg.velocity != 0: button_pad = note_to_button.get(msg.note) if button_pad is not None: lfo_pitch_slide.scale = -8 if button_pad <= 4 else 8 lfo_pitch_slide.rate = 0.0625 / ((button_pad - 1) % 4 + 1) lfo_pitch_slide.retrigger() elif isinstance(msg, NoteOff) or isinstance(msg, NoteOn) and msg.velocity == 0: lfo_pitch_slide.scale = 0.0 lfo_pitch_slide.rate = 0.0 else: d_print(1, "MIDI MSG:", msg)