From 78294cc05f0dbd5f5eb9f0811bb15a89882fdfcb Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Thu, 9 Jan 2025 18:55:21 -0500 Subject: [PATCH] Add DCSN-2 Script (#399) * Add DCSN-2 contrib script * Add `ellipse` function for drawing circles and ellipses --- software/contrib/README.md | 6 + software/contrib/dscn2.md | 31 ++++ software/contrib/dscn2.py | 149 ++++++++++++++++++ software/contrib/menu.py | 1 + software/firmware/experimental/screensaver.py | 3 + software/oled_tips.md | 1 + software/tests/mocks/ssd1306.py | 3 + 7 files changed, 194 insertions(+) create mode 100644 software/contrib/dscn2.md create mode 100644 software/contrib/dscn2.py diff --git a/software/contrib/README.md b/software/contrib/README.md index 7deda14cd..d8105e55f 100644 --- a/software/contrib/README.md +++ b/software/contrib/README.md @@ -54,6 +54,12 @@ Recording of CV can be primed so that you can record a movement without missing Author: [anselln](https://github.com/anselln)
Labels: sequencer, CV, performance +### DCSN-2 \[ [documentation](/software/contrib/dscn2.md) | [script](/software/contrib/dcsn2.md) \] +A loopable random gate sequencer based on a binary tree. Inspired by the Robaux DCSN3 + +Author: [chrisib](https://github.com/chrisib) +
Labels: sequencer, gates, triggers, randomness + ### Egressus Melodium \[ [documentation](/software/contrib/egressus_melodiam.md) | [script](/software/contrib/egressus_melodiam.py) \] Clockable and free-running LFO and random CV pattern generator diff --git a/software/contrib/dscn2.md b/software/contrib/dscn2.md new file mode 100644 index 000000000..47600bc85 --- /dev/null +++ b/software/contrib/dscn2.md @@ -0,0 +1,31 @@ +# DCSN-2 + +DSCN-2 is a loopable, random gate sequencer based on a binary tree. An incoming clock signal is routed to +one of two child nodes, and from there to one of four grandchild nodes. + +Inspired by the [Robaux DCSN3](https://robaux.io/products/dcsn3). + +## Ins & Outs + +| I/O | Function +|-----|-----------------------------------------------------------------------------------| +| DIN | Incoming clock signal | +| AIN | CV control for randomness | +| K1 | Length of the pattern | +| K2 | Randomness; anticlockwise will lock the loop, clockwise will introduce randomness | +| B1 | Manually advance the pattern | +| B2 | Generates a new random pattern | +| CV1 | Child output 1 | +| CV2 | Grandchild output 1-1 | +| CV3 | Grandchild output 1-2 | +| CV4 | Child output 2 | +| CV5 | Grandchild output 2-1 | +| CV6 | Grandchild output 2-2 | + +## Operation + +Every time a clock signal is received on `DIN` the one child & one grandchild output is turned on; all other +outputs are turned off. Depending on the randomness the pattern of gates will be looped, fully random, or +somewhere in between + +The pulse width of the outputs is determined by the pulse width of the input clock. diff --git a/software/contrib/dscn2.py b/software/contrib/dscn2.py new file mode 100644 index 000000000..01433178c --- /dev/null +++ b/software/contrib/dscn2.py @@ -0,0 +1,149 @@ +""" +Binary tree based looping gate sequencer +""" + +from europi import * +from europi_script import EuroPiScript + +from framebuf import FrameBuffer, MONO_HLSB +from random import random as rnd + + +class Dcsn2(EuroPiScript): + + randomness_cv = ain + randomness_knob = k2 + + length_knob = k1 + + MAX_LENGTH = 16 + MIN_LENGTH = 2 + + children = [cv1, cv4] + grandchildren = [ + [cv2, cv3], + [cv5, cv6] + ] + + loop_image = FrameBuffer(bytearray(b'\x1cZ\x81\x81\x81\x81Z8'), CHAR_WIDTH, CHAR_HEIGHT, MONO_HLSB) + + def __init__(self): + super().__init__() + + self.unhandled_clock = False + + # initialize random pattern + self.pattern = [] + for i in range(self.MAX_LENGTH): + self.pattern.append(self.choose_random()) + + def on_clock_rise(): + self.unhandled_clock = True + + def on_clock_fall(): + turn_off_all_cvs() + + def regenerate_pattern(): + for i in range(self.MAX_LENGTH): + self.pattern[i] = self.choose_random() + + din.handler(on_clock_rise) + din.handler_falling(on_clock_fall) + b1.handler(on_clock_rise) + b1.handler_falling(on_clock_fall) + + b2.handler(regenerate_pattern) + + self.set_outputs() + + def calculate_randomness(self): + """Combine AIN & K2 to determine the probability that the pattern loops + """ + # this will be in the range [0, 2] + randomness = self.randomness_cv.percent() + self.randomness_knob.percent() + + # restrict to [0, 1] + if randomness >= 1: + randomness = 2.0 - randomness + + return randomness + + def choose_random(self): + """ + Pick a random gate for the output pattern + + 0: child 1, grandchild 1-1 + 1: child 1, grandchild 1-2 + 2: child 2, grandchild 2-1 + 3: child 2, grandchild 2-2 + """ + return int(rnd() * 4) + + def set_outputs(self): + turn_off_all_cvs() + g = self.pattern[0] + self.children[g >> 1].on() + self.grandchildren[g >> 1][g & 1].on() + + def draw(self, pattern_length, loop_prob): + oled.fill(0) + + active_child = self.pattern[0] >> 1 + active_grandchild = self.pattern[0] & 1 + + # draw the tree with lines & circles + oled.ellipse(OLED_WIDTH//2, 5, 4, 4, 1, True) # root, always filled + + # children + oled.ellipse(OLED_WIDTH//4, OLED_HEIGHT//2, 4, 4, 1, active_child == 0) + oled.ellipse(3*OLED_WIDTH//4, OLED_HEIGHT//2, 4, 4, 1, active_child != 0) + + # grandchildren + oled.ellipse(6, OLED_HEIGHT-5, 4, 4, 1, active_child == 0 and active_grandchild == 0) + oled.ellipse(OLED_WIDTH//2-6, OLED_HEIGHT-5, 4, 4, 1, active_child == 0 and active_grandchild != 0) + oled.ellipse(OLED_WIDTH//2+6, OLED_HEIGHT-5, 4, 4, 1, active_child != 0 and active_grandchild == 0) + oled.ellipse(OLED_WIDTH-6, OLED_HEIGHT-5, 4, 4, 1, active_child != 0 and active_grandchild != 0) + + if active_child == 0: + oled.line(OLED_WIDTH//2, 5, OLED_WIDTH//4, OLED_HEIGHT//2, 1) + if active_grandchild == 0: + oled.line(OLED_WIDTH//4, OLED_HEIGHT//2, 6, OLED_HEIGHT-5, 1) + else: + oled.line(OLED_WIDTH//4, OLED_HEIGHT//2, OLED_WIDTH//2-6, OLED_HEIGHT-5, 1) + else: + oled.line(OLED_WIDTH//2, 5, 3*OLED_WIDTH//4, OLED_HEIGHT//2, 1) + if active_grandchild == 0: + oled.line(3*OLED_WIDTH//4, OLED_HEIGHT//2, OLED_WIDTH//2+6, OLED_HEIGHT-5, 1) + else: + oled.line(3*OLED_WIDTH//4, OLED_HEIGHT//2, OLED_WIDTH-6, OLED_HEIGHT-5, 1) + + oled.text(f"{pattern_length}", 0, 0, 1) + s = f"{round(loop_prob * 100)}" + oled.blit(self.loop_image, OLED_WIDTH - CHAR_WIDTH * (len(s)+1) - 1, 0) + oled.text(s, OLED_WIDTH - len(s) * CHAR_WIDTH, 0, 1) + oled.text(f"{self.pattern[0]}", OLED_WIDTH//2-CHAR_WIDTH//2, OLED_HEIGHT//2 - CHAR_HEIGHT//2, 1) + oled.show() + + def main(self): + while True: + r = rnd() + loop_prob = 1.0 - self.calculate_randomness() # 0 -> random, 1 -> loop + pattern_length = round(self.length_knob.percent() * (self.MAX_LENGTH - self.MIN_LENGTH) + self.MIN_LENGTH) + + if self.unhandled_clock: + self.unhandled_clock = False + + # shift the pattern over 1 step, introducing randomness as needed + if r <= loop_prob: + tmp = self.pattern[pattern_length - 1] + else: + tmp = self.choose_random() + for i in range(pattern_length - 1): + self.pattern[pattern_length-1-i] = self.pattern[pattern_length-2-i] + self.pattern[0] = tmp + self.set_outputs() + + self.draw(pattern_length, loop_prob) + +if __name__ == "__main__": + Dcsn2().main() diff --git a/software/contrib/menu.py b/software/contrib/menu.py index e69c1380d..ae8d93fc7 100644 --- a/software/contrib/menu.py +++ b/software/contrib/menu.py @@ -29,6 +29,7 @@ ["Consequencer", "contrib.consequencer.Consequencer"], ["Conway", "contrib.conway.Conway"], ["CVecorder", "contrib.cvecorder.CVecorder"], + ["DCSN-2", "contrib.dscn2.Dcsn2"], ["Diagnostic", "contrib.diagnostic.Diagnostic"], ["EgressusMelodiam", "contrib.egressus_melodiam.EgressusMelodiam"], ["EnvelopeGen", "contrib.envelope_generator.EnvelopeGenerator"], diff --git a/software/firmware/experimental/screensaver.py b/software/firmware/experimental/screensaver.py index ab53c9ecb..908126c89 100644 --- a/software/firmware/experimental/screensaver.py +++ b/software/firmware/experimental/screensaver.py @@ -159,6 +159,9 @@ def rect(self, x, y, width, height, color=1): def fill_rect(self, x, y, width, height, color=1): oled.fill_rect(x, y, width, height, color) + def ellipse(self, x, y, xr, yr, colour=1, fill=False): + oled.ellipse(x, y, xr, yr, colour, fill) + def blit(self, buffer, x, y): oled.blit(buffer, x, y) diff --git a/software/oled_tips.md b/software/oled_tips.md index 8786aeb34..5d752921c 100644 --- a/software/oled_tips.md +++ b/software/oled_tips.md @@ -19,6 +19,7 @@ The pixels are indexed with (0, 0) at the top left, and (127, 31) at the bottom |vline|x, y, length, colour|Draws a vertical wide line starting at (x, y) with specified length and colour| |rect|x, y, width, height, colour|Draws a rectangle starting at (x, y) with specified width, height, and outline colour| |fill_rect|x, y, width, height, colour|Draws a rectangle starting at (x, y) with specified width, height, and fill colour| +|ellipse|x, y, xr, yr, colour[, fill]|Draws an ellipse with horizontal radius xr and vertical radius yr, at postition (x, y) with the speficied colour and optional fill| |blit|buffer, x, y|Draws a bitmap based on a buffer, starting at (x, y) |scroll|x, y|Scrolls the contents of the display by (x, y) |invert|colour|Inverts the display diff --git a/software/tests/mocks/ssd1306.py b/software/tests/mocks/ssd1306.py index 916b2ccac..ba3f5fe21 100644 --- a/software/tests/mocks/ssd1306.py +++ b/software/tests/mocks/ssd1306.py @@ -33,6 +33,9 @@ def fill_rect(self, *args): def rect(self, *args): pass + def ellipse(self, *args): + pass + def text(self, *args): pass