Skip to content

Commit 9362eca

Browse files
authored
Reduce latency in Clock Modifer script (#363)
* Use microseconds for calculating the timing instead of milliseconds. Keep track of when the GUI needs re-rendering instead of always redrawing to reduce latency * Simplify state calculation * Minor linting * Increase the timeout for the incoming clock to 5s (from 1s). Re-implement the clock multiplication & division to force it to re-sync to the incoming gates. Division is now implemented as a simple gate-skipper. Multiplication phase times are based on the last incoming gate time instead of a free-running counter * Add an upper bound to the gate counter to prevent overflows/performance degredation with large integers * Add a screensaver since the UI is otherwise pretty static. Should prevent burn-in when used for extended periods * Fix the order of the screensaver & blank conditions so they actually fire correctly * Just remove the blanking; it always forces a re-render, which can impact the timing * Fix a bug where the screensaver can appear stuck when time.ticks_diff wraps around during a notably long interval * Prevent ticks_diff from wrapping around too far by synthetically incrementing the last-render time
1 parent 71f178a commit 9362eca

File tree

3 files changed

+97
-38
lines changed

3 files changed

+97
-38
lines changed

software/contrib/clock_mod.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ are not adjustable and are fixed to approximately a 50% duty cycle (some roundin
1818
| `cv1-6` | Multiplied/divided clock signals |
1919

2020
The outputs will begin firing automatically when clock signals are received on `din`, and will stop if the input
21-
signals are stopped for 1s or longer. Upon stopping all output channels will reset. (NOTE: this means the signal
22-
coming into `din` cannot be 1Hz or slower!)
21+
signals are stopped for 5s or longer. Upon stopping all output channels will reset. (NOTE: this means the signal
22+
coming into `din` cannot be 0.2Hz or slower!)
2323

2424
Applying a signal of at least 0.8V to `ain` will reset all output channels.
2525

software/contrib/clock_mod.py

+91-35
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,18 @@
99
from europi_script import EuroPiScript
1010
from experimental.a_to_d import AnalogReaderDigitalWrapper
1111
from experimental.knobs import KnobBank
12+
from experimental.screensaver import Screensaver
1213
from math import floor
1314

1415

16+
# This script operates in microseconds, so for convenience define one second as a constant
17+
ONE_SECOND = 1000000
18+
19+
# Automatically reset if we receive no input clocks after 5 seconds
20+
# This lets us handle resonably slow input clocks, while also providing some reasonable timing
21+
CLOCK_IN_TIMEOUT = 5 * ONE_SECOND
22+
23+
1524
def ljust(s, length):
1625
"""Re-implements the str.ljust method from standard Python
1726
@@ -22,53 +31,69 @@ def ljust(s, length):
2231
n_spaces = max(0, length - len(s))
2332
return s + ' '*n_spaces
2433

34+
2535
class ClockOutput:
2636
"""A control class that handles a single output
2737
"""
38+
## The smallest common multiple of the allowed clock divisions (2, 3, 4, 5, 6, 8, 12)
39+
#
40+
# Used to reset the input gate counter to avoid integer overflows/performance degredation with large values
41+
MAX_GATE_COUNT = 120
42+
2843
def __init__(self, output_port, modifier):
2944
"""Constructor
3045
3146
@param output_port One of the six output CV ports, e.g. cv1, cv2, etc... that this class will control
3247
@param modifier The initial clock modifier for this output channel
3348
"""
34-
self.last_external_clock_at = time.ticks_ms()
35-
self.last_interval_ms = 0
49+
self.last_external_clock_at = time.ticks_us()
50+
self.last_interval_us = 0
3651
self.modifier = modifier
3752
self.output_port = output_port
3853

54+
# Should the output be high or low?
3955
self.is_high = False
40-
self.last_state_change_at = time.ticks_ms()
4156

42-
def set_external_clock(self, ticks_ms):
57+
# Used to implement basic gate-skipping for clock divisions
58+
self.input_gate_counter = 0
59+
60+
def set_external_clock(self, ticks_us):
4361
"""Notify this output when the last external clock signal was received.
4462
4563
The calculate_state function will use this to calculate the duration of the high/low phases
4664
of the output gate
4765
"""
48-
if self.last_external_clock_at != ticks_ms:
49-
self.last_interval_ms = time.ticks_diff(ticks_ms, self.last_external_clock_at)
50-
self.last_external_clock_at = ticks_ms
66+
if self.last_external_clock_at != ticks_us:
67+
self.last_interval_us = time.ticks_diff(ticks_us, self.last_external_clock_at)
68+
self.last_external_clock_at = ticks_us
69+
70+
self.input_gate_counter += 1
71+
if self.input_gate_counter >= self.MAX_GATE_COUNT:
72+
self.input_gate_counter = 0
5173

52-
def calculate_state(self, ms):
74+
def calculate_state(self, ticks_us):
5375
"""Calculate whether this output should be high or low based on the current time
5476
5577
Must be called before calling set_output_voltage
5678
57-
@param ms The current time in ms; passed as a parameter to synchronize multiple channels
79+
@param ticks_us The current time in microseconds; passed as a parameter to synchronize multiple channels
5880
"""
59-
gate_duration_ms = self.last_interval_ms / self.modifier
60-
hi_lo_duration_ms = gate_duration_ms / 2
81+
if self.modifier >= 1:
82+
# We're in clock multiplication mode; calculate the duration of output gates and set high/low state
83+
gate_duration_us = self.last_interval_us / self.modifier
84+
hi_lo_duration_us = gate_duration_us / 2
6185

62-
elapsed_ms = time.ticks_diff(ms, self.last_state_change_at)
86+
# The time elapsed since our last external clock
87+
elapsed_us = time.ticks_diff(ticks_us, self.last_external_clock_at)
6388

64-
if elapsed_ms > hi_lo_duration_ms:
65-
self.last_state_change_at = ms
66-
if self.is_high:
67-
self.is_high = False
89+
# The number of phases that have happened since the last incoming clock
90+
n_phases = elapsed_us // hi_lo_duration_us
6891

69-
else:
70-
self.is_high = True
71-
self.output_port.on()
92+
self.is_high = n_phases % 2 == 0
93+
else:
94+
# We're in clock division mode; just do a simple gate-skip to stay in sync with the input
95+
n_gates = round(1.0 / self.modifier)
96+
self.is_high = self.input_gate_counter % (n_gates * 2) < n_gates
7297

7398
def set_output_voltage(self):
7499
"""Set the output voltage either high or low.
@@ -85,12 +110,14 @@ def reset(self):
85110
"""
86111
self.is_high = False
87112
self.output_port.off()
88-
self.last_state_change_at = time.ticks_ms()
113+
self.input_gate_counter = 0
114+
89115

90116
class ClockModifier(EuroPiScript):
91117
"""The main script class; multiplies and divides incoming clock signals
92118
"""
93119
def __init__(self):
120+
self.ui_dirty = False
94121
state = self.load_state_json()
95122

96123
self.k1_bank = (
@@ -152,6 +179,7 @@ def b1_rising():
152179
self.channel_markers[0] = ' '
153180
self.channel_markers[1] = '>'
154181
self.channel_markers[2] = ' '
182+
self.ui_dirty = True
155183

156184
@b2.handler
157185
def b2_rising():
@@ -162,6 +190,7 @@ def b2_rising():
162190
self.channel_markers[0] = ' '
163191
self.channel_markers[1] = ' '
164192
self.channel_markers[2] = '>'
193+
self.ui_dirty = True
165194

166195
@b1.handler_falling
167196
def b1_falling():
@@ -173,6 +202,7 @@ def b1_falling():
173202
self.channel_markers[1] = ' '
174203
self.channel_markers[2] = ' '
175204
self.state_dirty = True
205+
self.ui_dirty = True
176206

177207
@b2.handler_falling
178208
def b2_falling():
@@ -184,12 +214,13 @@ def b2_falling():
184214
self.channel_markers[1] = ' '
185215
self.channel_markers[2] = ' '
186216
self.state_dirty = True
217+
self.ui_dirty = True
187218

188219
@din.handler
189220
def on_din():
190221
"""Record the start time of our rising edge
191222
"""
192-
self.last_clock_at = time.ticks_ms()
223+
self.last_clock_at = time.ticks_us()
193224

194225
def on_ain():
195226
"""Reset all channels when AIN goes high
@@ -218,7 +249,20 @@ def save_state(self):
218249
def main(self):
219250
"""The main loop
220251
"""
252+
screensaver = Screensaver()
253+
last_render_at = time.ticks_us()
254+
221255
knob_choices = list(self.clock_modifiers.keys())
256+
257+
prev_mods = [
258+
knob_choices[0],
259+
knob_choices[0],
260+
knob_choices[0],
261+
knob_choices[0],
262+
knob_choices[0],
263+
knob_choices[0],
264+
]
265+
222266
while True:
223267
# update AIN so its rising edge callback can fire
224268
self.d_ain.update()
@@ -240,9 +284,9 @@ def main(self):
240284
for i in range(len(mods)):
241285
self.outputs[i].modifier = self.clock_modifiers[mods[i]]
242286

243-
# if we don't get an external signal within 1s, stop
244-
now = time.ticks_ms()
245-
if time.ticks_diff(now, self.last_clock_at) < 1000:
287+
# if we don't get an external signal within the timeout duration, reset the outputs
288+
now = time.ticks_us()
289+
if time.ticks_diff(now, self.last_clock_at) <= CLOCK_IN_TIMEOUT:
246290
# separate calculating the high/low state and setting the output voltage into two loops
247291
# this helps reduce phase-shifting across outputs
248292
for output in self.outputs:
@@ -256,17 +300,29 @@ def main(self):
256300
output.reset()
257301

258302
# Update the GUI
259-
# Yes, this is a very long string, but it centers nicely
260-
# It looks something like this:
261-
#
262-
# > 1: x1 4: /2
263-
# 2: x2 5: x3
264-
# 3: /4 6: /3
265-
#
266-
oled.fill(0)
267-
oled.centre_text(
268-
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)}"
269-
)
303+
# This only needs to be done if the modifiers have changed or a button has been pressed/released
304+
self.ui_dirty = self.ui_dirty or any([mods[i] != prev_mods[i] for i in range(len(mods))])
305+
if self.ui_dirty:
306+
# Yes, this is a very long string, but it centers nicely
307+
# It looks something like this:
308+
#
309+
# > 1: x1 4: /2
310+
# 2: x2 5: x3
311+
# 3: /4 6: /3
312+
#
313+
oled.fill(0)
314+
oled.centre_text(
315+
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)}"
316+
)
317+
self.ui_dirty = False
318+
last_render_at = time.ticks_us()
319+
elif time.ticks_diff(now, last_render_at) > screensaver.ACTIVATE_TIMEOUT_US:
320+
last_render_at = time.ticks_add(now, -screensaver.ACTIVATE_TIMEOUT_US)
321+
screensaver.draw()
322+
323+
for i in range(len(mods)):
324+
prev_mods[i] = mods[i]
325+
270326

