# Copyright 2022 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.
"""
Polyrhythmic Sequencer
author: Adam Wonak (github.com/awonak)
date: 2022-01-17
labels: polyrhythms, sequencer, triggers

EuroPi version of a Subharmonicon style polyrhythmic sequencer.
Partially inspired by m0wh: https://github.com/m0wh/subharmonicon-sequencer
Demo video: https://youtu.be/vMAVqVQIpW0

Page 1 is the first 4 note sequence, page 2 is the second 4 note sequence, page
3 is the polyrhythms assignable to each sequence. Use knob 1 to select between
the 4 steps and use knob 2 to edit that step. On page 3 there are 4 polyrhythm
options ranging from triggering every 1 beat to every 16 beats. On this page
button 2 assigns which sequence this polyrhythm should apply to. Button 1 will
cycle through the pages. The script needs a clock source in the digital input
to play.

digital_in: clock in
analog_in: unused

knob_1: select step option for current page
knob_2: adjust the value of the selected option

button_1: cycle through editable parameters (seq1, seq1, poly)
button_2: edit current parameter options

output_1: pitch 1
output_2: trigger 1
output_3: trigger logical AND
output_4: pitch 2
output_5: trigger 2
output_6: trigger logical XOR

"""
try:
    # Local development
    from software.firmware.europi import OLED_WIDTH, OLED_HEIGHT, CHAR_HEIGHT
    from software.firmware.europi import din, k1, k2, oled, b1, b2, cv1, cv2, cv3, cv4, cv5, cv6
    from software.firmware.europi_script import EuroPiScript
except ImportError:
    # Device import path
    from europi import *
    from europi_script import EuroPiScript

from collections import namedtuple
import struct
import machine
from utime import ticks_diff, ticks_ms


# Script Constants
MENU_DURATION = 1200

VOLT_PER_OCT = 1 / 12

# fmt: off
NOTES = [
    "C0", "C#0", "D0", "D#0", "E0", "F0", "F#0", "G0", "G#0", "A0", "A#0", "B0",
    "C1", "C#1", "D1", "D#1", "E1", "F1", "F#1", "G1", "G#1", "A1", "A#1", "B1",
    "C2", "C#2", "D2", "D#2", "E2", "F2", "F#2", "G2", "G#2", "A2", "A#2", "B2",
    "C3", "C#3", "D3", "D#3", "E3", "F3", "F#3", "G3", "G#3", "A3", "A#3", "B3",
    "C4", "C#4", "D4", "D#4", "E4", "F4", "F#4", "G4", "G#4", "A4", "A#4", "B4",
]
# fmt: on


class Sequence:
    def __init__(self, notes, pitch_cv, trigger_cv):
        self.notes = notes
        self.pitch_cv = pitch_cv
        self.trigger_cv = trigger_cv
        self.step_index = 0

        # Save state struct
        self.format_string = "4s"
        self.State = namedtuple("State", "note_indexes")

    def set_state(self, state):
        """Update instance variables with given state bytestring."""
        state = self.State(*struct.unpack(self.format_string, state))
        self.notes = [NOTES[n] for n in state.note_indexes]

    def get_state(self):
        """Return state byte string."""
        note_indexes = [NOTES.index(n) for n in self.notes]
        return struct.pack(self.format_string, bytes(note_indexes))

    def _pitch_cv(self, note: str) -> float:
        return NOTES.index(note) * VOLT_PER_OCT

    def _set_pitch(self):
        pitch = self._pitch_cv(self.current_note())
        self.pitch_cv.voltage(pitch)

    def current_note(self) -> str:
        return self.notes[self.step_index]

    def edit_step(self, step: int, note: str):
        """Set the given step to the given note value and update pitch cv out."""
        assert note in NOTES, f"Given note not in available notes: {note}"
        self.notes[step] = note
        self._set_pitch()

    def advance_step(self):
        """Advance the sequence step index."""
        self.step_index = (self.step_index + 1) % len(self.notes)

    def play_next_step(self):
        """Advance the sequence step and play the note."""
        self.advance_step()
        # Set cv output voltage to sequence step pitch.
        self._set_pitch()
        self.trigger_cv.on()

    def reset(self):
        """Reset the sequence back to the first note."""
        self.step_index = 0
        self._set_pitch()
        self.trigger_cv.off()


