# 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 math import cos, radians
from time import sleep_ms, ticks_diff, ticks_ms
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 ):
# 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 .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 .clock_division = self .selected_lfo_start_value = self . get_clock_division ()
self .state_needs_saving = False
# Set the digital input and button handlers
""" 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
""" 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
""" 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
""" Reset all LFOs to zero volts, maintaining their divisions """
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 )
""" Save the current set of divisions to file """
# Prevent save-spam by limiting save rate to 1Hz
if self . last_saved () < 1000 :
self .state_needs_saving = False
" divisions " : self .divisions,
" viewAllWaveforms " : self .viewAllWaveforms,
def update_display ( self ) :
""" Update the OLED display every 10 cycles (degrees) """
if round ( self .degree , - 1 ) % 10 == 0 :
""" 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 ))
shape = self .modes[ self .selected_lfo]
if shape == self . MODES_SHAPES [ ' SINE ' ] :
elif shape == self . MODES_SHAPES [ ' SAW ' ] :
oled. line ( 3 , 31 , 9 , 24 , 1 )
oled. line ( 9 , 31 , 15 , 24 , 1 )
elif shape == self . MODES_SHAPES [ ' SQUARE ' ] :
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 ' ] :
elif shape == self . MODES_SHAPES [ ' NOISE ' ] :
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 )
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 )]
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 :
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
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 )
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
self . check_change_clock_division ()
self . display_graphic_lines ()
self . display_selected_lfo ()
if self .state_needs_saving:
if __name__ == " __main__ " :