# 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.
"""Clock multiplier & divider
from collections import OrderedDict
from europi_script import EuroPiScript
from experimental.a_to_d import AnalogReaderDigitalWrapper
from experimental.knobs import KnobBank
from experimental.screensaver import Screensaver
# This script operates in microseconds, so for convenience define one second as a constant
# Automatically reset if we receive no input clocks after 5 seconds
# This lets us handle resonably slow input clocks, while also providing some reasonable timing
CLOCK_IN_TIMEOUT = 5 * ONE_SECOND
"""Re-implements the str.ljust method from standard Python
@param s A string to left-align with spaces
@param length The desired final length of the string. If len(s) is greater or equal to this, no padding is done
@return The padding string
n_spaces = max(0, length - len(s))
"""A control class that handles a single output
## The smallest common multiple of the allowed clock divisions (2, 3, 4, 5, 6, 8, 12)
# Used to reset the input gate counter to avoid integer overflows/performance degredation with large values
def __init__(self, output_port, modifier):
@param output_port One of the six output CV ports, e.g. cv1, cv2, etc... that this class will control
@param modifier The initial clock modifier for this output channel
self.last_external_clock_at = time.ticks_us()
self.last_interval_us = 0
self.output_port = output_port
# Should the output be high or low?
# Used to implement basic gate-skipping for clock divisions
self.input_gate_counter = 0
def set_external_clock(self, ticks_us):
"""Notify this output when the last external clock signal was received.
The calculate_state function will use this to calculate the duration of the high/low phases
if self.last_external_clock_at != ticks_us:
self.last_interval_us = time.ticks_diff(ticks_us, self.last_external_clock_at)
self.last_external_clock_at = ticks_us
self.input_gate_counter += 1
if self.input_gate_counter >= self.MAX_GATE_COUNT:
self.input_gate_counter = 0
def calculate_state(self, ticks_us):
"""Calculate whether this output should be high or low based on the current time
Must be called before calling set_output_voltage
@param ticks_us The current time in microseconds; passed as a parameter to synchronize multiple channels
# We're in clock multiplication mode; calculate the duration of output gates and set high/low state
gate_duration_us = self.last_interval_us / self.modifier
hi_lo_duration_us = gate_duration_us / 2
# The time elapsed since our last external clock
elapsed_us = time.ticks_diff(ticks_us, self.last_external_clock_at)
# The number of phases that have happened since the last incoming clock
n_phases = elapsed_us // hi_lo_duration_us
self.is_high = n_phases % 2 == 0
# We're in clock division mode; just do a simple gate-skip to stay in sync with the input
n_gates = round(1.0 / self.modifier)
self.is_high = self.input_gate_counter % (n_gates * 2) < n_gates
def set_output_voltage(self):
"""Set the output voltage either high or low.
Must be called after calling calculate_state
"""Reset the pattern to the initial position
self.input_gate_counter = 0
class ClockModifier(EuroPiScript):
"""The main script class; multiplies and divides incoming clock signals
state = self.load_state_json()
.with_unlocked_knob("channel_a") \
.with_locked_knob("channel_b", initial_percentage_value=state.get("mod_cv2", 0.5)) \
.with_locked_knob("channel_c", initial_percentage_value=state.get("mod_cv3", 0.5)) \
.with_unlocked_knob("channel_a") \
.with_locked_knob("channel_b", initial_percentage_value=state.get("mod_cv5", 0.5)) \
.with_locked_knob("channel_c", initial_percentage_value=state.get("mod_cv6", 0.5)) \
## The time the last rising edge of the clock was recorded
# Initially negative 1s to avoid starting the module
self.last_clock_at = -1000
ClockOutput(cv, 1.0) for cv in cvs
## Gui labels to indicate what row of modifiers we're adjusting
self.channel_markers = ['>', ' ', ' ']
self.clock_modifiers = OrderedDict([
# Indicates that the modifiers have changed and a save is required
"""Activate channel b controls while b1 is held
self.k1_bank.set_current("channel_b")
self.k2_bank.set_current("channel_b")
self.channel_markers[0] = ' '
self.channel_markers[1] = '>'
self.channel_markers[2] = ' '
"""Activate channel c controls while b1 is held
self.k1_bank.set_current("channel_c")
self.k2_bank.set_current("channel_c")
self.channel_markers[0] = ' '
self.channel_markers[1] = ' '
self.channel_markers[2] = '>'
"""Revert to channel a when the button is released
self.k1_bank.set_current("channel_a")
self.k2_bank.set_current("channel_a")
self.channel_markers[0] = '>'
self.channel_markers[1] = ' '
self.channel_markers[2] = ' '
"""Revert to channel a when the button is released
self.k1_bank.set_current("channel_a")
self.k2_bank.set_current("channel_a")
self.channel_markers[0] = '>'
self.channel_markers[1] = ' '
self.channel_markers[2] = ' '
"""Record the start time of our rising edge
self.last_clock_at = time.ticks_us()
"""Reset all channels when AIN goes high
for output in self.outputs:
self.d_ain = AnalogReaderDigitalWrapper(
"""Save the clock modifiers for channels 2, 3, 5, 6 to the config file for loading
Channels 1 and 4 are read directly from the knob positions on startup, and are considered volatile
"mod_cv2": self.k1_bank["channel_b"].percent(),
"mod_cv3": self.k1_bank["channel_c"].percent(),
"mod_cv5": self.k2_bank["channel_b"].percent(),
"mod_cv6": self.k2_bank["channel_c"].percent()
self.save_state_json(state)
screensaver = Screensaver()
last_render_at = time.ticks_us()
knob_choices = list(self.clock_modifiers.keys())
# update AIN so its rising edge callback can fire
# Save the clock modifiers for channels 2, 3, 5, 6 if they've been edited
# Get the clock modifiers for all 6 channels and apply them to the outputs
self.k1_bank["channel_a"].choice(knob_choices),
self.k1_bank["channel_b"].choice(knob_choices),
self.k1_bank["channel_c"].choice(knob_choices),
self.k2_bank["channel_a"].choice(knob_choices),
self.k2_bank["channel_b"].choice(knob_choices),
self.k2_bank["channel_c"].choice(knob_choices)
for i in range(len(mods)):
self.outputs[i].modifier = self.clock_modifiers[mods[i]]
# if we don't get an external signal within the timeout duration, reset the outputs
if time.ticks_diff(now, self.last_clock_at) <= CLOCK_IN_TIMEOUT:
# separate calculating the high/low state and setting the output voltage into two loops
# this helps reduce phase-shifting across outputs
for output in self.outputs:
output.set_external_clock(self.last_clock_at)
output.calculate_state(now)
for output in self.outputs:
output.set_output_voltage()
for output in self.outputs:
# This only needs to be done if the modifiers have changed or a button has been pressed/released
self.ui_dirty = self.ui_dirty or any([mods[i] != prev_mods[i] for i in range(len(mods))])
# Yes, this is a very long string, but it centers nicely
# It looks something like this:
f"{self.channel_markers[0]} 1:{ljust(mods[0], 3)} 4:{ljust(mods[3], 3)}\n{self.channel_markers[1]} 2:{ljust(mods[1], 3)} 5:{ljust(mods[4], 3)}\n{self.channel_markers[2]} 3:{ljust(mods[2], 3)} 6:{ljust(mods[5], 3)}"
last_render_at = time.ticks_us()
elif time.ticks_diff(now, last_render_at) > screensaver.ACTIVATE_TIMEOUT_US:
last_render_at = time.ticks_add(now, -screensaver.ACTIVATE_TIMEOUT_US)
for i in range(len(mods)):