# 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.
"""Gaussian-based, clocked, quantized CV generator
Inspired by Magnetic Freak's Gaussian module.
@author Chris Iverach-Brereton
from europi_script import EuroPiScript
from experimental.bisect import bisect_left
from experimental.knobs import *
from experimental.random_extras import normal
from experimental.screensaver import Screensaver
"""Generic class for different output modes"""
def __init__(self, name):
"""Abstract function to be implemented by subclasses
@param v The input voltage to assign to a bin
raise Exception("Not implemented")
class ContinuousBin(OutputBin):
"""Smooth, continuous output"""
def __init__(self, name):
class VoltageBin(OutputBin):
"""Quantizes a random voltage to the closest bin"""
def __init__(self, name, bins):
"""Create a new set of bins
@param name The human-readable display name for this set of bins
@param bins A list of voltages we are allowed to output
self.bins = [float(b) for b in bins]
"""Quantize an input voltage to the closest bin. If two bins are equally close, choose the lower one.
Our internal bins are sorted, so we can do a binary search for that sweet, sweet O(log(n)) efficiency
@param v A voltage in the range 0-10 to quantize
@return The closest voltage bin to @v
i = bisect_left(self.bins, v)
if abs(v - next) < abs(v - prev):
"""A class that handles setting a CV output on or after a given tick"""
# We're not currently queued to do any output
# We've been assigned a time in the future to set the voltage
# The voltage has been applied and the gate is currently high
def __init__(self, cv, gate):
"""Create a new delayed output manager
@param cv The output for CV voltage
@param gate The output for a gate voltage
self.state = self.STATE_IDLE
self.gate_high_tick = time.ticks_ms()
self.gate_low_tick = time.ticks_ms()
def process(self, now=None):
if self.state == self.STATE_WAITING and time.ticks_diff(now, self.gate_high_tick) >= 0:
self.cv.voltage(self.target_volts)
self.state = self.STATE_GATE_HIGH
elif self.state == self.STATE_GATE_HIGH and time.ticks_diff(now, self.gate_low_tick) >= 0:
self.state = self.STATE_IDLE
def voltage_at(self, v, tick, gate_duration_ms=10):
"""Specify the voltage we want to apply at the desired tick
Call @process() to actually apply the voltage if needed
@param v The desired voltags (volts)
@param tick The tick (ms) we want the voltage to change at
@param gate_duration_ms The desired duration of the high cycle of the output gate
self.state = self.STATE_WAITING
self.gate_high_tick = tick
self.gate_low_tick = time.ticks_add(self.gate_high_tick, gate_duration_ms)
class Sigma(EuroPiScript):
"""The main class for this script
Handles all I/O, renders the UI
## Voltage bins for bin mode
ContinuousBin("Continuous"),
VoltageBin("Bin 2", [0, 10]),
VoltageBin("Bin 3", [0, 5, 10]),
VoltageBin("Bin 6", [0, 2, 4, 6, 8, 10]),
VoltageBin("Bin 7", [0, 1.7, 3.4, 5, 6.6, 8.3, 10]),
VoltageBin("Bin 9", [0, 1.25, 2.5, 3.75, 5, 6.25, 7.5, 8.75, 10])
# create bins for the quantized 1V/oct modes
VOLTS_PER_SEMITONE = 1.0 / 12
VOLTS_PER_QUARTERTONE = 1.0 / 24
tones.append(oct + VOLTS_PER_TONE * tone)
for semitone in range(12):
semitones.append(oct + VOLTS_PER_SEMITONE * semitone)
for quartertone in range(24):
quartertones.append(oct + VOLTS_PER_QUARTERTONE * quartertone)
self.voltage_bins.append(VoltageBin("Tone", tones))
self.voltage_bins.append(VoltageBin("Semitone", semitones))
self.voltage_bins.append(VoltageBin("Quartertone", quartertones))
cfg = self.load_state_json()
self.mean = cfg.get("mean", 0.5)
self.stdev = cfg.get("stdev", 0.5)
self.ain_route = cfg.get("ain_route", 0)
self.voltage_bin = cfg.get("bin", 0)
self.jitter = cfg.get("jitter", 0)
# create the lockable knobs
# Note that this does mean _sometimes_ you'll need to sweep the knob all the way left/right
.with_unlocked_knob("mean")
.with_locked_knob("jitter", initial_percentage_value=cfg.get("jitter", 0.5))
.with_unlocked_knob("stdev")
.with_locked_knob("bin", initial_percentage_value=self.voltage_bin / len(self.voltage_bins))
self.config_dirty = False
self.output_dirty = False
self.last_interaction_at = time.ticks_ms()
self.screensaver = Screensaver()
self.last_clock_at = time.ticks_ms()
self.clock_duration_ms = 0
self.clock_duty_cycle_ms = 0
self.k1_bank.set_current("jitter")
self.k2_bank.set_current("bin")
self.last_interaction_at = time.ticks_ms()
self.k1_bank.set_current("mean")
self.k2_bank.set_current("stdev")
self.ain_route = (self.ain_route + 1) % self.N_AIN_ROUTES
self.last_interaction_at = time.ticks_ms()
self.clock_duration_ms = time.ticks_diff(now, self.last_clock_at)
self.clock_duty_cycle_ms = time.ticks_diff(now, self.last_clock_at)
"""Save the current state to the persistence file"""
self.config_dirty = False
"ain_route": self.ain_route,
self.save_state_json(cfg)
self.mean = self.k1_bank["mean"].percent()
self.stdev = self.k2_bank["stdev"].percent()
self.jitter = self.k1_bank["jitter"].percent()
self.voltage_bin = int(self.k2_bank["bin"].percent() * len(self.voltage_bins))
# Apply attenuation to our CV-controlled input
if self.ain_route == self.AIN_ROUTE_MEAN:
self.mean = self.mean * ain.percent()
elif self.ain_route == self.AIN_ROUTE_STDEV:
self.stdev = self.stdev * ain.percent()
elif self.ain_route == self.AIN_ROUTE_JITTER:
self.jitter = self.jitter * ain.percent()
elif self.ain_route == self.AIN_ROUTE_BIN:
self.voltage_bin = int(self.k2_bank["bin"].percent() * ain.percent() * len(self.voltage_bins))
if self.voltage_bin == len(self.voltage_bins):
self.voltage_bin = len(self.voltage_bins) - 1 # keep the index in bounds if we reach 1.0
def set_outputs(self, now):
def calculate_jitter(self, now):
self.output_dirty = False
if cv == self.outputs[0]:
target_tick = time.ticks_add(now, int(abs(normal(mean = 0, stdev = self.jitter) * self.clock_duration_ms / 4)))
x = normal(mean = self.mean * MAX_OUTPUT_VOLTAGE, stdev = self.stdev * 2)
v = self.voltage_bins[self.voltage_bin].closest(x)
prev_mean = int(self.mean * DISPLAY_PRECISION)
prev_stdev = int(self.stdev * DISPLAY_PRECISION)
prev_jitter = int(self.jitter * DISPLAY_PRECISION)
prev_bin = self.voltage_bin
self.calculate_jitter(now)
new_mean = int(self.mean * DISPLAY_PRECISION)
new_stdev = int(self.stdev * DISPLAY_PRECISION)
new_jitter = int(self.jitter * DISPLAY_PRECISION)
self.ui_dirty = (self.ui_dirty or
new_stdev != prev_stdev or
new_jitter != prev_jitter or
self.voltage_bin != prev_bin
self.last_interaction_at = now
prev_bin = self.voltage_bin
if time.ticks_diff(now, self.last_interaction_at) > self.screensaver.ACTIVATE_TIMEOUT_MS:
last_interaction_at = time.ticks_add(now, -self.screensaver.ACTIVATE_TIMEOUT_MS*2)
oled.centre_text(f"""{self.mean:0.2f} {self.stdev:0.2f} {self.jitter:0.2f}
{self.voltage_bins[self.voltage_bin]}
CV: {self.AIN_ROUTE_NAMES[self.ain_route]}""")
if __name__ == "__main__":