diff --git a/software/contrib/README.md b/software/contrib/README.md index ca5b22b36..94bfe1d1e 100644 --- a/software/contrib/README.md +++ b/software/contrib/README.md @@ -229,6 +229,13 @@ Users have a copy of the original trigger signal, a sample and hold and a track Author: [seanbechhofer](https://github.com/seanbechhofer)
Labels: gates, sample&hold, track&hold +### Ocean Surge \[ [documentation](/software/contrib/ocean_surge.md) | [script](/software/contrib/ocean_surge.py) \] + +A clone of the Addac 508 "Swell Physics". Uses a trochoidal wave to simulate ocean surge, outputting control voltages and gates depending on the relative heights of 4 buoys floating on top of the wave. + +Author: [chrisib](https://github.com/chrisib) +
Labels: LFO, gate, sequencer, physics + ### OSC Interface \[ [documentation](/software/contrib/osc_control.md) | [script](/software/contrib/osc_control.py) \] Interface program for sending & receiving Open Sound Control (OSC) packets over UDP. Compatible with TouchOSC and diff --git a/software/contrib/menu.py b/software/contrib/menu.py index c45a40fb6..9c19379ea 100644 --- a/software/contrib/menu.py +++ b/software/contrib/menu.py @@ -61,6 +61,7 @@ ["MasterClock", "contrib.master_clock.MasterClock"], ["Morse", "contrib.morse.Morse"], ["NoddyHolder", "contrib.noddy_holder.NoddyHolder"], + ["Ocean Surge", "contrib.ocean_surge.OceanSurge"], ["OSC Interface", "contrib.osc_control.OscControl"], ["Pam's Workout", "contrib.pams.PamsWorkout2"], ["Particle Phys.", "contrib.particle_physics.ParticlePhysics"], diff --git a/software/contrib/ocean_surge.md b/software/contrib/ocean_surge.md new file mode 100644 index 000000000..93c7ea914 --- /dev/null +++ b/software/contrib/ocean_surge.md @@ -0,0 +1,61 @@ +# Ocean Surge + +This program is a loose clone of the [ADDAC 508 "Swell Physics"](https://www.addacsystem.com/en/products/modules/addac500-series/addac508). +The program uses a [trochoidal wave](https://en.wikipedia.org/wiki/Trochoidal_wave) to simulate +the motion of 3 buoys floating on the ocean surface. The relative elevations of these buoys generates +control voltage signals, while logical comparisons between them output gate signals. + +## Controls, Inputs, and Outputs + +| Control | Effect +|-----------|----------------------------------------| +| `b1` | Change clip mode (clip, reflect, wrap) | +| `b2 | Shift control for knobs | +| `k1` | Swell size | +| `b2 + k1` | Buoy spread | +| `k2` | Agitation | +| `b2 + k2` | Simulation speed | +| `din` | Unused | +| `ain` | CV control (see below) | + +| Output | Description | +|--------|----------------------------------------------------| +| `cv1` | 0-10V representing the height of buoy 1 | +| `cv2` | 0-10V representing the height of buoy 2 | +| `cv3` | 0-10V representing the height of buoy 3 | +| `cv4` | Gate on if buoy 1 is lower than buoy 2 | +| `cv5` | Gate on if buoy 2 if higher than buoy 3 | +| `cv6` | 0-10V representing the average height of all buoys | + +### CV Routing + +`ain` can be used to control any one of: +- Swell Size +- Buoy Spread +- Agitation +- Simulation speed + +By default it will control the agitation. + +To change the CV routing, create/edit `config/OceanSurge.json`: +```json +{ + "CV_TARGET": "agitation" +} +``` +where `CV_TARGET` is one of: +- `agitation` +- `buoy_spread` +- `sim_speed` +- `swell_size` + +## Wave Demo + +[This demo](https://www.desmos.com/calculator/yv8qomtzdf) provides a nice, interactive visualization +of the wave used in the simulation. + +The `d` parameter is not used by Ocean Surge. + +The `l` parameter corresponds to the `Swell Size` (`k1`) control + +The `r` parameter corresponds to the `Agitation` (`k2`) control diff --git a/software/contrib/ocean_surge.py b/software/contrib/ocean_surge.py new file mode 100644 index 000000000..10c0cf33f --- /dev/null +++ b/software/contrib/ocean_surge.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +""" +A loose clone of the ADDAC 508 "Swell Physics" + +Uses a trochoidal wave to generate control voltages +""" + +import configuration +from europi import * +from europi_script import EuroPiScript + +from experimental.knobs import KnobBank +from experimental.math_extras import rescale +from experimental.screensaver import OledWithScreensaver +from experimental.thread import DigitalInputHelper + +import machine +from math import ( + cos, + pi, + sin, +) + +import _thread + +two_pi = 2 * pi + +ssoled = OledWithScreensaver() + + + +class OceanSurge(EuroPiScript): + BG_ERR = None + + MIN_RADIUS = 0.01 + MAX_RADIUS = 2 + + MIN_LENGTH = 1 + MAX_LENGTH = 20 + + MAX_BUOY_SPREAD = 10 + + MIN_SPEED_INCREMENT = machine.freq() / 1_000_000_000_000 + MAX_SPEED_INCREMENT = MIN_SPEED_INCREMENT * 100 + + CLIP_MODE_REFLECT = 0 + CLIP_MODE_WRAP = 1 + CLIP_MODE_CLIP = 2 + N_CLIP_MODES = 3 + + CV_TARGET_NONE = -1 + CV_TARGET_AGITATION = 0 + CV_TARGET_BUOY_SPREAD = 1 + CV_TARGET_SPEED = 2 + CV_TARGET_SWELL_SIZE = 3 + + def __init__(self): + super().__init__() + self.is_running = False + + self.wavelength = 10 + + saved_state = self.load_state_json() + self.speed = saved_state.get("speed", 0.5) + self.spread = saved_state.get("spread", 0.5) + self.swell_size = k1.percent() + self.agitation = k2.percent() + self.settings_dirty = False + self.clip_mode = saved_state.get("clip_mode", self.CLIP_MODE_REFLECT) + self.d = 2.0 + + if self.config.CV_TARGET == "agitation": + self.cv_target = self.CV_TARGET_AGITATION + elif self.config.CV_TARGET == "buoy_spread": + self.cv_target = self.CV_TARGET_BUOY_SPREAD + elif self.config.CV_TARGET == "sim_speed": + self.cv_target = self.CV_TARGET_SPEED + elif self.config.CV_TARGET == "swell_size": + self.cv_target = self.CV_TARGET_SWELL_SIZE + else: + self.cv_target = self.CV_TARGET_NONE + + self.shift = False + + self.k1_bank = ( + KnobBank.builder(k1) + .with_unlocked_knob("swell_size") + .with_locked_knob( + "spread", initial_percentage_value=self.spread + ) + .build() + ) + + self.k2_bank = ( + KnobBank.builder(k2) + .with_unlocked_knob("agitation") + .with_locked_knob( + "speed", initial_percentage_value=self.speed + ) + .build() + ) + + # Use B2 as a shift for knob controls + def on_b2_press(): + self.shift = True + self.k1_bank.set_current("spread") + self.k2_bank.set_current("speed") + ssoled.notify_user_interaction() + + def on_b2_release(): + self.shift = False + self.k1_bank.set_current("swell_size") + self.k2_bank.set_current("agitation") + self.settings_dirty = True + + def on_b1_press(): + self.clip_mode = (self.clip_mode + 1) % self.N_CLIP_MODES + self.settings_dirty = True + ssoled.notify_user_interaction() + + self.digital_input_state = DigitalInputHelper( + on_b2_rising = on_b2_press, + on_b2_falling = on_b2_release, + on_b1_rising = on_b1_press, + ) + + @classmethod + def config_points(cls): + return [ + configuration.choice( + "CV_TARGET", + [ + "agitation", + "buoy_spread", + "sim_speed", + "swell_size", + ], + "agitation", + ), + ] + + def save(self): + self.settings_dirty = False + self.save_state_json({ + "spread": self.spread, + "speed": self.speed, + }) + + def apply_clip(self, y): + """ + Convert the wave to a CV value + + As configured, the wave will (at most) go from -2 to +2 on the Y axis. Depending on + the clipping mode we either + - truncate to -1 to +1 + - reflect at -1/+1 + - wrap through the limits (e.g. 1.5 -> -0.5) + + Then we shift the clipped wave to 0-1 & multiply by the max output voltage + """ + if self.clip_mode == self.CLIP_MODE_CLIP: + if y < -1: + y = -1 + elif y > 1: + y = 1 + elif self.clip_mode == self.CLIP_MODE_REFLECT: + if y < -1 or y > 1: + delta = abs(y) - 1 + if y > 1: + y = 1 - delta + else: + y = -1 + delta + elif self.clip_mode == self.CLIP_MODE_WRAP: + if y < -1 or y > 1: + delta = abs(y) - 1 + if y > 1: + y = -1 + delta + else: + y = 1 - delta + + return y + + def wave_to_cv(self, y): + y = (y + 1) / 2 + return y * MAX_OUTPUT_VOLTAGE + + def wave_x(self, a, b, t): + return a + self.r * sin(t - 2 * pi * a / self.wavelength) * self.d ** b + + def wave_y(self, a, b, t): + return b + self.r * cos(t - 2 * pi * a / self.wavelength) * self.d ** b + + def draw(self): + # UI: + # +----------------+ + # |Swl 0.0 Agt 0.0| + # |Spr 0.0 Spd 0.0| + # | | + # +----------------+ + ssoled.fill(0) + + + if not self.shift: + ssoled.fill_rect(0, 0, OLED_WIDTH, CHAR_HEIGHT+2, 1) + else: + ssoled.fill_rect(0, CHAR_HEIGHT+1, OLED_WIDTH, CHAR_HEIGHT+2, 1) + + ssoled.text(f"Swl {self.swell_size:0.1f} Agt {self.agitation:0.1f}", 0, 1, 1 if self.shift else 0) + ssoled.text(f"Spr {self.spread:0.1f} Spd {self.speed:0.1f}", 0, CHAR_HEIGHT+2, 0 if self.shift else 1) + if self.clip_mode == self.CLIP_MODE_CLIP: + ssoled.text("clip", 0, 2*CHAR_HEIGHT, 1) + elif self.clip_mode == self.CLIP_MODE_REFLECT: + ssoled.text("reflect", 0, 2*CHAR_HEIGHT, 1) + elif self.clip_mode == self.CLIP_MODE_WRAP: + ssoled.text("wrap", 0, 2*CHAR_HEIGHT+2, 1) + ssoled.show() + + def gui_thread(self): + draw_rate = 30.0 + fps_sleep = 1.0 / draw_rate + while self.is_running: + try: + self.draw() + time.sleep(fps_sleep) + except Exception as err: + self.BG_ERR = err + + if self.is_running: + self.BG_ERR = Exception('USB disconnected') + + def voltage_thread(self): + sim_now = 0.0 + + prev_swell = self.k1_bank["swell_size"].percent() + prev_spread = self.k1_bank["spread"].percent() + prev_agitation = self.k2_bank["agitation"].percent() + prev_speed = self.k2_bank["speed"].percent() + + def ui_change(old, new): + return abs(old - new) >= 0.01 + + while self.is_running: + if self.BG_ERR is not None: + print(f'Background error {self.BG_ERR}') + self.BG_ERR = None + + self.digital_input_state.update() + + # read the current knob values + prev_swell = self.swell_size + prev_spread = self.spread + prev_agitation = self.agitation + prev_speed = self.speed + self.swell_size = self.k1_bank["swell_size"].percent() + self.spread = self.k1_bank["spread"].percent() + self.agitation = self.k2_bank["agitation"].percent() + self.speed = self.k2_bank["speed"].percent() + + if ( + ui_change(prev_swell, self.swell_size) + or ui_change(prev_spread, self.spread) + or ui_change(prev_agitation, self.agitation) + or ui_change(prev_speed, self.speed) + ): + ssoled.notify_user_interaction() + + if self.cv_target == self.CV_TARGET_AGITATION: + self.agitation += ain.percent() + elif self.cv_target == self.CV_TARGET_BUOY_SPREAD: + self.spread += ain.percent() + elif self.cv_target == self.CV_TARGET_SPEED: + self.speed += ain.percent() + elif self.cv_target == self.CV_TARGET_SWELL_SIZE: + self.swell_size += ain.percent() + + # convert the knob values into our wave parameters + self.r = rescale(self.agitation, 0.0, 1.0, self.MIN_RADIUS, self.MAX_RADIUS) + self.wavelength = rescale(self.swell_size, 0.0, 1.0, self.MIN_LENGTH, self.MAX_LENGTH) + + buoy_x = self.MAX_BUOY_SPREAD * self.spread + + y1 = self.apply_clip(self.wave_y(-buoy_x, 0, sim_now)) + y2 = self.apply_clip(self.wave_y(0, 0, sim_now)) + y3 = self.apply_clip(self.wave_y(buoy_x, 0, sim_now)) + + cv1.voltage(self.wave_to_cv(y1)) + cv2.voltage(self.wave_to_cv(y2)) + cv3.voltage(self.wave_to_cv(y3)) + + if y1 < y2: + cv4.on() + else: + cv4.off() + + if y2 > y3: + cv5.on() + else: + cv5.off() + + cv6.voltage( + self.wave_to_cv((y1 + y2 + y3) / 3.0) + ) + + sim_now += rescale(self.speed, 0, 1, self.MIN_SPEED_INCREMENT, self.MAX_SPEED_INCREMENT) + if sim_now > two_pi: + sim_now -= two_pi + + if self.settings_dirty: + self.save() + + def main(self): + self.is_running = True + try: + _thread.start_new_thread(self.gui_thread, ()) + self.voltage_thread() + except KeyboardInterrupt as err: + print(err) + self.is_running = False + finally: + print("User aborted. Exiting.") + +if __name__ == "__main__": + OceanSurge().main() diff --git a/software/tests/mocks/machine.py b/software/tests/mocks/machine.py index bc3e00d74..b9190d208 100644 --- a/software/tests/mocks/machine.py +++ b/software/tests/mocks/machine.py @@ -65,8 +65,10 @@ def deinit(self): pass -def freq(_): - pass +def freq(f=None): + if f is None: + return 150_000_000 + return f class mem: