-
Notifications
You must be signed in to change notification settings - Fork 87
/
Copy pathpolyrhythmic_sequencer.py
353 lines (289 loc) · 12 KB
/
polyrhythmic_sequencer.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
"""
Polyrhythmic Sequencer
author: Adam Wonak (github.com/awonak)
date: 2022-01-17
labels: polyrhythms, sequencer, triggers
EuroPi version of a Subharmonicon style polyrhythmic sequencer.
Partially inspired by m0wh: https://github.com/m0wh/subharmonicon-sequencer
Demo video: https://youtu.be/vMAVqVQIpW0
Page 1 is the first 4 note sequence, page 2 is the second 4 note sequence, page
3 is the polyrhythms assignable to each sequence. Use knob 1 to select between
the 4 steps and use knob 2 to edit that step. On page 3 there are 4 polyrhythm
options ranging from triggering every 1 beat to every 16 beats. On this page
button 2 assigns which sequence this polyrhythm should apply to. Button 1 will
cycle through the pages. The script needs a clock source in the digital input
to play.
digital_in: clock in
analog_in: unused
knob_1: select step option for current page
knob_2: adjust the value of the selected option
button_1: cycle through editable parameters (seq1, seq1, poly)
button_2: edit current parameter options
output_1: pitch 1
output_2: trigger 1
output_3: trigger logical AND
output_4: pitch 2
output_5: trigger 2
output_6: trigger logical XOR
"""
try:
# Local development
from software.firmware.europi import OLED_WIDTH, OLED_HEIGHT, CHAR_HEIGHT
from software.firmware.europi import din, k1, k2, oled, b1, b2, cv1, cv2, cv3, cv4, cv5, cv6
from software.firmware.europi_script import EuroPiScript
except ImportError:
# Device import path
from europi import *
from europi_script import EuroPiScript
from collections import namedtuple
import struct
import machine
from utime import ticks_diff, ticks_ms
# Script Constants
MENU_DURATION = 1200
VOLT_PER_OCT = 1 / 12
# fmt: off
NOTES = [
"C0", "C#0", "D0", "D#0", "E0", "F0", "F#0", "G0", "G#0", "A0", "A#0", "B0",
"C1", "C#1", "D1", "D#1", "E1", "F1", "F#1", "G1", "G#1", "A1", "A#1", "B1",
"C2", "C#2", "D2", "D#2", "E2", "F2", "F#2", "G2", "G#2", "A2", "A#2", "B2",
"C3", "C#3", "D3", "D#3", "E3", "F3", "F#3", "G3", "G#3", "A3", "A#3", "B3",
"C4", "C#4", "D4", "D#4", "E4", "F4", "F#4", "G4", "G#4", "A4", "A#4", "B4",
]
# fmt: on
class Sequence:
def __init__(self, notes, pitch_cv, trigger_cv):
self.notes = notes
self.pitch_cv = pitch_cv
self.trigger_cv = trigger_cv
self.step_index = 0
# Save state struct
self.format_string = "4s"
self.State = namedtuple("State", "note_indexes")
def set_state(self, state):
"""Update instance variables with given state bytestring."""
state = self.State(*struct.unpack(self.format_string, state))
self.notes = [NOTES[n] for n in state.note_indexes]
def get_state(self):
"""Return state byte string."""
note_indexes = [NOTES.index(n) for n in self.notes]
return struct.pack(self.format_string, bytes(note_indexes))
def _pitch_cv(self, note: str) -> float:
return NOTES.index(note) * VOLT_PER_OCT
def _set_pitch(self):
pitch = self._pitch_cv(self.current_note())
self.pitch_cv.voltage(pitch)
def current_note(self) -> str:
return self.notes[self.step_index]
def edit_step(self, step: int, note: str):
"""Set the given step to the given note value and update pitch cv out."""
assert note in NOTES, f"Given note not in available notes: {note}"
self.notes[step] = note
self._set_pitch()
def advance_step(self):
"""Advance the sequence step index."""
self.step_index = (self.step_index + 1) % len(self.notes)
def play_next_step(self):
"""Advance the sequence step and play the note."""
self.advance_step()
# Set cv output voltage to sequence step pitch.
self._set_pitch()
self.trigger_cv.on()
def reset(self):
"""Reset the sequence back to the first note."""
self.step_index = 0
self._set_pitch()
self.trigger_cv.off()
class PolyrhythmSeq(EuroPiScript):
pages = ['SEQUENCE 1', 'SEQUENCE 2', 'POLYRHYTHM']
# Two 4-step melodic sequences.
seqs = [
Sequence(["C0", "D#0", "D0", "G0"], cv1, cv2),
Sequence(["G0", "F0", "D#0", "C0"], cv4, cv5),
]
# 4 editable polyrhythms, assignable to the sequences.
polys = [8, 3, 2, 5]
# Indicates which sequences are assigned to each polyrhythm.
# 0: none, 1: seq1, 2: seq2, 3: both seq1 and seq2.
seq_poly = [2, 1, 1, 0]
# Used to indicates if state has changed and not yet saved.
_dirty = False
_last_saved = 0
def __init__(self):
super().__init__()
# Configure EuroPi options to improve performance.
b2.debounce_delay = 200
oled.contrast(0) # dim the oled
# Current editable sequence.
self.seq = self.seqs[0]
self.page = 0
self.param_index = 0
self.counter = 0
self.reset_timeout = 3000
self._prev_k2 = None
# Assign cv outputs to logical triggers.
self.trigger_and = cv3
self.trigger_xor = cv6
# Save state struct
self.format_string = "12s12s4s4s"
self.State = namedtuple("State", "seq1 seq2 polys seq_poly")
# Load state if previous state exists.
self.load_state()
@b1.handler
def page_handler():
# Pressing button 1 cycles through the pages of editable parameters.
self._prev_k2 = None
self.page = (self.page + 1) % len(self.pages)
if self.page == 0:
self.seq = self.seqs[0]
if self.page == 1:
self.seq = self.seqs[1]
self._dirty = True
@b2.handler
def edit_parameter():
# Pressing button 2 edits the current selected parameter.
if self.page == 2:
# Cycles through which sequence this polyrhythm is assigned to.
self.seq_poly[self.param_index] = (
self.seq_poly[self.param_index] + 1) % len(self.seq_poly)
self._dirty = True
@din.handler
def play_notes():
# For each polyrhythm, check if each sequence is enabled and if the
# current beat should play.
seq1 = False
seq2 = False
# Check each polyrhythm to determine if a sequence should be triggered.
for i, poly in enumerate(self.polys):
if self.counter % poly == 0:
_seq1, _seq2 = self._trigger_seq(i)
seq1 = _seq1 or seq1
seq2 = _seq2 or seq2
if seq1:
self.seqs[0].play_next_step()
if seq2:
self.seqs[1].play_next_step()
# Trigger logical AND / XOR
if seq1 and seq2:
self.trigger_and.on()
if (seq1 or seq2) and seq1 != seq2:
self.trigger_xor.on()
self.counter = self.counter + 1
@din.handler_falling
def triggers_off():
# Turn off all of the trigger CV outputs.
for seq in self.seqs:
seq.trigger_cv.off()
self.trigger_and.off()
self.trigger_xor.off()
def _trigger_seq(self, step: int):
# Convert poly sequence enablement into binary to determine which
# sequences are triggered on this step.
status = f"{self.seq_poly[step]:02b}"
# Reverse the binary string values to match display.
return int(status[1]) == 1, int(status[0]) == 1
def load_state(self):
"""Load state from previous run."""
state = self.load_state_bytes()
if state:
self.set_state(state)
def save_state(self):
"""Save state if it has changed since last call."""
# Only save state if state has changed and more than 1s has elapsed
# since last save.
if self._dirty and self.last_saved() > 1000:
state = self.get_state()
self.save_state_bytes(state)
self._dirty = False
self._last_saved = ticks_ms()
def get_state(self):
"""Get state as a byte string."""
return struct.pack(self.format_string,
self.seqs[0].get_state(),
self.seqs[1].get_state(),
bytes(self.polys),
bytes(self.seq_poly))
def set_state(self, state):
"""Update instance variables with given state bytestring."""
try:
_state = self.State(*struct.unpack(self.format_string, state))
except ValueError as e:
print(f"Unable to load state: {e}")
return
self.seqs[0].set_state(_state.seq1)
self.seqs[1].set_state(_state.seq2)
self.polys = list(_state.polys)
self.seq_poly = list(_state.seq_poly)
def reset_check(self):
"""Reset the sequences and triggers when no clock pulse detected for specified time."""
if self.counter != 0 and ticks_diff(ticks_ms(), din.last_triggered()) > self.reset_timeout:
self.step = 0
self.counter = 0
for s in self.seqs:
s.reset()
self.trigger_and.off()
self.trigger_xor.off()
def show_menu_header(self):
if ticks_diff(ticks_ms(), b1.last_pressed()) < MENU_DURATION:
oled.fill_rect(0, 0, OLED_WIDTH, CHAR_HEIGHT, 1)
oled.text(f"{self.pages[self.page]}", 0, 0, 0)
def edit_sequence(self):
# Display each sequence step.
for step in range(len(self.seq.notes)):
# If the current step is selected, edit with the parameter edit knob.
if step == self.param_index:
selected_note = k2.choice(NOTES)
if self._prev_k2 and self._prev_k2 != selected_note:
self.seq.edit_step(step, selected_note)
self._dirty = True
self._prev_k2 = selected_note
# Display the current step.
padding_x = 4 + (int(OLED_WIDTH/4) * step)
padding_y = 12
oled.text(f"{self.seq.notes[step]:<3}", padding_x, padding_y, 1)
# Display a bar under current playing step.
if step == self.seq.step_index:
x1 = (int(OLED_WIDTH / 4) * step)
x2 = int(OLED_WIDTH / 4)
oled.fill_rect(x1, OLED_HEIGHT - 6, x2, OLED_HEIGHT, 1)
def edit_poly(self):
# Display each polyrhythm option.
for poly_index in range(len(self.polys)):
# If the current polyrhythm is selected, edit with the parameter knob.
if poly_index == self.param_index:
poly = k2.range(16) + 1
if self._prev_k2 and self._prev_k2 != poly:
self.polys[poly_index] = poly
self._dirty = True
self._prev_k2 = poly
# Display the current polyrhythm.
padding_x = 8 + (int(OLED_WIDTH/4) * poly_index)
padding_y = 12
oled.text(f"{self.polys[poly_index]:>2}", padding_x, padding_y, 1)
# Display graphic for seq 1 & 2 enablement.
seq1, seq2 = self._trigger_seq(poly_index)
y1 = OLED_HEIGHT - 10
x1 = 9 + int(OLED_WIDTH/4) * poly_index
(oled.fill_rect if seq1 else oled.rect)(x1, y1, 6, 6, 1)
x1 = 17 + int(OLED_WIDTH/4) * poly_index
(oled.fill_rect if seq2 else oled.rect)(x1, y1, 6, 6, 1)
def main(self):
while True:
oled.fill(0)
# Parameter edit index & display selected box
self.param_index = k1.range(4)
left_x = int((OLED_WIDTH/4) * self.param_index)
right_x = int(OLED_WIDTH/4)
oled.rect(left_x, 0, right_x, OLED_HEIGHT, 1)
if self.page == 0 or self.page == 1:
self.edit_sequence()
if self.page == 2:
self.edit_poly()
self.reset_check()
self.show_menu_header()
oled.show()
self.save_state()
# Main script execution
if __name__ == '__main__':
script = PolyrhythmSeq()
script.main()