Skip to content

Commit

Permalink
Add Particle Physics LFO script (#297)
Browse files Browse the repository at this point in the history
* Initial commit of a bouncy physics-inspired LFO

* Highlight which modifiers are editable, remember to set the last update time so the particle moves correctly

* Overhaul the Particle class so update() doesn't return anything but instead sets flags inherent to the class. Assume the particle has come to rest if it's 2mm at peak altitude; otherwise we get into weird floating-point errors. May tweak this more later. Use CV3 to send a gate when the particle is at rest (can be used to auto-reset the particle!). CV4-5 are position/velocity. CV6 is still unused

* Rename Marble Physics -> Particle Physics. Add the readme, add the new program to the menu & the main readme

* Show a visualization of the particle bouncing instead of the raw y and dy values
  • Loading branch information
chrisib committed Sep 23, 2023
1 parent 35ec4a6 commit bf2b6b6
Show file tree
Hide file tree
Showing 5 changed files with 330 additions and 0 deletions.
10 changes: 10 additions & 0 deletions software/contrib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ source with multiple wave shapes, optional quantization, euclidean rhythm output
<i>Author: [chrisib](https://github.com/chrisib)</i>
<br><i>Labels: clock, euclidean, gate, lfo, quantizer, random, trigger</i>

### Particle Physics \[ [documentation](/software/contrib/particle_physics.md) | [script](/software/contrib/particle_physics.py) \]
An irregular LFO based on a basic 1-dimensional physics simulation. Outputs triggers when a particle bounces under the effects of gravity. Outputs control signals
based on the particle's position and velocity.

While not technically random, the effects of changing the particle's initial conditions, gravity, and elasticity coefficient can create unpreditable rhythms.

<i>Author: [chrisib](https://github.com/chrisib)</i>
<br><i>Labels: gate, lfo, sequencer, random, trigger</i>


### Poly Square \[ [documentation](/software/contrib/poly_square.md) | [script](/software/contrib/poly_square.py) \]
Six independent oscillators which output on CVs 1-6.

Expand Down
1 change: 1 addition & 0 deletions software/contrib/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
["MasterClock", "contrib.master_clock.MasterClock"],
["NoddyHolder", "contrib.noddy_holder.NoddyHolder"],
["Pam's Workout", "contrib.pams.PamsWorkout"],
["Particle Phys.", "contrib.particle_physics.ParticlePhysics"],
["Piconacci", "contrib.piconacci.Piconacci"],
["PolyrhythmSeq", "contrib.polyrhythmic_sequencer.PolyrhythmSeq"],
["PolySquare", "contrib.poly_square.PolySquare"],
Expand Down
63 changes: 63 additions & 0 deletions software/contrib/particle_physics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Particle Physics

This program implements a very basic particle physics model where an object falls under gravity and bounces. Every
bounce reduces the velocity proportional to an elasticity constant.

## I/O Mapping

| I/O | Usage
|---------------|-------------------------------------------------------------------|
| `din` | Releases the particle from initial conditions |
| `ain` | Unused |
| `b1` | Releases the particle from initial conditions |
| `b2` | Alt button to be held while turning `k1` or `k2` |
| `k1` | Edit the drop height (alt: edit gravity) |
| `k2` | Edit elasticity coefficient (alt: edit initial velocity) |
| `cv1` - `cv5` | Output signals. Configuration is explained below |
| `cv6` | Unused |

## CV Outputs

`cv1` outputs a 5V trigger every time the particle touches the ground. When at rest this trigger becomes a gate,
indicating that the particle is always touching the ground.

`cv2` outputs a 5V trigger every time the particle reaches its peak altitude for the bounce and begins falling
again. When at rest this becomes a gate, indicating that the particle is always at peak altitude.

`cv3` outputs a gate when the particle is at rest. This can be patched into `din` to automatically reset the
particle when it comes to rest.

`cv4` outputs a control signal in the range `[0, 10]V`, proportional to the particles height.

`cv5` outputs a control signal in the range `[0, 10]V`, proportional to the particles absolute velocity. (Because
EuroPi can only output positive voltages this output will be high when the particle is moving quickly up or down.)

## Physics, Explained

For clarity, positive values are up and negative values are down. Units don't matter, but if it helps assume
everything is SI units (meters, m/s, m/s^2).

- let `(y, dy)` be a 1-D particle, representing its height `y` and velocity `dy`
- let `h` be the particle's initial height above the ground
- let `v` be the particle's initial velocity in the vertical direction
- let `g` be the acceleration due to gravity
- let `e` be the elasticity coefficient, such that `0 < e < 1`
- when released, `(y, dy) = (h, v)`

At every tick of the main loop, the particle's position and velocity are updated:
```
dt = the time between this tick and the previous one
dy' = dy - g * dt
y' = y + dy * dt
```

If `y'` is less than or equal to zero we assume the particle has it the ground and further modify `dy'`:

```
dy' = |dy| * e
```

To avoid floating point rounding causing the particle to bounce forever, we assume it has come to rest if its
peak height for any bounce is `0.002`. When this occurs, `y` and `dy` are both set to zero and the simulation
effectively stops.
245 changes: 245 additions & 0 deletions software/contrib/particle_physics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""A 1D physics simulating LFO, inspired by the ADDAC503
(https://www.addacsystem.com/en/products/modules/addac500-series/addac503)
"""

from europi import *
from europi_script import EuroPiScript

from experimental.knobs import KnobBank

import math
import time


EARTH_GRAVITY = 9.8
MIN_GRAVITY = 0.1
MAX_GRAVITY = 20

MIN_HEIGHT = 0.1
MAX_HEIGHT = 10.0

MIN_SPEED = -5.0
MAX_SPEED = 5.0

MIN_ELASTICITY = 0.0
MAX_ELASTICITY = 0.9

## If a bounce reaches no higher than this, assume we've come to rest
ASSUME_STOP_PEAK = 0.002

def rescale(x, old_min, old_max, new_min, new_max):
if x <= old_min:
return new_min
elif x >= old_max:
return new_max
else:
return (x - old_min) / (old_max - old_min) * (new_max - new_min) + new_min


class Particle:
def __init__(self):
self.y = 0.0
self.dy = 0.0

self.last_update_at = time.ticks_ms()

self.hit_ground = False
self.reached_apogee = False
self.stopped = True

self.peak_height = 0.0

def set_initial_position(self, height, velocity):
self.peak_height = height
self.y = height
self.dy = velocity
self.last_update_at = time.ticks_ms()

def update(self, g, elasticity):
"""Update the particle position based on the ambient gravity & elasticy of the particle
"""
now = time.ticks_ms()
delta_t = time.ticks_diff(now, self.last_update_at) / 1000.0

new_dy = self.dy - delta_t * g
new_y = self.y + self.dy * delta_t

# if we were going up, but now we're going down we've reached apogee
self.reached_apogee = new_dy <= 0 and self.dy >= 0

if self.reached_apogee:
self.peak_height = self.y

# if the vertical position is zero or negative, we've hit the ground
self.hit_ground = new_y <= 0

if self.hit_ground:
#new_y = 0
new_dy = abs(self.dy * elasticity) # bounce upwards, reduding the velocity by our elasticity modifier

self.stopped = self.peak_height <= ASSUME_STOP_PEAK

if self.stopped:
new_y = 0
new_dy = 0

self.dy = new_dy
self.y = new_y
self.last_update_at = now

class ParticlePhysics(EuroPiScript):
def __init__(self):
settings = self.load_state_json()

self.gravity = settings.get("gravity", 9.8)
self.initial_velocity = settings.get("initial_velocity", 0.0)
self.release_height = settings.get("height", 10.0)
self.elasticity = settings.get("elasticity", 0.75)

self.k1_bank = (
KnobBank.builder(k1)
.with_locked_knob("height", initial_percentage_value=rescale(self.release_height, MIN_HEIGHT, MAX_HEIGHT, 0, 1))
.with_locked_knob("gravity", initial_percentage_value=rescale(self.gravity, MIN_GRAVITY, MAX_GRAVITY, 0, 1))
.build()
)

self.k2_bank = (
KnobBank.builder(k2)
.with_locked_knob("elasticity", initial_percentage_value=rescale(self.elasticity, MIN_ELASTICITY, MAX_ELASTICITY, 0, 1))
.with_locked_knob("speed", initial_percentage_value=rescale(self.initial_velocity, MIN_SPEED, MAX_SPEED, 0, 1))

.build()
)

self.particle = Particle()

self.release_before_next_update = False

self.alt_knobs = False

@din.handler
def on_din_rising():
self.reset()

@b1.handler
def on_b1_press():
self.reset()

@b2.handler
def on_b2_press():
self.alt_knobs = True
self.k1_bank.next()
self.k2_bank.next()

@b2.handler_falling
def on_b2_release():
self.alt_knobs = False
self.k1_bank.next()
self.k2_bank.next()


@classmethod
def display_name(cls):
return "ParticlePhysics"

def save(self):
state = {
"gravity" : self.gravity,
"initial_velocity" : self.initial_velocity,
"height" : self.release_height,
"elasticity" : self.elasticity
}
self.save_state_json(state)

def reset(self):
self.release_before_next_update = True

def draw(self):
oled.fill(0)
row_1_color = 1
row_2_color = 2
if self.alt_knobs:
oled.fill_rect(0, CHAR_HEIGHT+1, OLED_WIDTH, CHAR_HEIGHT+1, 1)
row_2_color = 0
else:
oled.fill_rect(0, 0, OLED_WIDTH, CHAR_HEIGHT+1, 1)
row_1_color = 0


oled.text(f"h: {self.release_height:0.2f} e: {self.elasticity:0.2f}", 0, 0, row_1_color)
oled.text(f"g: {self.gravity:0.2f} v: {self.initial_velocity:0.2f}", 0, CHAR_HEIGHT+1, row_2_color)

# a horizontal representation of the particle bouncing off the left edge of the screen
oled.pixel(int(rescale(self.particle.y, 0, self.release_height, 0, OLED_WIDTH)), 3 * CHAR_HEIGHT, 1)

oled.show()

def main(self):
while True:
g = round(rescale(self.k1_bank["gravity"].percent(), 0, 1, MIN_GRAVITY, MAX_GRAVITY), 2)
h = round(rescale(self.k1_bank["height"].percent(), 0, 1, MIN_HEIGHT, MAX_HEIGHT), 2)
v = round(rescale(self.k2_bank["speed"].percent(), 0, 1, MIN_SPEED, MAX_SPEED), 2)
e = round(rescale(self.k2_bank["elasticity"].percent(), 0, 1, MIN_ELASTICITY, MAX_ELASTICITY), 2)

# the maximum veliocity we can attain, given the current parameters
# d = 1/2 aT^2 -> T = sqrt(2d/a)
h2 = 0
v2 = 0
if v > 0:
# initial upward velocity; add this to the initial height
t = v / g
h2 = v * t
else:
v2 = abs(v)
t = math.sqrt(2 * (h+h2) / g)
max_v = g * t + v2

if g != self.gravity or \
h != self.release_height or \
v != self.initial_velocity or \
e != self.elasticity:
self.gravity = g
self.initial_velocity = v
self.release_height = h
self.elasticity = e
self.save()

self.draw()

if self.release_before_next_update:
self.particle.set_initial_position(self.release_height, self.initial_velocity)
self.release_before_next_update = False

self.particle.update(self.gravity, self.elasticity)

# CV 1 outputs a gate whenever we hit the ground
if self.particle.hit_ground:
cv1.voltage(5)
else:
cv1.voltage(0)

# CV 2 outputs a trigger whenever we reach peak altitude and start falling again
if self.particle.reached_apogee:
cv2.voltage(5)
else:
cv2.voltage(0)

# CV 3 outputs a gate when the particle comes to rest
if self.particle.stopped:
cv3.voltage(5)
else:
cv3.voltage(0)

# CV 4 outputs control voltage based on the height of the particle
cv4.voltage(rescale(self.particle.y, 0, MAX_HEIGHT, 0, MAX_OUTPUT_VOLTAGE))

# CV 5 outputs control voltage based on the speed of the particle
cv5.voltage(rescale(abs(self.particle.dy), 0, max_v, 0, MAX_OUTPUT_VOLTAGE))

# TODO: I don't know what to use CV6 for. But hopefully I'll think of something
cv6.off()


if __name__ == "__main__":
ParticlePhysics().main()
11 changes: 11 additions & 0 deletions software/firmware/experimental/knobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,17 @@ def set_current(self, name):
# if the name isn't found, just silently trap the exception
pass

def __getitem__(self, name):
"""Get the LockableKnob in this bank with the given name
@param name The name of the knob to return, or None if the name isn't found
"""
try:
index = self.names.index(name)
return self.knobs[index]
except ValueError:
return None

class Builder:
"""A convenient interface for creating a :class:`KnobBank` with consistent initial state."""

Expand Down

0 comments on commit bf2b6b6

Please sign in to comment.