Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Lutra script #353

Merged
merged 13 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
301 changes: 301 additions & 0 deletions software/MULTITHREADING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
# Multi-Threading

The Raspberry Pi Pico used in EuroPi has 2 CPU cores. By default only one core is used. Micropython contains a
module called `_thread` that can be used to run code on both cores. This can be helpful if, for example, your program
features a lot of mathematical calculations (e.g. floating point operations, trigonometry (e.g. `sin` or `cos`)) that
must be run quickly, but also features a complex GUI to render to the OLED.

## WARNING

Multi-threading is considered an experimental feature, and is intended for advanced programmers only. Adding threads to
your program can introduce many hard-to-debug errors and requires some understanding of some advanced topics not
covered here, including:
- thread safety
- concurrency
- semaphores & mutexes

Unless you're certain you need the additional processing power offered by the EuroPi CPU's second core, it is
_strongly_ recommended that you write your program using a single thread.

## Using `_thread`

To use the `_thread` library you must first import it into your program:
```python
import _thread
```

Your program will always contain at least one thread, referred to as the "main thread." To start a second thread
you must define a function to execute and start it with `_thread.start_new_thread`:
```python
from europi import *
import time
import _thread

def my_second_thread():
"""The secondary thread; toggles CV2 on and off at 0.5Hz
"""
while True:
cv2.on()
time.sleep(1)
cv2.off()
time.sleep(1)

def main():
"""The main thread; creates the secondary thread and then toggles CV1 at 1Hz
"""

# Start the second thread
second_thread = _thread.start_new_thread(my_second_thread, ())

while True:
cv1.on()
time.sleep(0.5)
cv1.off()
time.sleep(0.5)
```

This is a more complex example that implements the same logic as above, but wrapped in a `EuroPiScript`:
```python
from europi import *
from europi_script import EuroPiScript
import time
import _thread

class BasicThreadingDemo1(EuroPiScript):
def __init__(self):
super().__init__()

def main_thread(self):
"""The main thread; toggles CV1 on and off at 1Hz
"""
while True:
cv1.on()
time.sleep(0.5)
cv1.off()
time.sleep(0.5)

def secondary_thread(self):
"""The secondary thread; toggles CV2 on and off at 0.5Hz
"""
while True:
cv2.on()
time.sleep(1)
cv2.off()
time.sleep(1)

def main(self):
# Clear the display
oled.fill(0)
oled.show()

second_thread = _thread.start_new_thread(self.secondary_thread, ())
self.main_thread()

if __name__ == "__main__":
BasicThreadingDemo1().main()
```

## Best Practices

Because each thread runs on a different core, and the EuroPi's processor only has 2 cores, it is recommended to limit
your program to 2 thread: the main thread, and a single additional thread started with `_thread.start_new_thread`.

Interrupt Service Routines (ISRs), implemented as `handler` functions for button presses and `din` rising/falling edges
have been known to cause threaded programs to hang. It is therefore recommended to _avoid_ using `handler` functions
in your program:
```python
# To NOT do this:
@b1.handler
def on_b1_rising():
self.do_stuff()
```

Instead, [`experimental.thread`](/software/firmware/experimental/thread.py) provides a `DigitalInputHelper` class
that can be used inside your main thread to check the state of the buttons & `din` and invoke callback functions
when a rising or falling edge is detected.

