Skip to content

Commit 694f65b

Browse files
authored
Add Traffic script (#311)
* Blank the screen * Fix a bug in the ain on-fall callback * Revert "Remove magic numbers for the I/O pins, replace with named constants" This reverts commit 327c3cc. * Show the gate length (to the nearest ms) on the screen, add the screensaver to G&T. Move the screensaver activation & blank timeouts into the Screensaver class itself instead of redefining them externally * Add a new wrapped Oled class that automatically checks for screensaver & screen-blanking. Use this wrapper instead of europi.oled in the 3 scripts that use the screensaver * Revert "Increase the sensitivity of the knobs for waking up the screensaver" This reverts commit 0f21705. * Change centre_text so it doesn't call .show() by default, add clear_first and auto_show arguments to centre_text, modify all extant calls to centre_text to use the correct auto_show behaviour * Go back to high-sensitivity for the knobs * More Linting. Remove the time import from euclid as it's not used anymore * Remove lingering references to .screensaver * Move module imports first, re-add time to deal with CI failures * Try using utime instead of time to see if that makes CI happier * Comment-out the failing script to see if the error moves to the next one or not * Revert the change to make centre_text auto-show by default, revert associated changes to other scripts * Revert screensaver-related changes & linting updates to non-essential scripts * Initial commit of the Traffic script * Save the gains for channels 2 and 3 since they aren't read immediately by the current knob positions * Round the knob readings to 2 decimal places to help reduce noise * Linting on the new firmware module * Missing newline * One more * Revert changes to Kompari from rebase * Remove the clear() function from the oled with screensaver * .voltage(5) -> .on() * Expand the docstring for AnalogReaderDigitalWrapper for clarity * Re-use the same function for the falling edge of either button * Re-implement how Traffic _actually_ works; I completely misunderstood it the first time around and clearly didn't do my research properly * Update the main readme too * Update the readme to indicate outputs respond immediately to knob changes * Streamline the GUI, round knobs to 3 decimal places instead of 2 * Make it clear that din = trigger 1 and ain = trigger 2 * Add 50ms of padding to allow better handling of "simultaneous" triggers -- the limit of human hearing is ~100ms, so half that should be close enough. This lets input 1 properly take priority when both signals trigger at almost the same time * Add a note about lockable knob behaviour * Increase samples when reading the knobs' values to reduce flickering
1 parent 116ac7f commit 694f65b

File tree

6 files changed

+288
-3
lines changed

6 files changed

+288
-3
lines changed

software/contrib/README.md

+7
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,13 @@ Users have the x, y, and z values of the output of each attractor model availabl
178178
<i>Author: [seanbechhofer](https://github.com/seanbechhofer)</i>
179179
<br><i>Labels: gates, triggers, randomness</i>
180180

181+
### Traffic \[ [documentation](/software/contrib/traffic.md) | [script](/software/contrib/traffic.py) \]
182+
A re-imagining of [Jasmine and Olive Tree's Traffic](https://jasmineandolivetrees.com/products/traffic) module. Triggers are sent to both inputs
183+
generating CV signals based on which trigger fired most recently and a pair of gains per channel.
184+
185+
<i>Author: [chrisib](http://github.com/chrisib)</i>
186+
<br><i>Labels: sequencer, gate, triggers</i>
187+
181188
### Turing Machine \[ [documentation](/software/contrib/turing_machine.md) | [script](/software/contrib/turing_machine.py) \]
182189
A script meant to recreate the [Music Thing Modular Turning Machine Random Sequencer](https://musicthing.co.uk/pages/turing.html)
183190
as faithfully as possible on the EuroPi hardware.

software/contrib/menu.py

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
["Seq. Switch", "contrib.sequential_switch.SequentialSwitch"],
5555
["Smooth Rnd Volts", "contrib.smooth_random_voltages.SmoothRandomVoltages"],
5656
["StrangeAttractor", "contrib.strange_attractor.StrangeAttractor"],
57+
["Traffic", "contrib.traffic.Traffic"],
5758
["Turing Machine", "contrib.turing_machine.EuroPiTuringMachine"],
5859

5960
["_Calibrate", "calibrate.Calibrate"], # this one should always be second to last!

software/contrib/traffic.md

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Traffic
2+
3+
This script is inspired by [Jasmine & Olive Tree's Traffic](https://jasmineandolivetrees.com/products/traffic).
4+
Instead of 3 trigger inputs, this version only has 2.
5+
6+
Traffic has 3 output channels (`cv1`-`cv3`), which output CVs signals. The value of the output signal depends on:
7+
1. which input the trigger was received most recently and
8+
2. the gains for that trigger on each channel.
9+
10+
For example, suppose the gains for channel A are set to `[0.25, 0.6]`. Whenever a trigger on `din` (trigger input 1) is
11+
received, channel A (`cv1`) will output `MAX_VOLTAGE * 0.25 = 2.5V`. Whenever a trigger on `ain` (trigger input 2) in
12+
received, channel A will output `MAX_VOLTAGE * 0.6 = 6V`.
13+
14+
The same occurs for channels B and C on `cv2` and `cv3`, each with their own pair of gains for the two inputs.
15+
16+
Changing the gains will immediately update the output value, if the gain for that input is active. i.e. if `din` last
17+
detected a trigger, changing the gains for `din` on channel A/B/C will affect the voltage on `cv1/2/3` immediately.
18+
19+
`cv6` outputs a 10ms, 5V gate every time a trigger is received on _either_ input.
20+
21+
`cv4` and `cv5` have no equivalent on the original Traffic module, but are used here as difference channels:
22+
- `cv4` is the absolute difference between `cv1` and `cv2`
23+
- `cv4` is the absolute difference between `cv1` and `cv3`
24+
25+
For a video tutorial on how the original Traffic module works, please see
26+
https://youtu.be/Pn7_NCCKcJc?si=OJ78FRa9PvjD8oSd. The functionality of this script is very much the same, but limited
27+
to two input triggers.
28+
29+
30+
## Setting the gains
31+
32+
Turning `k1` and `k2` will set the gains for channel A. Pressing and holding `b1` while rotating the knobs will set the
33+
gains for channel B. Pressing and holding `b2` while rotating the knobs will set the gains for channel C.
34+
35+
The gains for channels B and C are saved to the module's onboard memory, and will persist across power-cycles. The
36+
gains for channel A are always read from the current knob positions on startup.
37+
38+
Note that this each channel makes used of "locked knobs." This means that when changing the active channel by pressing
39+
or releasing `b1` or `b2` it may be necessary to sweep the knob to its prior position before the gain can be changed.
40+
This helps prevent accidentally changing the gains by pressing the buttons.
41+
42+
43+
## I/O Mapping
44+
45+
| I/O | Usage
46+
|---------------|-------------------------------------------------------------------|
47+
| `din` | Trigger input 1 |
48+
| `ain` | Trigger input 2 |
49+
| `b1` | Hold to adjust gains for channel B |
50+
| `b2` | Hold to adjust gains for channel C |
51+
| `k1` | Input 1 gain for channel A/B/C |
52+
| `k2` | Input 2 gain for channel A/B/C |
53+
| `cv1` | Channel A output |
54+
| `cv2` | Channel B output |
55+
| `cv3` | Channel C output |
56+
| `cv4` | Channel A minus channel B (absolute value) |
57+
| `cv5` | Channel A minus channel C (absolute value) |
58+
| `cv6` | 10ms, 5V trigger whenever a rising edge occurs on `ain` or `din` |

software/contrib/traffic.py

+152
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#!/usr/bin/env python3
2+
"""A EuroPi re-imagining of Traffic by Jasmine & Olive Trees
3+
4+
Two gate inputs are sent to AIN and DIN, their values multiplied by gains controlled by K1 and K2,
5+
and the summed & differenced outputs sent to the outputs
6+
"""
7+
8+
from europi import *
9+
from europi_script import EuroPiScript
10+
11+
from experimental.a_to_d import AnalogReaderDigitalWrapper
12+
from experimental.knobs import KnobBank
13+
from experimental.screensaver import OledWithScreensaver
14+
15+
ssoled = OledWithScreensaver()
16+
17+
class Traffic(EuroPiScript):
18+
def __init__(self):
19+
super().__init__()
20+
21+
state = self.load_state_json()
22+
23+
# Button handlers to change the active channel for setting gains
24+
self.channel_markers = ['>', ' ', ' '] # used to indicate the editable channel on the screen
25+
@b1.handler
26+
def b1_rising():
27+
"""Activate channel b controls while b1 is held
28+
"""
29+
ssoled.notify_user_interaction()
30+
self.k1_bank.set_current("channel_b")
31+
self.k2_bank.set_current("channel_b")
32+
self.channel_markers[0] = ' '
33+
self.channel_markers[1] = '>'
34+
self.channel_markers[2] = ' '
35+
36+
@b2.handler
37+
def b2_rising():
38+
"""Activate channel c controls while b1 is held
39+
"""
40+
ssoled.notify_user_interaction()
41+
self.k1_bank.set_current("channel_c")
42+
self.k2_bank.set_current("channel_c")
43+
self.channel_markers[0] = ' '
44+
self.channel_markers[1] = ' '
45+
self.channel_markers[2] = '>'
46+
47+
def either_button_falling():
48+
"""Revert to channel a when the button is released
49+
"""
50+
self.k1_bank.set_current("channel_a")
51+
self.k2_bank.set_current("channel_a")
52+
self.channel_markers[0] = '>'
53+
self.channel_markers[1] = ' '
54+
self.channel_markers[2] = ' '
55+
self.save_state()
56+
b1.handler_falling(either_button_falling)
57+
b2.handler_falling(either_button_falling)
58+
59+
60+
# Input trigger handlers
61+
self.input1_trigger_at = time.ticks_ms()
62+
self.input2_trigger_at = self.input1_trigger_at
63+
@din.handler
64+
def din1_rising():
65+
self.input1_trigger_at = time.ticks_ms()
66+
67+
# Set the all-triggers output high
68+
self.last_output_trigger_at = self.input1_trigger_at
69+
cv6.on()
70+
71+
def din2_rising():
72+
self.input2_trigger_at = time.ticks_ms()
73+
74+
# Set the all-triggers output high
75+
self.last_output_trigger_at = self.input2_trigger_at
76+
cv6.on()
77+
78+
self.k1_bank = (
79+
KnobBank.builder(k1) \
80+
.with_unlocked_knob("channel_a") \
81+
.with_locked_knob("channel_b", initial_percentage_value=state.get("gain_b1", 0.5)) \
82+
.with_locked_knob("channel_c", initial_percentage_value=state.get("gain_c1", 0.5)) \
83+
.build()
84+
)
85+
86+
self.k2_bank = (
87+
KnobBank.builder(k2) \
88+
.with_unlocked_knob("channel_a") \
89+
.with_locked_knob("channel_b", initial_percentage_value=state.get("gain_b2", 0.5)) \
90+
.with_locked_knob("channel_c", initial_percentage_value=state.get("gain_c2", 0.5)) \
91+
.build()
92+
)
93+
94+
self.din1 = din
95+
self.din2 = AnalogReaderDigitalWrapper(ain, cb_rising=din2_rising)
96+
97+
# we fire a trigger on CV6, so keep track of the previous outputs so we know when something's changed
98+
self.last_output_trigger_at = time.ticks_ms()
99+
100+
def save_state(self):
101+
state = {
102+
"gain_b1": self.k1_bank["channel_b"].percent(samples=1024),
103+
"gain_b2": self.k2_bank["channel_b"].percent(samples=1024),
104+
"gain_c1": self.k1_bank["channel_c"].percent(samples=1024),
105+
"gain_c2": self.k2_bank["channel_c"].percent(samples=1024)
106+
}
107+
self.save_state_json(state)
108+
109+
def main(self):
110+
TRIGGER_DURATION = 10 # 10ms triggers every time we get a rising edge on either input channel
111+
112+
while True:
113+
self.din2.update()
114+
115+
gain_a1 = round(self.k1_bank["channel_a"].percent(samples=1024), 3)
116+
gain_a2 = round(self.k2_bank["channel_a"].percent(samples=1024), 3)
117+
118+
gain_b1 = round(self.k1_bank["channel_b"].percent(samples=1024), 3)
119+
gain_b2 = round(self.k2_bank["channel_b"].percent(samples=1024), 3)
120+
121+
gain_c1 = round(self.k1_bank["channel_c"].percent(samples=1024), 3)
122+
gain_c2 = round(self.k2_bank["channel_c"].percent(samples=1024), 3)
123+
124+
# calculate the outputs
125+
delta_t = time.ticks_diff(self.input1_trigger_at, self.input2_trigger_at)
126+
if delta_t > 0 or abs(delta_t) <= 50: # assume triggers within 50ms are simultaneous
127+
# din received a trigger more recently than ain
128+
out_a = MAX_OUTPUT_VOLTAGE * gain_a1
129+
out_b = MAX_OUTPUT_VOLTAGE * gain_b1
130+
out_c = MAX_OUTPUT_VOLTAGE * gain_c1
131+
else:
132+
# ain received a trigger more recently than din
133+
out_a = MAX_OUTPUT_VOLTAGE * gain_a2
134+
out_b = MAX_OUTPUT_VOLTAGE * gain_b2
135+
out_c = MAX_OUTPUT_VOLTAGE * gain_c2
136+
137+
cv1.voltage(out_a)
138+
cv2.voltage(out_b)
139+
cv3.voltage(out_c)
140+
cv4.voltage(abs(out_a - out_b))
141+
cv5.voltage(abs(out_a - out_c))
142+
143+
now = time.ticks_ms()
144+
if time.ticks_diff(now, self.last_output_trigger_at) > TRIGGER_DURATION:
145+
cv6.off()
146+
147+
# show the current gains * outputs, marking the channel we're controlling via the knobs & buttons
148+
ssoled.centre_text(f"{self.channel_markers[0]} A {gain_a1:0.3f} {gain_a2:0.3f}\n{self.channel_markers[1]} B {gain_b1:0.3f} {gain_b2:0.3f}\n{self.channel_markers[2]} C {gain_c1:0.3f} {gain_c2:0.3f}")
149+
150+
151+
if __name__ == "__main__":
152+
Traffic().main()
+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from europi import HIGH, LOW
2+
3+
import utime
4+
5+
6+
class AnalogReaderDigitalWrapper:
7+
"""Wraps an AnalogReader to allow it to simulate a DigitalReader.
8+
9+
The EuroPiScript class using the AnalogReaderDigitalWrapper must call `.update()` to read the current state of
10+
the analogue input and trigger any rising/falling edge callbacks.
11+
12+
The value returned by `.value()` is accurate to the last time `.update()` was called.
13+
"""
14+
15+
def __init__(
16+
self, ain, debounce=1, high_low_cutoff=0.8, cb_rising=lambda: None, cb_falling=lambda: None
17+
):
18+
"""Constructor
19+
20+
@param ain The AnalogReader we're wrapping
21+
@param debounce The number of consecutive high/low signals needed to flip the digital state
22+
@param high_low_cutoff The threshold at which the analog signal is considered high
23+
24+
@param cb_rising A function to call on the rising edge of the signal
25+
@param cb_falling A function to call on the falling edge of the signal
26+
"""
27+
self.ain = ain
28+
self.debounce = debounce
29+
self.high_low_cutoff = high_low_cutoff
30+
self.last_rising_time = 0
31+
self.last_falling_time = 0
32+
self.debounce_counter = 0
33+
self.state = False
34+
35+
if not callable(cb_rising) or not callable(cb_falling):
36+
raise ValueError("Provided callback func is not callable")
37+
38+
self.cb_rising = cb_rising
39+
self.cb_falling = cb_falling
40+
41+
def value(self):
42+
"""Returns europi.HIGH or europi.LOW depending on the state of the input"""
43+
return HIGH if self.state else LOW
44+
45+
def update(self):
46+
"""Reads the current value of the analogue input and updates the internal state"""
47+
volts = self.ain.read_voltage()
48+
49+
# count how many opposite-voltage readings we have
50+
if (self.state and volts < self.high_low_cutoff) or (
51+
not self.state and volts >= self.high_low_cutoff
52+
):
53+
self.debounce_counter += 1
54+
55+
# change state if we've reached the debounce threshold
56+
if self.debounce_counter >= self.debounce:
57+
self.debounce_counter = 0
58+
self.state = not self.state
59+
if self.state:
60+
self.last_rising_time = utime.ticks_ms()
61+
self.cb_rising()
62+
else:
63+
self.last_falling_time = utime.ticks_ms()
64+
self.cb_falling()
65+
66+
def last_rising_ms(self):
67+
return self.last_rising_time
68+
69+
def last_falling_ms(self):
70+
return self.last_falling_time

software/firmware/experimental/screensaver.py

-3
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,6 @@ def show(self):
129129
def fill(self, color):
130130
oled.fill(color)
131131

132-
def clear(self):
133-
oled.clear()
134-
135132
def text(self, string, x, y, color=1):
136133
oled.text(string, x, y, color)
137134

0 commit comments

Comments
 (0)