|
| 1 | +### cpx-basic-synth v1.4 |
| 2 | +### CircuitPython (on CPX) synth module using internal speaker |
| 3 | +### Velocity sensitive monophonic synth |
| 4 | +### with crude amplitude modulation (cc1) and choppy pitch bend |
| 5 | + |
| 6 | +### Tested with CPX and CircuitPython and 4.0.0-beta.7 |
| 7 | + |
| 8 | +### Needs recent adafruit_midi module |
| 9 | + |
| 10 | +### copy this file to CPX as code.py |
| 11 | + |
| 12 | +### MIT License |
| 13 | + |
| 14 | +### Copyright (c) 2019 Kevin J. Walters |
| 15 | + |
| 16 | +### Permission is hereby granted, free of charge, to any person obtaining a copy |
| 17 | +### of this software and associated documentation files (the "Software"), to deal |
| 18 | +### in the Software without restriction, including without limitation the rights |
| 19 | +### to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 20 | +### copies of the Software, and to permit persons to whom the Software is |
| 21 | +### furnished to do so, subject to the following conditions: |
| 22 | + |
| 23 | +### The above copyright notice and this permission notice shall be included in all |
| 24 | +### copies or substantial portions of the Software. |
| 25 | + |
| 26 | +### THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 27 | +### IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 28 | +### FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 29 | +### AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 30 | +### LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 31 | +### OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 32 | +### SOFTWARE. |
| 33 | + |
| 34 | +import array |
| 35 | +import time |
| 36 | +import math |
| 37 | + |
| 38 | +import digitalio |
| 39 | +import audioio |
| 40 | +import board |
| 41 | +import usb_midi |
| 42 | +import neopixel |
| 43 | + |
| 44 | +import adafruit_midi |
| 45 | + |
| 46 | +from adafruit_midi.midi_message import note_parser |
| 47 | + |
| 48 | +from adafruit_midi.note_on import NoteOn |
| 49 | +from adafruit_midi.note_off import NoteOff |
| 50 | +from adafruit_midi.control_change import ControlChange |
| 51 | +from adafruit_midi.pitch_bend import PitchBend |
| 52 | + |
| 53 | +# Turn the speaker on |
| 54 | +speaker_enable = digitalio.DigitalInOut(board.SPEAKER_ENABLE) |
| 55 | +speaker_enable.direction = digitalio.Direction.OUTPUT |
| 56 | +speaker_on = True |
| 57 | +speaker_enable.value = speaker_on |
| 58 | + |
| 59 | +dac = audioio.AudioOut(board.SPEAKER) |
| 60 | + |
| 61 | +# 440Hz is the standard frequency for A4 (A above middle C) |
| 62 | +# MIDI defines middle C as 60 and modulation wheel is cc 1 by convention |
| 63 | +A4refhz = const(440) |
| 64 | +midi_note_C4 = note_parser("C4") |
| 65 | +midi_note_A4 = note_parser("A4") |
| 66 | +midi_cc_modwheel = const(1) |
| 67 | +twopi = 2 * math.pi |
| 68 | + |
| 69 | +# A length of 12 will make the sawtooth rather steppy |
| 70 | +sample_len = 12 |
| 71 | +base_sample_rate = A4refhz * sample_len |
| 72 | +max_sample_rate = 350000 # a CPX / M0 DAC limitation |
| 73 | + |
| 74 | +midpoint = 32768 |
| 75 | + |
| 76 | +# A sawtooth function like math.sin(angle) |
| 77 | +# 0 returns 1.0, pi returns 0.0, 2*pi returns -1.0 |
| 78 | +def sawtooth(angle): |
| 79 | + return 1.0 - angle % twopi / twopi * 2 |
| 80 | + |
| 81 | +# make a sawtooth wave between +/- each value in volumes |
| 82 | +# phase shifted so it starts and ends near midpoint |
| 83 | +# "H" arrays for RawSample looks more memory efficient |
| 84 | +# see https://forums.adafruit.com/viewtopic.php?f=60&t=150894 |
| 85 | +def waveform_sawtooth(length, waves, volumes): |
| 86 | + for vol in volumes: |
| 87 | + waveraw = array.array("H", |
| 88 | + [midpoint + |
| 89 | + round(vol * sawtooth((idx + 0.5) / length |
| 90 | + * twopi |
| 91 | + + math.pi)) |
| 92 | + for idx in list(range(length))]) |
| 93 | + waves.append((audioio.RawSample(waveraw), waveraw)) |
| 94 | + |
| 95 | +# Make some square waves of different volumes volumes, generated with |
| 96 | +# n=10;[round(math.sqrt(x)/n*32767*n/math.sqrt(n)) for x in range(1, n+1)] |
| 97 | +# square root is for mapping velocity to power rather than signal amplitude |
| 98 | +# n=15 throws MemoryError exceptions when a note is played :( |
| 99 | +waveform_by_vol = [] |
| 100 | +waveform_sawtooth(sample_len, |
| 101 | + waveform_by_vol, |
| 102 | + [10362, 14654, 17947, 20724, 23170, |
| 103 | + 25381, 27415, 29308, 31086, 32767]) |
| 104 | + |
| 105 | +# brightness 1.0 saves memory by removing need for a second buffer |
| 106 | +# 10 is number of NeoPixels on CPX |
| 107 | +numpixels = const(10) |
| 108 | +pixels = neopixel.NeoPixel(board.NEOPIXEL, numpixels, brightness=1.0) |
| 109 | + |
| 110 | +# Turn NeoPixel on to represent a note using RGB x 10 |
| 111 | +# to represent 30 notes - doesn't do anything with pitch bend |
| 112 | +def noteLED(pix, pnote, pvel): |
| 113 | + note30 = (pnote - midi_note_C4) % (3 * numpixels) |
| 114 | + pos = note30 % numpixels |
| 115 | + r, g, b = pix[pos] |
| 116 | + if pvel == 0: |
| 117 | + brightness = 0 |
| 118 | + else: |
| 119 | + # max brightness will be 32 |
| 120 | + brightness = round(pvel / 127 * 30 + 2) |
| 121 | + # Pick R/G/B based on range within the 30 notes |
| 122 | + if note30 < 10: |
| 123 | + r = brightness |
| 124 | + elif note30 < 20: |
| 125 | + g = brightness |
| 126 | + else: |
| 127 | + b = brightness |
| 128 | + pix[pos] = (r, g, b) |
| 129 | + |
| 130 | +# Calculate the note frequency from the midi_note with pitch bend |
| 131 | +# of pb_st (float) semitones |
| 132 | +# Returns float |
| 133 | +def note_frequency(midi_note, pb_st): |
| 134 | + # 12 semitones in an octave |
| 135 | + return A4refhz * math.pow(2, (midi_note - midi_note_A4 + pb_st) / 12.0) |
| 136 | + |
| 137 | +midi_channel = 1 |
| 138 | +midi = adafruit_midi.MIDI(midi_in=usb_midi.ports[0], |
| 139 | + in_channel=midi_channel-1) |
| 140 | + |
| 141 | +# pitchbendrange in semitones - often 2 or 12 |
| 142 | +pb_midpoint = 8192 |
| 143 | +pitch_bend_multiplier = 2 / pb_midpoint |
| 144 | +pitch_bend_value = pb_midpoint # mid point - no bend |
| 145 | + |
| 146 | +wave = [] # current or last wave played |
| 147 | +last_note = None |
| 148 | + |
| 149 | +# Amplitude modulation frequency in Hz |
| 150 | +am_freq = 16 |
| 151 | +mod_wheel = 0 |
| 152 | + |
| 153 | +# Read any incoming MIDI messages (events) over USB |
| 154 | +# looking for note on, note off, pitch bend change |
| 155 | +# or control change for control 1 (modulation wheel) |
| 156 | +# Apply crude amplitude modulation using speaker enable |
| 157 | +while True: |
| 158 | + msg = midi.receive() |
| 159 | + if isinstance(msg, NoteOn) and msg.velocity != 0: |
| 160 | + last_note = msg.note |
| 161 | + # Calculate the sample rate to give the wave form the frequency |
| 162 | + # which matches the midi note with any pitch bending applied |
| 163 | + pitch_bend = (pitch_bend_value - pb_midpoint) * pitch_bend_multiplier |
| 164 | + note_freq = note_frequency(msg.note, pitch_bend) |
| 165 | + note_sample_rate = round(base_sample_rate * note_freq / A4refhz) |
| 166 | + |
| 167 | + # Select the wave with volume for the note velocity |
| 168 | + # Value slightly above 127 together with int() maps the velocities |
| 169 | + # to equal intervals and avoids going out of bound |
| 170 | + wave_vol = int(msg.velocity / 127.01 * len(waveform_by_vol)) |
| 171 | + wave = waveform_by_vol[wave_vol] |
| 172 | + |
| 173 | + if note_sample_rate > max_sample_rate: |
| 174 | + note_sample_rate = max_sample_rate |
| 175 | + wave[0].sample_rate = note_sample_rate # must be integer |
| 176 | + dac.play(wave[0], loop=True) |
| 177 | + |
| 178 | + noteLED(pixels, msg.note, msg.velocity) |
| 179 | + |
| 180 | + elif (isinstance(msg, NoteOff) or |
| 181 | + isinstance(msg, NoteOn) and msg.velocity == 0): |
| 182 | + # Our monophonic "synth module" needs to ignore keys that lifted on |
| 183 | + # overlapping presses |
| 184 | + if msg.note == last_note: |
| 185 | + dac.stop() |
| 186 | + last_note = None |
| 187 | + |
| 188 | + noteLED(pixels, msg.note, 0) # turn off NeoPixel |
| 189 | + |
| 190 | + elif isinstance(msg, PitchBend): |
| 191 | + pitch_bend_value = msg.pitch_bend # 0 to 16383 |
| 192 | + if last_note is not None: |
| 193 | + pitch_bend = (pitch_bend_value - pb_midpoint) * pitch_bend_multiplier |
| 194 | + note_freq = note_frequency(last_note, pitch_bend) |
| 195 | + note_sample_rate = round(base_sample_rate * note_freq / A4refhz) |
| 196 | + if note_sample_rate > max_sample_rate: |
| 197 | + note_sample_rate = max_sample_rate |
| 198 | + wave[0].sample_rate = note_sample_rate # must be integer |
| 199 | + dac.play(wave[0], loop=True) |
| 200 | + |
| 201 | + elif isinstance(msg, ControlChange): |
| 202 | + if msg.control == midi_cc_modwheel: |
| 203 | + mod_wheel = msg.value # msg.value is 0 (none) to 127 (max) |
| 204 | + |
| 205 | + if mod_wheel > 0: |
| 206 | + t1 = time.monotonic() * am_freq |
| 207 | + # Calculate a form of duty_cycle for enabling speaker for crude |
| 208 | + # amplitude modulation. Empirically the divisor needs to greater |
| 209 | + # than 127 as can't hear much when speaker is off more than half |
| 210 | + # 220 works reasonably well |
| 211 | + new_speaker_on = (t1 - int(t1)) > (mod_wheel / 220) |
| 212 | + else: |
| 213 | + new_speaker_on = True |
| 214 | + |
| 215 | + if speaker_on != new_speaker_on: |
| 216 | + speaker_enable.value = new_speaker_on |
| 217 | + speaker_on = new_speaker_on |
0 commit comments