For example:
```python
from europi import *
from europi_script import EuroPiScript

import time
import _thread

from experimental.thread import DigitalInputHelper

class BasicThreadingDemo2(EuroPiScript):
def __init__(self):
super().__init__()

self.digital_input_helper = DigitalInputHelper(
on_b1_rising = self.on_b1_press,
on_b1_falling = self.on_b1_release,
on_b2_rising = self.on_b2_press,
on_b2_falling = self.on_b2_release,
on_din_rising = self.on_din_high,
on_din_falling = self.on_din_low
)

def on_b1_press(self):
"""Turn on CV4 when the button is held
"""
cv4.on()

def on_b1_release(self):
"""Turn off CV4 when the button is released
"""
cv4.off()

def on_b2_press(self):
"""Turn on CV5 when the button is held
"""
cv5.on()

def on_b2_release(self):
"""Turn off CV5 when the button is released
"""
cv5.off()

def on_din_high(self):
"""Turn on CV6 when the signal goes high
"""
cv6.on()

def on_din_low(self):
"""Turn off CV6 when the signal drops low
"""
cv6.off()

def main_thread(self):
"""The main thread; toggles CV1 on and off at 1Hz and handles the buttons + DIN
"""
# calling time.sleep will block the input handling, so we need to check the clock instead
last_state_change_time = time.ticks_ms()
cv1_on = True
cv1.on()

while True:
# Check the inputs
self.digital_input_helper.update()

now = time.ticks_ms()
if time.ticks_diff(now, last_state_change_time) >= 500:
last_state_change_time = now
cv1_on = not cv1_on
if cv1_on:
cv1.on()
else:
cv1.off()

def secondary_thread(self):
"""The secondary thread; toggles CV2 on and off at 0.5Hz
"""
while True:
cv2.on()
time.sleep(1)
cv2.off()
time.sleep(1)

def main(self):
# Clear the display
oled.fill(0)
oled.show()

second_thread = _thread.start_new_thread(self.secondary_thread, ())
self.main_thread()


if __name__ == "__main__":
BasicThreadingDemo2().main()
```

One of the slowest operations in the EuroPi firmware is updated the OLED. If possible moving any GUI rendering into
a secondary thread is a good idea to optimize your program. Note that you will likely need to use `Lock` objects
to prevent threads from trying to read/write data at the same time.

```python
from europi import *
from europi_script import EuroPiScript

import math
import _thread

class BasicThreadingDemo3(EuroPiScript):
def __init__(self):
super().__init__()

# Create a lock object to prevent multiple threads from accessing our pixel array
self.pixel_lock = _thread.allocate_lock()

# Create an array that we'll store vertical pixel positions in to draw on screen
self.pixel_heights = []

def cv_thread(self):
"""The main thread; generates a sine wave on CV1
"""
CYCLE_TICKS = 10000 # one complete sine wave every N times through the loop

ticks = 0
cv1_volts = 0
while True:
# convert the tick counter to radians for use with sin/cos
theta = ticks / CYCLE_TICKS * (2*math.pi)

# sin gives us [-1, 1], but we need to shift it to [0, MAX_OUTPUT_VOLTAGE]
cv1_volts = (math.sin(theta) + 1) / 2 * MAX_OUTPUT_VOLTAGE

# convert the voltage to an integer from 0 to OLED_HEIGHT
# note that row 0 is the top, so we'll draw a line that goes down as the voltage goes up
# since this is just an example, that's fine; the math to flip the visualization is left as
# an exercise for the reader
with self.pixel_lock:
self.pixel_heights.append(int(cv1_volts * OLED_HEIGHT / MAX_OUTPUT_VOLTAGE))

# Restrict the number of pixel heights to match the width of the screen
if len(self.pixel_heights) >= OLED_WIDTH:
self.pixel_heights.pop(0)

ticks = ticks + 1
if ticks >= CYCLE_TICKS:
ticks = 0

cv1.voltage(cv1_volts)

def gui_thread(self):
"""Draw the wave shapes to the screen
"""
while True:
# clear the screen
oled.fill(0)

# Draw the pixels representing our waves
# Make sure to grab the lock so the arrays aren't modified while we're reading them!
with self.pixel_lock:
for i in range(len(self.pixel_heights)):
oled.pixel(i, self.pixel_heights[i], 0xff) # draw a white pixel

# Show the updated display
oled.show()

def main(self):
second_thread = _thread.start_new_thread(self.gui_thread, ())
self.cv_thread()


if __name__ == "__main__":
BasicThreadingDemo3().main()
```

## Cancelling Execution and Thonny

If you use Thonny to debug your EuroPi programs, note that when you cancel execution with `ctrl+C` the second core
will likely still be busy. This can cause Thonny to raise errors when trying to e.g. run another function in the
Python terminal or saving files to the module.

If this happens, simply Stop/Restart the backend via Thonny's Run menu.


## References