271327
if __name__=="__main__":
272328
ClockModifier().main()

software/firmware/experimental/screensaver.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ class Screensaver:
3030

3131
## Standard duration before we activate the screensaver
3232
ACTIVATE_TIMEOUT_MS = 1000 * 60 * 5
33+
ACTIVATE_TIMEOUT_US = ACTIVATE_TIMEOUT_MS * 1000
3334

3435
## Standard duration before we blank the screen
3536
BLANK_TIMEOUT_MS = 1000 * 60 * 20
37+
BLANK_TIMEOUT_US = BLANK_TIMEOUT_MS * 1000
3638

3739
def __init__(self):
3840
self.last_logo_reposition_at = 0
@@ -49,7 +51,8 @@ def draw(self, force=False):
4951
LOGO_UPDATE_INTERVAL = 2000
5052

5153
now = utime.ticks_ms()
52-
if force or time.ticks_diff(now, self.last_logo_reposition_at) > LOGO_UPDATE_INTERVAL:
54+
elapsed_ms = time.ticks_diff(now, self.last_logo_reposition_at)
55+
if force or abs(elapsed_ms) >= LOGO_UPDATE_INTERVAL:
5356
self.last_logo_reposition_at = now
5457
x = random.randint(0, OLED_WIDTH - self.LOGO_WIDTH)
5558
y = random.randint(0, OLED_HEIGHT - self.LOGO_HEIGHT)

0 commit comments

Comments
 (0)