# Copyright 2022 Allen Synthesis
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
author: Adam Wonak (github.com/awonak)
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
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_3: trigger logical AND
output_6: trigger logical XOR
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
from europi_script import EuroPiScript
from collections import namedtuple
from utime import ticks_diff, ticks_ms
"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",
def __init__(self, notes, pitch_cv, trigger_cv):
self.trigger_cv = trigger_cv
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]
"""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
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}"
"""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."""
# Set cv output voltage to sequence step pitch.
"""Reset the sequence back to the first note."""
class PolyrhythmSeq(EuroPiScript):
pages = ['SEQUENCE 1', 'SEQUENCE 2', 'POLYRHYTHM']
# Two 4-step melodic sequences.
Sequence(["C0", "D#0", "D0", "G0"], cv1, cv2),
Sequence(["G0", "F0", "D#0", "C0"], cv4, cv5),
# 4 editable polyrhythms, assignable to the sequences.
# Indicates which sequences are assigned to each polyrhythm.
# 0: none, 1: seq1, 2: seq2, 3: both seq1 and seq2.
# Used to indicates if state has changed and not yet saved.
# Configure EuroPi options to improve performance.
oled.contrast(0) # dim the oled
# Current editable sequence.
self.reset_timeout = 3000
# Assign cv outputs to logical triggers.
self.format_string = "12s12s4s4s"
self.State = namedtuple("State", "seq1 seq2 polys seq_poly")
# Load state if previous state exists.
# Pressing button 1 cycles through the pages of editable parameters.
self.page = (self.page + 1) % len(self.pages)
# Pressing button 2 edits the current selected parameter.
# 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)
# For each polyrhythm, check if each sequence is enabled and if the
# current beat should play.
# 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)
self.seqs[0].play_next_step()
self.seqs[1].play_next_step()
# Trigger logical AND / XOR
if (seq1 or seq2) and seq1 != seq2:
self.counter = self.counter + 1
# Turn off all of the trigger CV outputs.
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
"""Load state from previous run."""
state = self.load_state_bytes()
"""Save state if it has changed since last call."""
# Only save state if state has changed and more than 1s has elapsed
if self._dirty and self.last_saved() > 1000:
self.save_state_bytes(state)
self._last_saved = ticks_ms()
"""Get state as a byte string."""
return struct.pack(self.format_string,
self.seqs[0].get_state(),
self.seqs[1].get_state(),
def set_state(self, state):
"""Update instance variables with given state bytestring."""
_state = self.State(*struct.unpack(self.format_string, state))
print(f"Unable to load state: {e}")
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)
"""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:
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)
# 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._prev_k2 = selected_note
# Display the current step.
padding_x = 4 + (int(OLED_WIDTH/4) * step)
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)
oled.fill_rect(x1, OLED_HEIGHT - 6, x2, OLED_HEIGHT, 1)
# 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:
if self._prev_k2 and self._prev_k2 != poly:
self.polys[poly_index] = poly
# Display the current polyrhythm.
padding_x = 8 + (int(OLED_WIDTH/4) * poly_index)
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)
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)
# 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:
if __name__ == '__main__':