# 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()