- [Official Python `_thread` docs](https://docs.python.org/3/library/_thread.html)
9 changes: 9 additions & 0 deletions software/contrib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ Treats both inputs as digital on/off signals and outputs the results of binary A
<i>Author: [chrisib](https://github.com/chrisib)</i>
<br><i>Labels: logic, gates, binary operators</i>

### Lutra \[ [documentation](/software/contrib/lutra.md) | [script](/software/contrib/lutra.py) \]

Six syncable LFOs with variable wave shapes. The clock speed of each LFO is slightly different, with an adjustable base speed and CV-controllable spread.

Inspired by [Expert Sleepers' Otterly](https://expert-sleepers.co.uk/otterley.html) module.

<i>Author: [chrisib](https://github.com/chrisib)</i>
<br><i>Labels: lfo</i>

### Noddy Holder \[ [documentation](/software/contrib/noddy_holder.md) | [script](/software/contrib/noddy_holder.py) \]
Two channels of sample/track and hold based on a single trigger and CV source

Expand Down
Binary file added software/contrib/lutra-docs/wave_ramp.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added software/contrib/lutra-docs/wave_saw.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added software/contrib/lutra-docs/wave_sine.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added software/contrib/lutra-docs/wave_square.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added software/contrib/lutra-docs/wave_triangle.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 69 additions & 0 deletions software/contrib/lutra.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Lutra

Lutra is a re-imagining of [Expert Sleepers' Otterly](https://expert-sleepers.co.uk/otterley.html) module.

Each output channel is a free running LFO with a subtly different clock speed. The spread of clock speeds is controlled
by `k2` (and optionally `ain` -- see configuration below)

## Wave Shapes

The shape of the output wave can be changed by pressing `b2` while the module is running. This choice is saved to
the module and will be re-loaded every time `Lutra` starts. Available wave shapes are:
- ![Sine Wave](./lutra-docs/wave_sine.png) Sine
- ![Square Wave](./lutra-docs/wave_square.png) Square
- ![Triangle Wave](./lutra-docs/wave_triangle.png) Triangle
- ![Saw Wave](./lutra-docs/wave_saw.png) Saw
- ![Ramp Wave](./lutra-docs/wave_ramp.png) Ramp

When pressing `b2` to select the wave shape, the selected shape will briefly appear in the upper left corner of the
screen.

## CV Input Configuration

By default CV signals applied to `ain` will adjust the spread of the output waves. If preferred, this can be changed
to control the overall speed of the waves instead by creating/editing `/config/Lutra.json` on the module.

```json
{
"AIN_MODE": "spread"
}
```

- `AIN_MODE`: sets the mode for `ain`. Must be one of `spread` or `speed`. If set to `spread` the spread of clock
speeds of the outputs is controlled by `ain`. If set to `speed` the master clock speed is controlled by `ain`.

`ain` is expected to receive signals from zero to `MAX_INPUT_VOLTAGE` (default 12V -- see
[EuroPi configuration](/software/CONFIGURATION.md)). Increasing the voltage will increase the speed or spread of
the LFOs. Decreasing the speed/spread is not allowed, as EuroPi cannot accept negative voltages. Instead it is
recommended to set `k1` and `k2` to set the minimum desired speed & spread with `ain` unpatched. Then send an
attenuated signal into `ain` to increase the speed/spread as desired.

## Knob Control

Turning `k1` fully anticlockwise will set the clock speed to the slowest setting. Turning `k1` fully clockwise will set
the clock speed to the fastest setting.

Turning `k2` fully anticlockwise will set the spread of the waves to zero; every output will have the same clock speed,
(though depending on previous settings and random noise) they may be phase-shifted from each other.

Turning `k2` clockwise will gradually increase the speed of `cv2-6`, with each output becoming slightly faster than
the previous one. i.e. `cv2` will be faster than `cv1`, `cv3` will be faster than `cv2`, etc...

At the maximum clockwise position, the speeds of `cv2-6` will be common harmonic intervals from `cv1`, as shown on the
table below:

| CV Output | Ratio w/ `cv1 | Max speed multiplier |
|-----------|---------------|----------------------|
| `cv1` | 1:1 | x1 |
| `cv2` | 6:5 | x1.2 |
| `cv3` | 5:4 | x1.25 |
| `cv4` | 4:3 | x1.3333... |
| `cv5` | 3:2 | x1.5 |
| `cv6` | 2:1 | x2 |

## Re-syncing

When `b1` is pressed or `din` receives a high voltage all CV outputs are temporarily halted. Once `b1` is released
_and_ the signal on `din` drops back to `0.0V` the output signals will begin again, starting in their initial,
synchronized state. This allows a very short gate (trigger) signal to reset all of the waves to re-synchronize them,
or a longer gate can be used to hold the outputs at zero for as long as desired.