class PolyrhythmSeq(EuroPiScript):
    pages = ['SEQUENCE 1', 'SEQUENCE 2', 'POLYRHYTHM']
    # Two 4-step melodic sequences.
    seqs = [
        Sequence(["C0", "D#0", "D0", "G0"], cv1, cv2),
        Sequence(["G0", "F0", "D#0", "C0"], cv4, cv5),
    ]
    # 4 editable polyrhythms, assignable to the sequences.
    polys = [8, 3, 2, 5]
    # Indicates which sequences are assigned to each polyrhythm.
    # 0: none, 1: seq1, 2: seq2, 3: both seq1 and seq2.
    seq_poly = [2, 1, 1, 0]

    # Used to indicates if state has changed and not yet saved.
    _dirty = False
    _last_saved = 0

    def __init__(self):
        super().__init__()

        # Configure EuroPi options to improve performance.
        b2.debounce_delay = 200
        oled.contrast(0)  # dim the oled

        # Current editable sequence.
        self.seq = self.seqs[0]

        self.page = 0
        self.param_index = 0
        self.counter = 0
        self.reset_timeout = 3000
        self._prev_k2 = None

        # Assign cv outputs to logical triggers.
        self.trigger_and = cv3
        self.trigger_xor = cv6

        # Save state struct
        self.format_string = "12s12s4s4s"
        self.State = namedtuple("State", "seq1 seq2 polys seq_poly")

        # Load state if previous state exists.
        self.load_state()

        @b1.handler
        def page_handler():
            # Pressing button 1 cycles through the pages of editable parameters.
            self._prev_k2 = None
            self.page = (self.page + 1) % len(self.pages)
            if self.page == 0:
                self.seq = self.seqs[0]
            if self.page == 1:
                self.seq = self.seqs[1]
            self._dirty = True

        @b2.handler
        def edit_parameter():
            # Pressing button 2 edits the current selected parameter.
            if self.page == 2:
                # Cycles through which sequence this polyrhythm is assigned to.
                self.seq_poly[self.param_index] = (
                    self.seq_poly[self.param_index] + 1) % len(self.seq_poly)
                self._dirty = True

        @din.handler
        def play_notes():
            # For each polyrhythm, check if each sequence is enabled and if the
            # current beat should play.
            seq1 = False
            seq2 = False
            # Check each polyrhythm to determine if a sequence should be triggered.
            for i, poly in enumerate(self.polys):
                if self.counter % poly == 0:
                    _seq1, _seq2 = self._trigger_seq(i)
                    seq1 = _seq1 or seq1
                    seq2 = _seq2 or seq2
            if seq1:
                self.seqs[0].play_next_step()
            if seq2:
                self.seqs[1].play_next_step()

            # Trigger logical AND / XOR
            if seq1 and seq2:
                self.trigger_and.on()
            if (seq1 or seq2) and seq1 != seq2:
                self.trigger_xor.on()

            self.counter = self.counter + 1

        @din.handler_falling
        def triggers_off():
            # Turn off all of the trigger CV outputs.
            for seq in self.seqs:
                seq.trigger_cv.off()
            self.trigger_and.off()
            self.trigger_xor.off()

    def _trigger_seq(self, step: int):
        # Convert poly sequence enablement into binary to determine which
        # sequences are triggered on this step.
        status = f"{self.seq_poly[step]:02b}"
        # Reverse the binary string values to match display.
        return int(status[1]) == 1, int(status[0]) == 1

    def load_state(self):
        """Load state from previous run."""
        state = self.load_state_bytes()
        if state:
            self.set_state(state)

    def save_state(self):
        """Save state if it has changed since last call."""
        # Only save state if state has changed and more than 1s has elapsed
        # since last save.
        if self._dirty and self.last_saved() > 1000:
            state = self.get_state()
            self.save_state_bytes(state)
            self._dirty = False
            self._last_saved = ticks_ms()

    def get_state(self):
        """Get state as a byte string."""
        return struct.pack(self.format_string,
                           self.seqs[0].get_state(),
                           self.seqs[1].get_state(),
                           bytes(self.polys),
                           bytes(self.seq_poly))

    def set_state(self, state):
        """Update instance variables with given state bytestring."""
        try:
            _state = self.State(*struct.unpack(self.format_string, state))
        except ValueError as e:
            print(f"Unable to load state: {e}")
            return
        self.seqs[0].set_state(_state.seq1)
        self.seqs[1].set_state(_state.seq2)
        self.polys = list(_state.polys)
        self.seq_poly = list(_state.seq_poly)

    def reset_check(self):
        """Reset the sequences and triggers when no clock pulse detected for specified time."""
        if self.counter != 0 and ticks_diff(ticks_ms(), din.last_triggered()) > self.reset_timeout:
            self.step = 0
            self.counter = 0
            for s in self.seqs:
                s.reset()
            self.trigger_and.off()
            self.trigger_xor.off()

    def show_menu_header(self):
        if ticks_diff(ticks_ms(), b1.last_pressed()) < MENU_DURATION:
            oled.fill_rect(0, 0, OLED_WIDTH, CHAR_HEIGHT, 1)
            oled.text(f"{self.pages[self.page]}", 0, 0, 0)

    def edit_sequence(self):
        # Display each sequence step.
        for step in range(len(self.seq.notes)):
            # If the current step is selected, edit with the parameter edit knob.
            if step == self.param_index:
                selected_note = k2.choice(NOTES)
                if self._prev_k2 and self._prev_k2 != selected_note:
                    self.seq.edit_step(step, selected_note)
                    self._dirty = True
                self._prev_k2 = selected_note

            # Display the current step.
            padding_x = 4 + (int(OLED_WIDTH/4) * step)
            padding_y = 12
            oled.text(f"{self.seq.notes[step]:<3}", padding_x, padding_y, 1)

            # Display a bar under current playing step.
            if step == self.seq.step_index:
                x1 = (int(OLED_WIDTH / 4) * step)
                x2 = int(OLED_WIDTH / 4)
                oled.fill_rect(x1, OLED_HEIGHT - 6, x2, OLED_HEIGHT, 1)

    def edit_poly(self):
        # Display each polyrhythm option.
        for poly_index in range(len(self.polys)):
            # If the current polyrhythm is selected, edit with the parameter knob.
            if poly_index == self.param_index:
                poly = k2.range(16) + 1
                if self._prev_k2 and self._prev_k2 != poly:
                    self.polys[poly_index] = poly
                    self._dirty = True
                self._prev_k2 = poly

            # Display the current polyrhythm.
            padding_x = 8 + (int(OLED_WIDTH/4) * poly_index)
            padding_y = 12
            oled.text(f"{self.polys[poly_index]:>2}", padding_x, padding_y, 1)

            # Display graphic for seq 1 & 2 enablement.
            seq1, seq2 = self._trigger_seq(poly_index)
            y1 = OLED_HEIGHT - 10

            x1 = 9 + int(OLED_WIDTH/4) * poly_index
            (oled.fill_rect if seq1 else oled.rect)(x1, y1, 6, 6, 1)

            x1 = 17 + int(OLED_WIDTH/4) * poly_index
            (oled.fill_rect if seq2 else oled.rect)(x1, y1, 6, 6, 1)

    def main(self):
        while True:
            oled.fill(0)

            # Parameter edit index & display selected box
            self.param_index = k1.range(4)
            left_x = int((OLED_WIDTH/4) * self.param_index)
            right_x = int(OLED_WIDTH/4)
            oled.rect(left_x, 0, right_x, OLED_HEIGHT, 1)

            if self.page == 0 or self.page == 1:
                self.edit_sequence()

            if self.page == 2:
                self.edit_poly()

            self.reset_check()

            self.show_menu_header()
            oled.show()

            self.save_state()


# Main script execution
if __name__ == '__main__':
    script = PolyrhythmSeq()
    script.main()
