# Copyright 2024 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.
Generates random voltages based on Bezier curves
Inspired by Addac Systems' & Monotrail's ADDAC507 collaboration
from europi_script import EuroPiScript
from experimental.knobs import *
from experimental.math_extras import solve_linear_system
from experimental.screensaver import OledWithScreensaver
ssoled = OledWithScreensaver()
"""Abstract class used for saving the gate outputs' logical operation
Subclasses must implement the compare(a, b) method
raise Exception("Not implemented")
class LogicalAnd(LogicalOperator):
"""Binary AND operator"""
class LogicalOr(LogicalOperator):
class LogicalXor(LogicalOperator):
"""Binary XOR operator"""
return (a or b) and not (a and b)
class LogicalNand(LogicalOperator):
"""Binary NAND operator"""
class LogicalNor(LogicalOperator):
"""Binary Nor operator"""
class LogicalXnor(LogicalOperator):
"""Binary XNOR operator"""
return (a and not b) or (not a and b)
AIN_MODE_FREQUENCY = "frequency"
def __init__(self, x, y):
return f"{self.x},{self.y}"
def linear_interpolate(x1, x2, t):
"""Linearly interpolate from one value to another
@param x1 The initial value of x
@param x2 The final value of x
@return The interpolated value of x
return x1 * (1-t) + x2 * t
"""Calculates 2D bezier curves using quadratic interpolation
The intermediate point, P1, always lies on the inverse diagonal o
self.origin = Point2D(0, 0)
self.next_point = Point2D(1, 0)
def set_next_value(self, y):
"""Set the y value for the next time increment (0-1)
self.origin.y = self.next_point.y
def value_at(self, t, k):
"""Get the value of the bezier curve for a given time
@param k The curve constant, in the range [-1, 1] See @interpolate for details on the curve constant
# Get 4 points on the curve so we can create a cubic equation for the curve
p1 = self.interpolate(0, k)
p2 = self.interpolate(1/3, k)
p3 = self.interpolate(2/3, k)
p4 = self.interpolate(1, k)
[p1.x**3, p1.x**2, p1.x, 1, p1.y],
[p2.x**3, p2.x**2, p2.x, 1, p2.y],
[p3.x**3, p3.x**2, p3.x, 1, p3.y],
[p4.x**3, p4.x**2, p4.x, 1, p4.y],
coeffs = solve_linear_system(m)
return coeffs[0] * t**3 + coeffs[1] * t**2 + coeffs[2] * t + coeffs[3]
def interpolate(self, t, k):
"""Calculate the 2D position for the given time
See https://en.wikipedia.org/wiki/Bezier_curve#Cubic_curves for details on the math.
@param t The current interpolated time, between 0 and 1
@param k A constant indicating our progression from horizontal endpoints (smoothest) @ -1.0 to
linear @ 0.0 to vertical endpoints (spikiest) @ 1.0
@return The interpolated 2D point on the bezier curve
# Define 2 intermediate points, which are either horizontally or vertically aligned with the start and end points
# start/endpoints aligned horizontally with intermediate points
# /3 results in slightly smoother results; /2 was giving cusps on sharp peaks & valleys
# start/endpoints aligned vertically with intermediate points
# p1 goes up, p2 goes down
# p1 goes down, p2 goes up
linear_interpolate(p0.x, p1.x, t),
linear_interpolate(p0.y, p1.y, t)
linear_interpolate(p1.x, p2.x, t),
linear_interpolate(p1.y, p2.y, t)
linear_interpolate(p2.x, p3.x, t),
linear_interpolate(p2.y, p3.y, t)
linear_interpolate(q0.x, q1.x, t),
linear_interpolate(q0.y, q1.y, t)
linear_interpolate(q1.x, q2.x, t),
linear_interpolate(q1.y, q2.y, t)
linear_interpolate(r0.x, r1.x, t),
linear_interpolate(r0.y, r1.y, t)
"""Wrapper for a CV output channel
def __init__(self, script, frequency_in, curve_in, cv_out):
self.curve = BezierCurve()
self.frequency_in = frequency_in
self.last_tick_at = time.ticks_ms()
self.vizualization_samples = []
def change_voltage(self):
"""Pick a random value between -0.1 and 1.1 and use it as the goal point for the bezier curve
We intentionally overshoot the [0, 1] range in order to force the occasional effect from the clipping mode.
self.curve.set_next_value(random.random() * 1.2 - 0.1)
def update(self, clip_mode=CLIP_MODE_LIMIT):
self.curve_k = self.curve_in.percent() * 2 -1 # [-1, 1]
self.frequency = self.frequency_in.percent() * (self.script.config.MAX_FREQUENCY - self.script.config.MIN_FREQUENCY) + self.script.config.MIN_FREQUENCY
if self.script.config.AIN_MODE == AIN_MODE_FREQUENCY:
# increase the frequency according to the voltage on AIN
self.frequency = self.frequency + ain.read_voltage() / self.script.config.MAX_INPUT_VOLTAGE * (self.script.config.MAX_FREQUENCY - self.script.config.MIN_FREQUENCY) + self.script.config.MIN_FREQUENCY
elif self.script.config.AIN_MODE == AIN_MODE_CURVE:
ain_k = ain.read_voltage() / self.script.config.MAX_INPUT_VOLTAGE * 2 -1 # [-1, 1]
self.curve_k = (self.curve_k + ain_k) / 2
t = 1000.0/self.frequency # Hz -> ms
elapsed_ms = time.ticks_diff(now, self.last_tick_at)
self.voltage_out = self.curve.value_at(elapsed_ms / t, self.curve_k) * (self.script.config.MAX_VOLTAGE - self.script.config.MIN_VOLTAGE) + self.script.config.MIN_VOLTAGE
if clip_mode == CLIP_MODE_LIMIT:
self.voltage_out = self.clip_limit(self.voltage_out)
elif clip_mode == CLIP_MODE_FOLD:
self.voltage_out = self.clip_fold(self.voltage_out)
elif clip_mode == CLIP_MODE_THRU:
self.voltage_out = self.clip_thru(self.voltage_out)
self.cv_out.voltage(self.voltage_out)
self.vizualization_samples.append(int((self.voltage_out - self.script.config.MIN_VOLTAGE) / (self.script.config.MAX_VOLTAGE - self.script.config.MIN_VOLTAGE) * OLED_HEIGHT/3))
if len(self.vizualization_samples) > OLED_WIDTH // 2:
self.vizualization_samples.pop(0)
if v < self.script.config.MIN_VOLTAGE:
return self.script.config.MIN_VOLTAGE
elif v > self.script.config.MAX_VOLTAGE:
return self.script.config.MAX_VOLTAGE
if v < self.script.config.MIN_VOLTAGE:
return self.script.config.MIN_VOLTAGE - v
elif v > self.script.config.MAX_VOLTAGE:
return self.script.config.MAX_VOLTAGE + (self.script.config.MAX_VOLTAGE - v)
if v < self.script.config.MIN_VOLTAGE:
return self.script.config.MAX_VOLTAGE - (self.script.config.MIN_VOLTAGE - v)
elif v > self.script.config.MAX_VOLTAGE:
return self.script.config.MIN_VOLTAGE - (self.script.config.MAX_VOLTAGE - v)
class Bezier(EuroPiScript):
cfg = self.load_state_json()
.with_unlocked_knob("channel_a")
.with_locked_knob("channel_b", initial_percentage_value=cfg.get("channel_b_frequency", 0.5))
.with_unlocked_knob("channel_a")
.with_locked_knob("channel_b", initial_percentage_value=cfg.get("channel_b_curve", 0.5))
# How do we handle clipping?
self.clip_mode = cfg.get("clip_mode", CLIP_MODE_LIMIT)
# How do we combine the gate outputs for cv6?
if self.config.LOGIC_MODE == "and":
self.gate_logic = LogicalAnd()
elif self.config.LOGIC_MODE == "or":
self.gate_logic = LogicalOr()
elif self.config.LOGIC_MODE == "xor":
self.gate_logic = LogicalXor()
elif self.config.LOGIC_MODE == "nand":
self.gate_logic = LogicalNand()
elif self.config.LOGIC_MODE == "nor":
self.gate_logic = LogicalNor()
elif self.config.LOGIC_MODE == "xnor":
self.gate_logic = LogicalXnor()
raise Exception(f"Unknown logic mode {self.config.LOGIC_MODE}")
# Are the settings dirty & need saving?
self.settings_dirty = False
self.curve_a = OutputChannel(self, self.frequency_in["channel_a"], self.curve_in["channel_a"], cv1)
self.curve_b = OutputChannel(self, self.frequency_in["channel_b"], self.curve_in["channel_b"], cv2)
# If DIN receives a signal, force the curves to change target value
self.force_voltage_change = False
self.force_voltage_change = True
self.clip_mode = (self.clip_mode + 1) % N_CLIP_MODES
self.settings_dirty = True
ssoled.notify_user_interaction()
self.curve_in.set_current("channel_b")
self.frequency_in.set_current("channel_b")
ssoled.notify_user_interaction()
self.curve_in.set_current("channel_a")
self.frequency_in.set_current("channel_a")
self.settings_dirty = True
ssoled.notify_user_interaction()
"""Return the static configuration options for this class
def restrict_input_voltage(v):
if v > europi_config.MAX_INPUT_VOLTAGE:
return europi_config.MAX_INPUT_VOLTAGE
configuration.floatingPoint(
name="MAX_INPUT_VOLTAGE",
maximum=europi_config.MAX_INPUT_VOLTAGE,
default=restrict_input_voltage(10.0)
configuration.floatingPoint(
maximum=europi_config.MAX_OUTPUT_VOLTAGE,
configuration.floatingPoint(
maximum=europi_config.MAX_OUTPUT_VOLTAGE,
default=europi_config.MAX_OUTPUT_VOLTAGE
configuration.floatingPoint(
configuration.floatingPoint(
choices=["frequency", "curve"],
choices=["and", "or", "xor", "nand", "nor", "xnor"],
"""Write the persistent settings file
"channel_b_curve": self.curve_in["channel_b"].percent(),
"channel_b_frequency": self.frequency_in["channel_b"].percent(),
"clip_mode": self.clip_mode
self.save_state_json(cfg)
self.settings_dirty = False
def draw_graph(self, curve):
for i in range(len(curve.vizualization_samples)):
ssoled.pixel(i+OLED_WIDTH//2, OLED_HEIGHT - 1 - curve.vizualization_samples[i], 1)
HALF_VOLTAGE = (self.config.MIN_VOLTAGE + self.config.MAX_VOLTAGE) / 2
# Used to detect user interaction with the knobs
prev_freq_value = self.frequency_in.current.percent()
prev_curve_value = self.curve_in.current.percent()
if self.force_voltage_change:
self.force_voltage_change = False
self.curve_a.change_voltage()
self.curve_b.change_voltage()
# set the main outputs, calculate the bezier curves
# set cv3 to the average of the channels
cv3.voltage((self.curve_a.voltage_out + self.curve_b.voltage_out) / 2)
# set the trigger state for cv4
if time.ticks_diff(now, self.curve_a.last_tick_at) <= GATE_DURATION:
# set cv5 high/low, depending on the value of channel b
if self.curve_b.voltage_out >= HALF_VOLTAGE:
logic_gate_on = self.gate_logic.compare(gate_a, gate_b)
# save settings if needed
# check if we've moved the knobs manually
# Wake up from the screensaver if we have
current_freq_value = self.frequency_in.current.percent()
current_curve_value = self.curve_in.current.percent()
if abs(current_freq_value - prev_freq_value) >= UI_DEADZONE or abs(current_curve_value - prev_curve_value) >= UI_DEADZONE:
ssoled.notify_user_interaction()
prev_freq_value = current_freq_value
prev_curve_value = current_curve_value
# +------------------------+
# +------------------------+
# Highlight either the first or second row to indicate it's being edited
channel_a_active = self.curve_in.current == self.curve_in["channel_a"]
ssoled.fill_rect(0, 0, OLED_WIDTH, CHAR_HEIGHT+2, 1)
ssoled.fill_rect(0, CHAR_HEIGHT+1, OLED_WIDTH, CHAR_HEIGHT+2, 1)
ssoled.text(f"A {self.curve_a.frequency:0.2f}Hz {self.curve_a.curve_k:+0.2f}", 1, 1, 0 if channel_a_active else 1)
ssoled.text(f"B {self.curve_b.frequency:0.2f}Hz {self.curve_b.curve_k:+0.2f}", 1, CHAR_HEIGHT+2, 1 if channel_a_active else 0)
ssoled.text(CLIP_MODE_NAMES[self.clip_mode], 1, 2*CHAR_HEIGHT + 4, 1)
self.draw_graph(self.curve_a)
self.draw_graph(self.curve_b)
if __name__ == "__main__":