Skip to content

Commit e4631b5

Browse files
authored
Create cpx-basic-synth.py
1 parent 366b0da commit e4631b5

1 file changed

Lines changed: 217 additions & 0 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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

Comments
 (0)