-
Notifications
You must be signed in to change notification settings - Fork 87
/
Copy pathquantizer.py
512 lines (408 loc) · 17.4 KB
/
quantizer.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
"""Equal-temperment quantizer for the EuroPi
Features configurable intervals for multiple outputs and customizable scale
@author Chris Iverach-Brereton <[email protected]>
@date 2023-02-12
"""
from europi import *
from europi_script import EuroPiScript
from experimental.quantizer import Quantizer, VOLTS_PER_OCTAVE, VOLTS_PER_SEMITONE, SEMITONES_PER_OCTAVE
from experimental.screensaver import Screensaver
import time
## Whe in triggered mode we only quantize when we receive an external clock signal
MODE_TRIGGERED=0
## In continuous mode the digital input is ignored and we quantize the input
# at the highest rate possible
MODE_CONTINUOUS=1
## How many milliseconds of idleness do we need before we trigger the screensaver?
#
# =20 minutes
SCREENSAVER_TIMEOUT_MS = 1000 * 60 * 20
SELECT_OPTION_Y = 16
HALF_CHAR_WIDTH = int(CHAR_WIDTH / 2)
class ScreensaverScreen(Screensaver):
"""Blank the screen when idle
Eventually it might be neat to have an animation, but that's
not necessary for now
"""
def __init__(self, quantizer):
super().__init__()
self.quantizer = quantizer
def on_button1(self):
self.quantizer.active_screen = self.quantizer.kb
class KeyboardScreen:
"""Draws a pretty keyboard and indicates what notes are enabled
and what note is being played as the primary output
"""
def __init__(self, quantizer):
self.quantizer = quantizer
self.highlight_note = 0
# X, Y, bw
self.enable_marks = [
( 8, 2, 0),
( 17, 2, 1),
( 26, 2, 0),
( 35, 2, 1),
( 43, 2, 0),
( 59, 2, 0),
( 69, 2, 1),
( 77, 2, 0),
( 85, 2, 1),
( 94, 2, 0),
(103, 2, 1),
(112, 2, 0)
]
self.playing_marks = [
( 8, 20, 0),
( 17, 15, 1),
( 26, 20, 0),
( 35, 15, 1),
( 43, 20, 0),
( 59, 20, 0),
( 69, 15, 1),
( 77, 20, 0),
( 85, 15, 1),
( 94, 20, 0),
(103, 15, 1),
(112, 20, 0)
]
def draw(self):
# a 128x32 keyboard image
# see https://github.com/Allen-Synthesis/EuroPi/blob/main/software/oled_tips.md
# and https://github.com/novaspirit/img2bytearray
KB_HEIGHT = 32
KB_WIDTH = 128
kb=b'\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xc0\x7f\xe0?\xfe\xff\xf8\x0f\xfc\x07\xfe\x03\xff\xe0\x07\xff\xfb\xff\xfd\xff\xfe\xff\xff\x7f\xff\xbf\xff\xdf\xff\xe0\x07\xff\xfb\xff\xfd\xff\xfe\xff\xff\x7f\xff\xbf\xff\xdf\xff\xe0\x07\xff\xfb\xff\xfd\xff\xfe\xff\xff\x7f\xff\xbf\xff\xdf\xff\xe0\x07\xff\xfb\xff\xfd\xff\xfe\xff\xff\x7f\xff\xbf\xff\xdf\xff\xe0\x07\xff\xfb\xff\xfd\xff\xfe\xff\xff\x7f\xff\xbf\xff\xdf\xff\xe0\x07\xff\xfb\xff\xfd\xff\xfe\xff\xff\x7f\xff\xbf\xff\xdf\xff\xe0\x07\xff\xfb\xff\xfd\xff\xfe\xff\xff\x7f\xff\xbf\xff\xdf\xff\xe0\x07\xff\xfb\xff\xfd\xff\xfe\xff\xff\x7f\xff\xbf\xff\xdf\xff\xe0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
# read the encoder value from knob 1 so we know what key to highlight
self.highlight_note = k1.range(len(self.quantizer.scale))
# draw the keyboard image to the screen
img = bytearray(kb)
imgFB = FrameBuffer(img, KB_WIDTH, KB_HEIGHT, MONO_HLSB)
oled.blit(imgFB,0,0)
# mark the enabled notes with a .
for i in range(len(self.quantizer.scale)):
if self.quantizer.scale[i]:
oled.text('.', *self.enable_marks[i])
# mark the active note with a +
k = self.quantizer.current_note
oled.text('+', self.playing_marks[k][0], self.playing_marks[k][1], self.playing_marks[k][2])
# clear the bottom of the screen and mark the togglable key with a line
oled.fill_rect(0, OLED_HEIGHT-2, OLED_WIDTH, 2, 0)
oled.fill_rect(self.enable_marks[self.highlight_note][0], OLED_HEIGHT-1, 7, 1, 1)
oled.show()
def on_button1(self):
self.quantizer.scale[self.highlight_note] = not self.quantizer.scale[self.highlight_note]
self.quantizer.save()
class MenuScreen:
"""Advanced menu options screen
"""
def __init__(self, quantizer):
self.quantizer = quantizer
self.menu_item = 0
self.menu_items = [
ModeChooser(quantizer),
RootChooser(quantizer),
OctaveChooser(quantizer),
IntervalChooser(quantizer, 2),
IntervalChooser(quantizer, 3),
IntervalChooser(quantizer, 4),
IntervalChooser(quantizer, 5)
]
def draw(self):
self.menu_item = k1.range(len(self.menu_items))
self.menu_items[self.menu_item].draw()
def on_button1(self):
self.menu_items[self.menu_item].on_button1()
class ModeChooser:
"""Used by MenuScreen to choose the operating mode
"""
def __init__(self, quantizer):
self.quantizer = quantizer
self.mode_names = [
"Triggered",
"Continuous"
]
def read_mode(self, mode='integer'):
if mode == 'string':
return k2.choice(self.mode_names)
else:
return k2.range(len(self.mode_names))
def on_button1(self):
new_mode = self.read_mode()
self.quantizer.mode = new_mode
self.quantizer.save()
def draw(self):
oled.fill(0)
oled.text(f"Mode", 0, 0)
current_mode = self.mode_names[self.quantizer.mode]
new_mode = self.read_mode(mode='string')
QuantizerScript.choose_option(self, new_mode, current_mode, self.mode_names)
oled.show()
class RootChooser:
"""Used by MenuScreen to choose the transposition offset
"""
def __init__(self, quantizer):
self.quantizer = quantizer
self.root_names = [
"C ",
"C#",
"D ",
"D#",
"E ",
"F ",
"F#",
"G ",
"G#",
"A ",
"A#",
"B "
]
def read_root(self, mode='integer'):
if mode == 'string':
return k2.choice(self.root_names)
else:
return k2.range(len(self.root_names))
def on_button1(self):
new_root = self.read_root()
self.quantizer.root = new_root
self.quantizer.save()
def draw(self):
oled.fill(0)
oled.text(f"Transpose", 0, 0)
new_root = self.read_root(mode='string')
current_root = self.root_names[self.quantizer.root]
QuantizerScript.choose_option(self, new_root, current_root, self.root_names)
oled.show()
class OctaveChooser:
"""Used by MenuScreen to choose the octave offset
"""
def __init__(self, quantizer):
self.quantizer = quantizer
self.octave_texts = ['-4', '-3', '-2', '-1', '0', '+1', '+2', '+3', '+4']
self.octave_text_y = 12
def read_octave(self, mode='integer'):
if mode == 'string':
return k2.choice(self.octave_texts)
else:
return k2.range(9) - 4 # result should be -1 to +2
def on_button1(self):
new_octave = self.read_octave()
self.quantizer.octave = new_octave
self.quantizer.save()
def draw(self):
oled.fill(0)
oled.text(f"Octave", 0, 0)
new_octave = self.read_octave(mode='string')
current_octave = self.quantizer.octave
if current_octave > 0:
current_octave = '+' + str(current_octave)
else:
current_octave = str(current_octave)
QuantizerScript.choose_option(self, new_octave, current_octave, self.octave_texts)
oled.show()
class IntervalChooser:
"""Used by MenuScreen to choose the interval offset for a given output
"""
def __init__(self, quantizer, n):
self.quantizer = quantizer
self.n = n
self.interval_names = [
"-Pe 8",
"-MA 7",
"-mi 7",
"-MA 6",
"-mi 6",
"-Pe 5",
"-Di 5",
"-Pe 4",
"-MA 3",
"-mi 3",
"-MA 2",
"-mi 2",
"Pe 1",
"+mi 2",
"+MA 2",
"+mi 3",
"+MA 3",
"+Pe 4",
"+Di 5",
"+Pe 5",
"+mi 6",
"+MA 6",
"+mi 7",
"+MA 7",
"+Pe 8"
]
def read_interval(self, mode='integer'):
if mode == 'string':
return k2.choice(self.interval_names)
else:
return k2.range(len(self.interval_names)) - 12
def on_button1(self):
new_interval = self.read_interval()
self.quantizer.intervals[self.n-2] = new_interval
self.quantizer.save()
def draw(self):
oled.fill(0)
oled.text(f"Output {self.n}", 0, 0)
new_interval = self.read_interval(mode='string')
current_interval = self.interval_names[self.quantizer.intervals[self.n-2]+12]
QuantizerScript.choose_option(self, new_interval, current_interval, self.interval_names)
oled.show()
class QuantizerScript(EuroPiScript):
"""The main EuroPi program. Uses Scale to quantize incoming analog voltages
and round them to the nearest note on the scale.
Primary output is on cv1, with cv2-5 as aux outputs shifted up/down a fixed
number of semitones. cv6 outputs a gate/trigger.
"""
def __init__(self):
super().__init__()
# keep track of the last time the user interacted with the module
# if we're idle for too long, start the screensaver
self.last_interaction_time = time.ticks_ms()
# Continious quantizing, or only on an external trigger?
self.mode = MODE_TRIGGERED
# What semitone is the root of the scale?
# 0 = C, 1 = C#/Db, 2 = D, etc...
# This is used to transpose the output up the given number of semitones
self.root = 0
# What octave are we outputting?
self.octave = 0
# Outputs 2-5 output the same note, shifted up or down by
# a fixed number of semitones
self.intervals = [0, 0, 0, 0]
self.aux_outs = [cv2, cv3, cv4, cv5]
# The current scale we're quantizing to
self.scale = Quantizer()
# The input/output voltages
self.input_voltage = 0.0
self.output_voltage = 0.0
# The semitone we're currently outputting on cv1 (0-11)
self.current_note = 0
# GUI/user interaction
self.kb = KeyboardScreen(self)
self.menu = MenuScreen(self)
self.screensaver = ScreensaverScreen(self)
self.active_screen = self.kb
self.screen_centre = int(OLED_WIDTH / 2)
self.load()
# connect event handlers for the rising & falling clock edges + button presses
@din.handler
def on_rising_clock():
"""Handler for the rising edge of the input clock
"""
if self.mode == MODE_TRIGGERED:
self.read_quantize_output()
cv6.on()
@din.handler_falling
def on_falling_clock():
"""Handler for the falling edge of the input clock
"""
if self.mode == MODE_TRIGGERED:
cv6.off()
@b1.handler
def on_b1_press():
"""Handler for pressing button 1
Button 1 is used for the main interaction and is passed to
the current display for user interaction
"""
self.last_interaction_time = time.ticks_ms()
self.active_screen.on_button1()
@b2.handler
def on_b2_press():
"""Handler for pressing button 2
Button 2 is used to cycle between screens
"""
self.last_interaction_time = time.ticks_ms()
if self.active_screen == self.kb:
self.active_screen = self.menu
else:
self.active_screen = self.kb
def load(self):
"""Load the persistent settings from storage and apply them
"""
state = self.load_state_json()
loaded_scale = state.get("scale", [True]*12) # default to a chromatic scale
self.scale.notes = loaded_scale
self.root = state.get("root", self.root)
self.octave = state.get("octave", self.octave)
self.intervals = state.get("intervals", self.intervals)
self.mode = state.get("mode", self.mode)
def save(self):
"""Save the current settings to persistent storage
"""
state = {
"scale": self.scale.notes,
"root": self.root,
"octave": self.octave,
"intervals": self.intervals,
"mode": self.mode
}
self.save_state_json(state)
@classmethod
def display_name(cls):
return "Quantizer"
def quantize(self, analog_in):
"""Take an analog signal and process it
Sets self.current_note and self.output_voltage
"""
(volts, semitone) = self.scale.quantize(analog_in)
# apply our octave & transposition offsets
volts = volts + self.octave * VOLTS_PER_OCTAVE + self.root * VOLTS_PER_SEMITONE
self.output_voltage = volts
self.current_note = semitone
def read_quantize_output(self):
"""Read the input signal, quantize it, set outputs 1-5 accordingly
Called by the main loop in continuous mode or the rising clock handler
in triggered mode
"""
self.input_voltage = ain.read_voltage(500) # increase the number of samples to help reduce noise
self.quantize(self.input_voltage)
cv1.voltage(self.output_voltage)
for i in range(len(self.aux_outs)):
self.aux_outs[i].voltage(self.output_voltage + self.intervals[i] * VOLTS_PER_SEMITONE)
def choose_option(self, new_item, current_item, all_items):
item_widths = []
for item_text in all_items:
if item_text == new_item:
offset = -int(sum(item_widths) + (CHAR_WIDTH * (len(item_widths))) + (CHAR_WIDTH * (len(item_text) / 2)) - (OLED_WIDTH / 2))
item_widths.append(len(item_text) * CHAR_WIDTH)
x = offset
for index, item_text in enumerate(all_items):
item_text_width = item_widths[index]
if item_text == current_item:
oled.fill_rect((x - 1), SELECT_OPTION_Y, (item_text_width + 3), (CHAR_HEIGHT + 4), 1)
oled.text(item_text, x, (SELECT_OPTION_Y + 2), 0)
elif item_text == new_item:
oled.rect((x - 1), SELECT_OPTION_Y, (item_text_width + 3), (CHAR_HEIGHT + 4), 1)
oled.text(item_text, x, (SELECT_OPTION_Y + 2), 1)
else:
oled.text(item_text, x, (SELECT_OPTION_Y + 2), 1)
x += item_text_width + CHAR_WIDTH
def main(self):
"""The main loop; reads from ain, sets the output voltages
"""
while True:
# Check if we've been idle for too long; if so, blank the screen
# to prevent burn-in
now = time.ticks_ms()
if time.ticks_diff(now, self.last_interaction_time) > SCREENSAVER_TIMEOUT_MS:
self.active_screen = self.screensaver
if self.mode == MODE_CONTINUOUS:
# clear the previous trigger
cv6.off()
# Read the new voltage and output it
last_output = self.output_voltage
self.read_quantize_output()
if last_output != self.output_voltage:
cv6.on()
# In continuous mode we fall back to a 100Hz internal clock
# that effectively simulates a very high-speed input trigger
# Note that this rate has to be long enough for the trigger on
# cv6 to be useful to other modules. A 10ms trigger is a
# reasonable compromise between high-speed input signal processing
# and keeping the code simple
CYCLE_RATE = 0.01
time.sleep(CYCLE_RATE)
self.active_screen.draw()
if __name__ == "__main__":
QuantizerScript().main()