Skip to content

Code — harmonic_lfos

# Copyright 2024 Allen Synthesis
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from europi import *
from math import cos, radians
from time import sleep_ms, ticks_diff, ticks_ms
from machine import freq
from random import randint
from europi_script import EuroPiScript
MAX_VOLTAGE = MAX_OUTPUT_VOLTAGE # Default is inherited but this can be overriden by replacing "MAX_OUTPUT_VOLTAGE" with an integer
MAX_HARMONIC = 32 # Too high a value may be hard to select using the knob, but the actual hardware limit is only reached at 4096
LONG_PRESS_MIN_DURATION = 500
class HarmonicLFOs(EuroPiScript):
MODES_SHAPES = {
'SINE': 0,
'SAW': 1,
'SQUARE': 2,
'OFF': 3,
'RANDOM': 4,
'NOISE': 5,
}
def __init__(self):
super().__init__()
# Retreive saved state information from file
state = self.load_state_json()
# Use the saved values for the LFO divisions and mode if found in the save state file, using defaults if not
self.divisions = state.get("divisions", [1, 3, 5, 7, 11, 13])
self.modes = state.get("modes", [self.MODES_SHAPES['SINE']] * 6)
self.MODES_COUNT = len(self.MODES_SHAPES)
self.viewAllWaveforms = state.get("viewAllWaveforms", True)
# Initialise all the other variables
self.degree = 0
self.rad = radians(self.degree)
self.delay, self.increment_value = self.get_delay_increment_value()
self.pixel_x = OLED_WIDTH-1
self.pixel_y = OLED_HEIGHT-1
self.selected_lfo = 0
self.clock_division = self.selected_lfo_start_value = self.get_clock_division()
self.state_needs_saving = False
# Set the digital input and button handlers
din.handler(self.reset)
@b1.handler_falling
def b1Pressed():
"""Triggered when B1 is pressed"""
diff = ticks_diff(ticks_ms(), b1.last_pressed())
if diff > LONG_PRESS_MIN_DURATION:
"""Long press: toggle waveform view mode"""
self.viewAllWaveforms = not self.viewAllWaveforms
else:
"""Short press: Change the mode that controls wave shape"""
self.modes[self.selected_lfo] = (self.modes[self.selected_lfo] + 1) % self.MODES_COUNT
self.state_needs_saving = True
@b2.handler_falling
def b2Pressed():
"""Move the selection to the next LFO"""
self.selected_lfo = (self.selected_lfo + 1) % 6
self.selected_lfo_start_value = self.get_clock_division()
def get_clock_division(self):
"""Determine the new clock division based on the position of knob 2"""
return k2.read_position(MAX_HARMONIC) + 1
def reset(self):
"""Reset all LFOs to zero volts, maintaining their divisions"""
self.degree = 0
def get_delay_increment_value(self):
"""Calculate the wait time between degrees"""
delay = (0.1 - (k1.read_position(100, 1) / 1000)) + (ain.read_voltage(1) / 100)
return delay, round((((1 / delay) - 10) / 1) + 1)
def save_state(self):
"""Save the current set of divisions to file"""
# Prevent save-spam by limiting save rate to 1Hz
if self.last_saved() < 1000:
return
self.state_needs_saving = False
self.save_state_json({
"divisions": self.divisions,
"modes": self.modes,
"viewAllWaveforms": self.viewAllWaveforms,
})
def update_display(self):
"""Update the OLED display every 10 cycles (degrees)"""
oled.scroll(-1, 0)
if round(self.degree, -1) % 10 == 0:
oled.show()
def increment(self):
"""Increment the current degree and determine new values of delay and increment_value"""
self.degree += self.increment_value
self.delay, self.increment_value = self.get_delay_increment_value()
sleep_ms(int(self.delay))
def draw_wave(self):
shape = self.modes[self.selected_lfo]
if shape == self.MODES_SHAPES['SINE']:
oled.pixel(3, 31, 1)
oled.pixel(3, 30, 1)
oled.pixel(3, 29, 1)
oled.pixel(4, 28, 1)
oled.pixel(4, 27, 1)
oled.pixel(4, 26, 1)
oled.pixel(4, 25, 1)
oled.pixel(5, 24, 1)
oled.pixel(6, 23, 1)
oled.pixel(7, 23, 1)
oled.pixel(8, 24, 1)
oled.pixel(9, 25, 1)
oled.pixel(9, 26, 1)
oled.pixel(9, 27, 1)
oled.pixel(10, 28, 1)
oled.pixel(10, 29, 1)
oled.pixel(11, 30, 1)
oled.pixel(12, 31, 1)
oled.pixel(13, 31, 1)
oled.pixel(14, 30, 1)
oled.pixel(15, 29, 1)
oled.pixel(15, 28, 1)
oled.pixel(15, 27, 1)
oled.pixel(15, 26, 1)
oled.pixel(16, 25, 1)
oled.pixel(16, 24, 1)
oled.pixel(16, 23, 1)
elif shape == self.MODES_SHAPES['SAW']:
oled.line(3, 31, 9, 24, 1)
oled.vline(9, 24, 8, 1)
oled.line(9, 31, 15, 24, 1)
oled.vline(15, 24, 8, 1)
elif shape == self.MODES_SHAPES['SQUARE']:
oled.vline(3, 24, 8, 1)
oled.hline(3, 24, 6, 1)
oled.vline(9, 24, 8, 1)
oled.hline(9, 31, 6, 1)
oled.vline(15, 24, 8, 1)
elif shape == self.MODES_SHAPES['OFF']:
oled.line(3, 24, 15, 31, 1)
oled.line(15, 24, 3, 31, 1)
elif shape == self.MODES_SHAPES['RANDOM']:
oled.pixel(3, 29, 1)
oled.pixel(4, 28, 1)
oled.pixel(4, 27, 1)
oled.pixel(5, 26, 1)
oled.pixel(6, 26, 1)
oled.pixel(7, 27, 1)
oled.pixel(8, 28, 1)
oled.pixel(9, 28, 1)
oled.pixel(10, 27, 1)
oled.pixel(10, 26, 1)
oled.pixel(10, 25, 1)
oled.pixel(11, 24, 1)
oled.pixel(12, 25, 1)
oled.pixel(13, 26, 1)
oled.pixel(13, 27, 1)
oled.pixel(14, 28, 1)
oled.pixel(14, 29, 1)
oled.pixel(15, 30, 1)
oled.pixel(16, 30, 1)
elif shape == self.MODES_SHAPES['NOISE']:
oled.pixel(3, 26, 1)
oled.pixel(3, 30, 1)
oled.pixel(4, 24, 1)
oled.pixel(4, 23, 1)
oled.pixel(5, 31, 1)
oled.pixel(5, 29, 1)
oled.pixel(6, 25, 1)
oled.pixel(6, 23, 1)
oled.pixel(7, 29, 1)
oled.pixel(7, 25, 1)
oled.pixel(8, 26, 1)
oled.pixel(8, 30, 1)
oled.pixel(9, 27, 1)
oled.pixel(9, 24, 1)
oled.pixel(10, 26, 1)
oled.pixel(10, 29, 1)
oled.pixel(11, 24, 1)
oled.pixel(11, 30, 1)
oled.pixel(12, 23, 1)
oled.pixel(12, 25, 1)
oled.pixel(13, 29, 1)
oled.pixel(13, 25, 1)
oled.pixel(14, 28, 1)
oled.pixel(14, 26, 1)
oled.pixel(15, 24, 1)
oled.pixel(15, 31, 1)
oled.pixel(16, 27, 1)
oled.pixel(16, 30, 1)
def display_selected_lfo(self):
"""Draw the current LFO's number and division to the OLED display"""
oled.fill_rect(0, 0, 20, 32, 0)
oled.fill_rect(0, 0, 20, 9, 1)
oled.text(f'{self.selected_lfo + 1}', 6, 1, 0)
number = self.divisions[self.selected_lfo]
x = 2 if number >= 10 else 6
oled.text(f'{number}', x, 12, 1)
self.draw_wave()
def calculate_voltage(self, cv, multiplier):
"""Determine the voltage based on current degree, wave shape, and MAX_VOLTAGE"""
three_sixty = 360 * multiplier
degree_three_sixty = self.degree % three_sixty
shape = self.modes[cvs.index(cv)]
voltage = 0
if shape == self.MODES_SHAPES['SINE']:
voltage = (0 - (cos(self.rad * (1 / multiplier))) + 1) * (MAX_VOLTAGE / 2)
elif shape == self.MODES_SHAPES['SAW']:
voltage = (degree_three_sixty / three_sixty) * MAX_VOLTAGE
elif shape == self.MODES_SHAPES['SQUARE']:
if degree_three_sixty / three_sixty < 0.5:
voltage = MAX_VOLTAGE
else:
voltage = 0
elif shape == self.MODES_SHAPES['RANDOM']: # This is NOT actually random, it is the sum of 3 out of sync sine waves, but it produces a fluctuating voltage that is near impossible to predict over time, and which can be clocked to be in time
voltage = (((0 - (cos(self.rad * (1 / multiplier))) + 1) * (MAX_VOLTAGE / 2)) / 3) \
+ (((0 - (cos(self.rad * (1 / (multiplier * 2.3)))) + 1) * (MAX_VOLTAGE / 2)) / 3) \
+ (((0 - (cos(self.rad * (1 / (multiplier * 5.6)))) + 1) * (MAX_VOLTAGE / 2)) / 3)
elif shape == self.MODES_SHAPES['NOISE']: # The division knob is affecting the spread for the noise
voltage = MAX_VOLTAGE * randint(0, int((1000 / MAX_HARMONIC) * multiplier)) / 1000
return voltage
def display_graphic_lines(self):
"""Draw the lines displaying each LFO's voltage to the OLED display"""
self.rad = radians(self.degree)
oled.vline(self.pixel_x, 0, OLED_HEIGHT, 0)
for index, (cv, multiplier) in enumerate(zip(cvs, self.divisions)):
volts = self.calculate_voltage(cv, multiplier)
cv.voltage(volts)
if self.viewAllWaveforms:
oled.pixel(self.pixel_x, self.pixel_y - int(volts * (self.pixel_y / 10)), 1)
elif index == self.selected_lfo:
oled.pixel(self.pixel_x, self.pixel_y - int(volts * (self.pixel_y / 10)), 1)
def check_change_clock_division(self):
"""Change current LFO's division with knob movement detection"""
self.clock_division = self.get_clock_division()
if self.clock_division != self.selected_lfo_start_value:
self.selected_lfo_start_value = self.divisions[self.selected_lfo] = self.clock_division
self.state_needs_saving = True
def main(self):
while True:
self.check_change_clock_division()
self.display_graphic_lines()
self.display_selected_lfo()
self.update_display()
self.increment()
if self.state_needs_saving:
self.save_state()
if __name__ == "__main__":
HarmonicLFOs().main()