From 0d1737cd49362e5aa42cb4d2db4515b2acdb7b80 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 28 Nov 2025 01:12:27 -0500 Subject: [PATCH 1/5] Re-implement the dynamic BPM version of Pam's for the new menu system --- software/contrib/pams.py | 80 +- .../firmware/experimental/settings_menu.py | 943 ++++++++++++++++++ software/tests/mocks/micropython.py | 8 + 3 files changed, 1028 insertions(+), 3 deletions(-) diff --git a/software/contrib/pams.py b/software/contrib/pams.py index 4350a41c7..1f7d581fd 100644 --- a/software/contrib/pams.py +++ b/software/contrib/pams.py @@ -35,6 +35,7 @@ import gc import math +import micropython import time import random @@ -295,11 +296,18 @@ ## Reset on a rising edge, but don't start/stop the clock DIN_MODE_RESET = 'Reset' +## External clock +# +# The clock is assumed to be x1 input; we interpolate the BPM from +# the input and use that to set the master clock's frequency dynamically +DIN_MODE_EXTERNAL = "Ext. Clk" + ## Sorted list of DIN modes for display DIN_MODES = [ DIN_MODE_GATE, DIN_MODE_TRIGGER, - DIN_MODE_RESET + DIN_MODE_RESET, + DIN_MODE_EXTERNAL, ] ## True/False labels for yes/no settings (e.g. mute) @@ -334,6 +342,19 @@ ] +@micropython.native +def us2bpm(us, ppqn=1): + """Convert the length of a gate (rise-to-rise) to a BPM + + @param us The elapsed time in microseconds between consecutive rising edges + @param ppqn The PPQN value for the clock + + @return The equivalent BPM of the gate signal + """ + us_per_quarter_note = us * ppqn + return 60000000.0 / us_per_quarter_note + + class BufferedAnalogueReader(AnalogueReader): """A wrapper for basic AnalogueReader instances that read the ADC hardware on-demand @@ -485,6 +506,7 @@ def add_channels(self, channels): for ch in channels: self.channels.append(ch) + @micropython.native def on_tick(self, timer): """Callback function for the timer's tick """ @@ -959,11 +981,13 @@ def update_menu_visibility(self, new_value=None, old_value=None, config_point=No self.t_lock.is_visible = show_turing self.t_mode.is_visible = show_turing + @micropython.native def change_e_length(self, new_value=None, old_value=None, config_point=None, arg=None): self.e_trig.modify_choices(list(range(self.e_step.value+1)), self.e_step.value) self.e_rot.modify_choices(list(range(self.e_step.value+1)), self.e_step.value) self.recalculate_e_pattern() + @micropython.native def recalculate_e_pattern(self, new_value=None, old_value=None, config_point=None, arg=None): """Recalulate the euclidean pattern this channel outputs """ @@ -981,6 +1005,7 @@ def change_clock_mod(self): self.real_clock_mod = self.clock_mod.mapped_value self.clock_mod_dirty = False + @micropython.native def square_wave(self, tick, n_ticks): """Calculate the [0, 1] value of a square wave with PWM @@ -1005,6 +1030,7 @@ def square_wave(self, tick, n_ticks): else: return 0.0 + @micropython.native def triangle_wave(self, tick, n_ticks): """Calculate the [0, 1] value of a triangle wave @@ -1033,6 +1059,7 @@ def triangle_wave(self, tick, n_ticks): y = peak - step * (tick - rising_ticks) return y + @micropython.native def sine_wave(self, tick, n_ticks): """Calculate the [0, 1] value of a sine wave @@ -1049,6 +1076,7 @@ def sine_wave(self, tick, n_ticks): s_theta = (math.sin(theta) + 1) / 2 # (sin(x) + 1)/2 since we can't output negative voltages return s_theta + @micropython.native def adsr_wave(self, tick, n_ticks): """Calculate the [0, 1] level of an ADSR envelope @@ -1102,6 +1130,7 @@ def adsr_wave(self, tick, n_ticks): # outside of the ADSR return 0.0 + @micropython.native def turing_shift(self): """Shift the turing machine register by 1 bit """ @@ -1112,6 +1141,7 @@ def turing_shift(self): incoming_bit = (self.turing_register >> (self.t_length.value - 1)) & 0x01 self.turing_register = ((self.turing_register << 1) & 0xffff) | incoming_bit + @micropython.native def turing_wave(self, tick, n_ticks): """Calculate the [0, 1] output of a Turing Machine wave @@ -1161,6 +1191,7 @@ def reset_settings(self): for s in self.all_settings: s.reset_to_default() + @micropython.native def tick(self): """Advance the current pattern one tick and calculate the output voltage @@ -1264,6 +1295,7 @@ def tick(self): self.out_volts = out_volts + @micropython.native def apply(self): """Apply the calculated voltage to the output channel @@ -1354,6 +1386,10 @@ def __init__(self): # Are UI elements _not_ managed by the main menu dirty? self.ui_dirty = True + # create the clock first; we need to assign its callbacks + # to other settings later + self.clock = MasterClock(120) + self.din_mode = SettingMenuItem( config_point = ChoiceConfigPoint( "din", @@ -1361,10 +1397,9 @@ def __init__(self): DIN_MODE_GATE ), prefix = "Clk", - title = "DIN Mode" + title = "DIN Mode", ) - self.clock = MasterClock(120) self.channels = [ PamsOutput(cv1, self.clock, 1), PamsOutput(cv2, self.clock, 2), @@ -1460,6 +1495,16 @@ def __init__(self): ) self.main_menu.load_defaults(self._state_filename) + ## Keep an array of the last few intervals between incoming external clock signals + # + # Initially 1 microsecond just to avoid division-by-zero issues; 1us won't cause significant issues with the + # timing for most applications + self.external_clock_intervals_us = [1] * 2 + self.next_external_clock_index = 0 + + ## The time we received the last external clock signal in microseconds + self.last_external_clock_at_us = time.ticks_us() + @din.handler def on_din_rising(): if self.din_mode.value == DIN_MODE_GATE: @@ -1467,6 +1512,18 @@ def on_din_rising(): elif self.din_mode.value == DIN_MODE_RESET: for ch in self.channels: ch.reset() + elif self.din_mode.value == DIN_MODE_EXTERNAL: + now = time.ticks_us() + self.external_clock_intervals_us[self.next_external_clock_index] = time.ticks_diff(now, self.last_external_clock_at_us) + self.last_external_clock_at_us = now + self.next_external_clock_index = self.next_external_clock_index + 1 + if self.next_external_clock_index == len(self.external_clock_intervals_us): + self.next_external_clock_index = 0 + + # to keep the internal & external clocks from de-syncing too much, hard-sync + # the internal clock to the nearest beat + self.clock.elapsed_pulses = self.clock.PPQN * round(self.clock.elapsed_pulses / self.clock.PPQN) + else: if self.clock.is_running: self.clock.stop() @@ -1536,6 +1593,7 @@ def save_bank(self, bank, channel): def bank_filename(self, bank): return f'saved_state_{self.__class__.__qualname__}_{bank.lower().replace(" ", "_")}.json' + @micropython.native def main(self): prev_k1 = CV_INS["KNOB"].percent() prev_k2 = k2_bank.current.percent() @@ -1547,6 +1605,22 @@ def main(self): current_k1 = CV_INS["KNOB"].percent() current_k2 = k2_bank.current.percent() + # Handle dynamic BPM calculations based on the external clock + if self.din_mode.value == DIN_MODE_EXTERNAL: + avg_duration = sum(self.external_clock_intervals_us) / len(self.external_clock_intervals_us) + bpm = round(us2bpm(avg_duration, 1)) + if bpm < MasterClock.MIN_BPM: + bpm = MasterClock.MIN_BPM + elif bpm > MasterClock.MAX_BPM: + bpm = MasterClock.MAX_BPM + + if bpm != self.clock.bpm.value: + self.clock.bpm.choose(bpm - MasterClock.MIN_BPM) # convert to a 0-based index, allowed range is [1, MAX_BPM] + self.clock.bpm.display_override = f"{bpm} (Ext)" + self.ui_dirty = True + else: + self.clock.bpm.display_override = None + # wake up from the screensaver if we rotate a knob if abs(current_k1 - prev_k1) > 0.02 or abs(current_k2 - prev_k2) > 0.02: self.ui_dirty = True diff --git a/software/firmware/experimental/settings_menu.py b/software/firmware/experimental/settings_menu.py index e1f0007c5..8ff81e1ae 100644 --- a/software/firmware/experimental/settings_menu.py +++ b/software/firmware/experimental/settings_menu.py @@ -937,3 +937,946 @@ def visible_items(self): if item.is_visible: items.append(item) return items +# 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. +""" +Contains objects used for interactive settings menus + +Menu interaction is done using a knob and a button (K2 and B2 by default): +- rotate the knob to select the menu item +- short-press to enter edit mode +- rotate knob to select an option +- short-press button to apply the new option +- long-press button to change between the 2 menu levels (if possible) + +For examples of how to create and use the SettingsMenu, please refer to +- contrib/settings_menu_example.py + +Additional, more complex, examples can be found in: +- contrib/euclid.py +- contrib/pams.py (this is a very complex example) +- contrib/sequential_switch.py +- tools/conf_edit.py +""" + +import europi + +from configuration import * +from experimental.knobs import KnobBank, LockableKnob +from framebuf import FrameBuffer, MONO_HLSB +from machine import Timer +import os +import time + + +# fmt: off +AIN_GRAPHICS = bytearray(b"\x00\x00|\x00|\x00d\x00d\x00g\x80a\x80\xe1\xb0\xe1\xb0\x01\xf0\x00\x00\x00\x00") +KNOB_GRAPHICS = bytearray(b"\x06\x00\x19\x80 @@ @ \x80\x10\x82\x10A @\xa0 @\x19\x80\x06\x00") + +AIN_LABEL = "AIN" +KNOB_LABEL = "Knob" + +AUTOSELECT_AIN = "autoselect_ain" +AUTOSELECT_KNOB = "autoselect_knob" + +DANGER_GRAPHICS = bytearray(b'\x00\x00\x04\x00\n\x00\n\x00\x11\x00\x15\x00$\x80$\x80@@D@\x80 \xff\xe0') +# fmt: off + + +class MenuItem: + """ + Generic class for anything we can display in the menu + + :param parent: A MenuItem representing this item's parent, if this item is the bottom-level of a multi-level menu + :param children: A list of MenuItems representing this item's children, if this is the top-level of a multi-level menu + :param is_visible: Is this menu item visible by default? + """ + + def __init__( + self, children: list[object] = None, parent: object = None, is_visible: bool = True + ): + self.menu = None + self.parent = parent + self.children = children + self.is_visible = is_visible + + # Used to indicate that if this is the active menu item, the UI should re-render it + self.ui_dirty = False + + if parent and children: + raise Exception("Cannot specify parent and children in the same menu item") + + def short_press(self): + """ + Handler for when the user short-presses the button + + This does nothing by default, but can be overridden by child classes + """ + pass + + def draw(self, oled=europi.oled): + """ + Draw the item to the screen + + You must call the screen's ``.show()`` method after calling this + + :param oled: A Display-compatible object we draw to + """ + self.ui_dirty = False + + def add_child(self, item): + """ + Add a new child item to this item + + :param item: The menu item to add as a new child + """ + if self.children is None: + self.children = [] + self.children.append(item) + item.parent = self + item.menu = self.menu + + @property + def is_editable(self): + return False + + @is_editable.setter + def is_editable(self, can_edit): + pass + + @property + def is_visible(self): + return self._is_visible + + @is_visible.setter + def is_visible(self, is_visible): + self._is_visible = is_visible + + +class ChoiceMenuItem(MenuItem): + """ + A generic menu item for displaying a choice of options to the user + + :param parent: If the menu has multiple levels, what is this item's parent control? + :param children: If this menu has multiple levels, whar are this item's child controls? + :param title: The title to display at the top of the display when this control is active + :param prefix: A prefix to display before the title when this control is active + :param graphics: A dict of values mapped to FrameBuffer or bytearray objects, representing + 12x12 MONO_HLSB graphics to display along with the keyed values + :param labels: A dict of values mapped to strings, representing human-readible versions of the ConfigPoint + options + :param is_visible: Is this menu item visible by default? + """ + + SELECT_OPTION_Y = 16 + + def __init__( + self, + parent: MenuItem = None, + children: list[MenuItem] = None, + title: str = None, + prefix: str = None, + graphics: dict = None, + labels: dict = None, + is_visible: bool = True, + ): + super().__init__( + children=children, + parent=parent, + is_visible=is_visible + ) + + self.title = title + self.prefix = prefix + self.graphics = graphics + self.labels = labels + + self.display_override = None + + self._is_editable = False + + def short_press(self): + """Toggle is_editable on a short press""" + self.is_editable = not self.is_editable + + def draw(self, oled=europi.oled): + """ + Draw the current item to the display object + + You MUST call the display's .show() function after calling this in order to send the buffer to the display + hardware + + :param oled: A Display instance (or compatible class) to render the item + """ + super().draw(oled) + + if self.is_editable: + display_value = self.menu.knob.choice(self.choices) + else: + display_value = self.default_choice + + text_left = 0 + prefix_left = 1 + prefix_right = len(self.prefix) * europi.CHAR_WIDTH + title_left = len(self.prefix) * europi.CHAR_WIDTH + 4 + + # If we're in a top-level menu the submenu is non-empty. In that case, the prefix in inverted text + # Otherwise, the title in inverted text to indicate we're in the sub-menu + if self.children and len(self.children) > 0: + oled.fill_rect(prefix_left - 1, 0, prefix_right + 1, europi.CHAR_HEIGHT + 2, 1) + oled.text(self.prefix, prefix_left, 1, 0) + oled.text(self.title, title_left, 1, 1) + else: + oled.fill_rect( + title_left - 1, + 0, + len(self.title) * europi.CHAR_WIDTH + 2, + europi.CHAR_HEIGHT + 2, + 1, + ) + oled.text(self.prefix, prefix_left, 1, 1) + oled.text(self.title, title_left, 1, 0) + + if self.graphics: + gfx = self.graphics.get(display_value, None) + if gfx: + text_left = 14 # graphics are 12x12, so add 2 pixel padding + if type(gfx) is bytearray: + gfx = FrameBuffer(gfx, 12, 12, MONO_HLSB) + oled.blit(gfx, 0, self.SELECT_OPTION_Y) + + if self.display_override: + display_text = str(self.display_override) + elif self.labels: + display_text = self.labels.get(display_value, str(display_value)) + else: + display_text = str(display_value) + + if self.is_editable: + # draw the value in inverted text + text_width = len(display_text) * europi.CHAR_WIDTH + + oled.fill_rect( + text_left, + self.SELECT_OPTION_Y, + text_left + text_width + 3, + europi.CHAR_HEIGHT + 4, + 1, + ) + oled.text(display_text, text_left + 1, self.SELECT_OPTION_Y + 2, 0) + else: + # draw the selection in normal text + oled.text(display_text, text_left + 1, self.SELECT_OPTION_Y + 2, 1) + + @property + def choices(self): + raise NotImplemented("choices(self) must be implemented by the sub-class!") + + @property + def default_choice(self): + raise NotImplemented("default_choice(self) must be implemented by the sub-class!") + + @property + def is_editable(self): + return self._is_editable + + @is_editable.setter + def is_editable(self, can_edit): + self._is_editable = can_edit + + +class SettingMenuItem(ChoiceMenuItem): + """ + A single menu item that presents a setting the user can manipulate + + The menu item is a wrapper around a ConfigPoint, and uses + that object's values as the available selections. + + If the item has a callback function defined, it will be invoked once during initialization + + :param config_point: The configration option this menu item controls + :param parent: If the menu has multiple levels, what is this item's parent control? + :param children: If this menu has multiple levels, whar are this item's child controls? + :param title: The title to display at the top of the display when this control is active + :param prefix: A prefix to display before the title when this control is active + :param graphics: A dict of values mapped to FrameBuffer or bytearray objects, representing + 12x12 MONO_HLSB graphics to display along with the keyed values + :param labels: A dict of values mapped to strings, representing human-readible versions of the ConfigPoint options + :param callback: A function to invoke when this item's value changes. Must accept + (new_value, old_value, config_point, arg=None) as parameters + :param callback_arg: An optional additional argument to pass to the callback function + :param float_resolution: The resolution of floating-point config points + (ignored if config_point is not a FloatConfigPoint) + :param value_map: An optional dict to map the underlying simple ConfigPoint values + to more complex objects e.g. map the string "CMaj" to a Quantizer object + :param is_visible: Is this menu item visible by default? + :param autoselect_knob: If True, this item gets "Knob" as an additional choice, allowing ad-hoc selection via the knob + :param autoselect_cv: If True, this item gets "AIN" as an additional choice, allowing ad-hoc selection via the CV input + """ + + def __init__( + self, + config_point: ConfigPoint = None, + parent: MenuItem = None, + children: list[MenuItem] = None, + title: str = None, + prefix: str = None, + graphics: dict = None, + labels: dict = None, + callback=lambda new_value, old_value, config_point, arg: None, + callback_arg=None, + float_resolution=2, + value_map: dict = None, + is_visible: bool = True, + autoselect_knob: bool = False, + autoselect_cv: bool = False, + ): + if title is None: + title = config_point.name + if prefix is None: + prefix = "" + + super().__init__( + parent=parent, + children=children, + title=title, + prefix=prefix, + graphics=graphics, + labels=labels, + is_visible=is_visible, + ) + + self.autoselect_cv = autoselect_cv + self.autoselect_knob = autoselect_knob + self.value_map = value_map + + # the configuration setting that we're controlling via this menu item + # convert everything to a choice configuration; this way we can add the knob/ain options too + if type(config_point) is FloatConfigPoint: + self.float_resolution = float_resolution + self.src_config = config_point + choices = self.get_option_list() + self.config_point = ChoiceConfigPoint( + config_point.name, + choices=choices, + default=config_point.default, + danger=config_point.danger, + ) + + self.NUM_AUTOINPUT_CHOICES = 0 + if self.autoselect_cv or self.autoselect_knob: + if self.autoselect_cv: + self.NUM_AUTOINPUT_CHOICES += 1 + if self.autoselect_knob: + self.NUM_AUTOINPUT_CHOICES += 1 + + if not self.graphics: + self.graphics = {} + self.graphics[AUTOSELECT_AIN] = AIN_GRAPHICS + self.graphics[AUTOSELECT_KNOB] = KNOB_GRAPHICS + + if not self.labels: + self.labels = {} + self.labels[AUTOSELECT_AIN] = AIN_LABEL + self.labels[AUTOSELECT_KNOB] = KNOB_LABEL + + self.callback_fn = callback + self.callback_arg = callback_arg + + # assign the initial value without firing any callbacks + self._value = self.config_point.default + self._value_choice = self.config_point.default + + @property + def choices(self): + return self.config_point.choices + + @property + def default_choice(self): + return self.value_choice + + def reset_to_default(self): + """ + Reset this item to its default value + """ + self.choose(self.src_config.default) + + def modify_choices(self, choices=None, new_default=None): + """ + Regenerate this item's available choices + + This is needed if we externally modify e.g. the maximum/minimum values of the underlying + config point as a result of one option needing to be within a range determined by another. + + :param choices: The list of new options we want to allow the user to choose from, excluding any autoselections + :param new_default: A value to assign to this setting if its existing value is out-of-range + """ + if choices is None: + choices = self.get_option_list() + else: + # add the autoselect items, if needed + if self.autoselect_knob: + choices.append(AUTOSELECT_KNOB) + if self.autoselect_knob: + choices.append(AUTOSELECT_AIN) + + self.config_point.choices = choices + still_valid = self.config_point.validate(self.value) + if not still_valid.is_valid: + self.choose(new_default) + + def short_press(self): + """ + Handle a short button press + + This enters edit mode, or applies the selection and + exits edit mode + """ + # don't allow edit-mode for strings! + if type(self.src_config) is StringConfigPoint: + return + + if self.is_editable: + new_choice = self.menu.knob.choice(self.config_point.choices) + if new_choice != self.value_choice: + # apply the currently-selected choice if we're in edit mode + self.choose(new_choice) + self.ui_dirty = True + self.menu.settings_dirty = True + + super().short_press() + + def get_option_list(self): + """ + Get the list of options the user can choose from + + :return: A list of choices + """ + t = type(self.src_config) + if t is FloatConfigPoint: + FLOAT_RESOLUTION = 1.0 / (10**self.float_resolution) + items = [] + x = self.src_config.minimum + while x <= self.src_config.maximum: + items.append(round(x, self.float_resolution)) + x += FLOAT_RESOLUTION + items.append(round(self.src_config.maximum, self.float_resolution)) + elif t is IntegerConfigPoint: + items = list(range(self.src_config.minimum, self.src_config.maximum + 1)) + elif t is BooleanConfigPoint: + items = [False, True] + elif t is ChoiceConfigPoint: + items = list(self.src_config.choices) # make a copy of the items so we can append the autoselect items! + elif t is StringConfigPoint: + # don't allow autoselect for strings; they're not editable/selectable + return [self.src_config.default] + else: + raise Exception(f"Unsupported ConfigPoint type: {type(self.src_config)}") + + # Add the autoselect inputs, if needed + if self.autoselect_knob: + items.append(AUTOSELECT_KNOB) + if self.autoselect_knob: + items.append(AUTOSELECT_AIN) + + return items + + def autoselect(self, percent: float): + """ + Called by the parent menu when the Knob/CV timer fires, automatically updating the value of this item + + :param percent: A value 0-1 indicating the level of the knob/cv source + """ + last_choice = len(self.config_point.choices) - self.NUM_AUTOINPUT_CHOICES + index = int( + percent * last_choice + ) + if index >= last_choice: + index = last_choice - 1 + + item = self.config_point.choices[index] + if item != self._value: + self.ui_dirty = True + old_value = self._value + self._value = item + self.callback_fn(item, old_value, self.config_point, self.callback_arg) + + def choose(self, choice): + """ + Set the raw value of this item's ConfigPoint + + :param choice: The value to assign to the ConfigPoint. + + :raises ValueError: if the given choice is not valid for this setting + """ + # choose whatever string we're given + if type(self.src_config) is StringConfigPoint: + self.src_config.default = choice + self.config_point.choices = [choice] + self._value = choice + self._value_choice = choice + return + + # kick out early if we aren't actually choosing anything + if choice == self.value_choice: + return + + validation = self.config_point.validate(choice) + if not validation.is_valid: + raise ValueError(f"{choice} is not a valid value for {self.config_point.name}") + + old_value = self._value_choice + self._value_choice = choice + + if old_value == AUTOSELECT_AIN: + self.menu.unregister_autoselect_cv(self) + elif old_value == AUTOSELECT_KNOB: + self.menu.unregister_autoselect_knob(self) + + if self._value_choice == AUTOSELECT_AIN: + self.menu.register_autoselect_cv(self) + elif self._value_choice == AUTOSELECT_KNOB: + self.menu.register_autoselect_knob(self) + else: + self._value = choice + self.callback_fn(choice, old_value, self.config_point, self.callback_arg) + + def draw(self, oled=europi.oled): + """ + Render this item to the screen + + You must call the screen's ``.show()`` method after calling this + + :param oled: The screen we're drawing to + """ + super().draw(oled) + + # show the real value in parentheses + if self.value_choice == AUTOSELECT_AIN or self.value_choice == AUTOSELECT_KNOB: + oled.text(f"({self.value})", europi.OLED_WIDTH//2, self.SELECT_OPTION_Y, 1) + + # add a ! to the lower-right corner to indicate a potentially + # volatile, edit-at-your-own-risk item + if self.config_point.danger: + fb = FrameBuffer(DANGER_GRAPHICS, 12, 12, MONO_HLSB) + oled.blit(fb, europi.OLED_WIDTH - 12, europi.OLED_HEIGHT - 12) + + if type(self.src_config) is StringConfigPoint: + oled.text( + "R/O", + europi.OLED_WIDTH - 3 * europi.CHAR_WIDTH, + europi.OLED_HEIGHT - europi.CHAR_HEIGHT, + 1 + ) + + @property + def value_choice(self): + """The value the user has chosen from the menu""" + return self._value_choice + + @property + def value(self): + """ + Get the raw value of this item's ConfigPoint + + You should use .mapped_value if you have assigned a value_map to the constructor + """ + return self._value + + @property + def mapped_value(self): + """ + Get the value of this item mapped by the value_map + + If value_map was not set by the constructor, this property returns the same + thing as .value + """ + if self.value_map: + return self.value_map[self._value] + return self._value + + +class ActionMenuItem(ChoiceMenuItem): + """ + A menu item that just invokes a callback function when selected. + + This class is similar to the SettingMenuItem, but doesn't wrap a ConfigPoint; it just has + options and fires the callback when you choose one + + :param actions: The list of choices the user can pick from. e.g. ["Cancel", "Ok"] + :param callback: The function to call when the user invokes the action. The selected item from choices is passed as the first parameter + :param callback_arg: The second parameter passed to the callback + :param parent: If the menu has multiple levels, what is this item's parent control? + :param children: If this menu has multiple levels, whar are this item's child controls? + :param title: The title to display at the top of the display when this control is active + :param prefix: A prefix to display before the title when this control is active + :param graphics: A dict of values mapped to FrameBuffer or bytearray objects, representing + 12x12 MONO_HLSB graphics to display along with the keyed values + :param labels: A dict of values mapped to strings, representing human-readible versions of the ConfigPoint options + :param is_visible: Is this menu item visible by default? + """ + + def __init__( + self, + actions: list[object], + callback = lambda x: None, + callback_arg: object = None, + parent: MenuItem = None, + children: list[MenuItem] = None, + title: str = None, + prefix: str = None, + graphics: dict = None, + labels: dict = None, + is_visible: bool = True, + ): + super().__init__( + parent=parent, + children=children, + title=title, + prefix=prefix, + graphics=graphics, + labels=labels, + is_visible=is_visible, + ) + + self.actions = actions + self.callback = callback + self.callback_arg = callback_arg + self.title = title + self.prefix = prefix + + self._is_editable = False + + @property + def choices(self): + return self.actions + + @property + def default_choice(self): + return self.choices[0] + + def short_press(self): + if self.is_editable: + # fire the callback if we're exiting edit-mode + choice = self.menu.knob.choice(self.choices) + self.callback(choice, self.callback_arg) + + super().short_press() + + + +class SettingsMenu: + """ + A menu-based GUI for any EuroPi script. + + This class is assumed to be the main interaction method for the program. + + Long/short press callbacks are invoked inside the handler for the falling edge of the button. It is recommended + to avoid any lengthy operations inside these callbacks, as they may prevent other interrupts from being + handled properly. + + :param menu_items: A list of MenuItem objects representing the top-level of the menu + :param navigation_button: The button the user presses to interact with the menu + :param navigation_knob: The knob the user turns to scroll through the menu. This may be an + experimental.knobs.KnobBank with 3 menu levels called "main_menu", "submenu" and "choice", or a raw knob like europi.k2 + :param short_press_cb: An optional callback function to invoke when the user interacts with a short-press of the button + :param long_press_cb: An optional callback function to invoke when the user interacts with a long-press of the button + :param autoselect_knob: A knob that the user can turn to select items without needing to menu-dive + :param autoselect_cv: An analogue input the user can use to select items with CV + """ + + # Treat a long press as anything more than 500ms + LONG_PRESS_MS = 500 + + def __init__( + self, + menu_items: list = None, + navigation_button: europi.Button = europi.b2, + navigation_knob: europi.Knob = europi.k2, + short_press_cb=lambda: None, + long_press_cb=lambda: None, + autoselect_knob: europi.Knob = europi.k1, + autoselect_cv: europi.AnalogueInput = europi.ain, + ): + self._knob = navigation_knob + self.button = navigation_button + + self._ui_dirty = True + + self.short_press_cb = short_press_cb + self.long_press_cb = long_press_cb + + self.button.handler(self.on_button_press) + self.button.handler_falling(self.on_button_release) + + self.items = [] + if menu_items: + for item in menu_items: + self.items.append(item) + + self.active_items = self.items + self.active_item = self.knob.choice(self.items) + + self.button_down_at = time.ticks_ms() + + # Indicates to the application that we need to save the settings to disk + self.settings_dirty = False + + # Iterate through the menu and get all of the config points + self.config_points_by_name = {} + self.menu_items_by_name = {} + for item in self.items: + item.menu = self + + if type(item) is SettingMenuItem: + self.config_points_by_name[item.config_point.name] = item.config_point + self.menu_items_by_name[item.config_point.name] = item + + if item.children: + for c in item.children: + c.menu = self + + if type(c) is SettingMenuItem: + self.config_points_by_name[c.config_point.name] = c.config_point + self.menu_items_by_name[c.config_point.name] = c + + # set up a timer for menu items that choose automatically based on the alternate knob or ain + self.autoselect_cv = autoselect_cv + self.autoselect_knob = autoselect_knob + self.autoselect_timer = Timer() + self.autoselect_cv_items = [] + self.autoselect_knob_items = [] + + @property + def knob(self): + """Get the navigation knob that controls this menu""" + if type(self._knob) is KnobBank: + return self._knob.current + else: + return self._knob + + def get_config_points(self): + """ + Get the config points for the menu so we can load/save them as needed + """ + return list(self.config_points_by_name.values()) + + def load_defaults(self, settings_file): + """ + Load the initial settings from the file + + :param settings_file: The path to a JSON file where the user's settings are saved + """ + failed_key_counts = {} + + # because we may have a situation where, via callbacks, some settings' options are dynamically + # modified, we need to load iteratively + json_data = load_json_file(settings_file) + keys = list(json_data.keys()) + max_tries = len(keys) + while len(keys) > 0: + k = keys[0] + keys.pop(0) + if k in self.menu_items_by_name: + try: + self.menu_items_by_name[k].choose(json_data[k]) + except ValueError as err: + if k not in failed_key_counts: + failed_key_counts[k] = 1 + else: + failed_key_counts[k] += 1 + # Bump this key to the back and try again; a prior key might fix the problem + if failed_key_counts[k] < max_tries: + keys.append(k) + else: + raise + + def save(self, settings_file): + """ + Save the current settings to the specified file + + :param settings_file: The path to the JSON file to generate + """ + data = {} + for item in self.menu_items_by_name.values(): + data[item.config_point.name] = item.value_choice + try: + # ensure the /config directory exists + # save_to_file won't create it for us! + os.mkdir("config") + except OSError: + pass + ConfigFile.save_to_file(settings_file, data) + self.settings_dirty = False + + def on_button_press(self): + """Handler for the rising edge of the button signal""" + self.button_down_at = time.ticks_ms() + self._ui_dirty = True + + def on_button_release(self): + """Handler for the falling edge of the button signal""" + self._ui_dirty = True + if time.ticks_diff(time.ticks_ms(), self.button_down_at) >= self.LONG_PRESS_MS: + self.long_press() + else: + self.short_press() + + def short_press(self): + """ + Handle a short button press + + This enters edit mode, or applies the selection and + exits edit mode + """ + self.active_item.short_press() + + # Cycle the knob bank, if necessary + if type(self._knob) is KnobBank: + if self.active_item.is_editable: + self._knob.set_current("choice") + if issubclass(type(self.active_item), SettingMenuItem): + # lock the knob to our current value + self._knob.current.change_lock_value( + self.active_item.choices.index(self.active_item.value_choice) / + len(self.active_item.choices) + ) + elif self.active_item.children and len(self.active_item.children) > 0: + self._knob.set_current("main_menu") + else: + self._knob.set_current("submenu") + + self.short_press_cb() + + def long_press(self): + """ + Handle a long button press + + This changes between the two menu levels (if possible) + """ + # exit editable mode when we change menu levels + self.active_item.is_editable = False + + # we're in the top-level menu; go to the submenu if it exists + if self.active_items == self.items: + if self.active_item.children: + self.active_items = self.active_item.children + if type(self.knob) is KnobBank: + self.knob.set_current("submenu") + else: + self.active_items = self.items + if type(self.knob) is KnobBank: + self.knob.set_current("main_menu") + + self.long_press_cb() + + def draw(self, oled=europi.oled): + """ + Draw the menu to the given display + + You should call the display's .fill(0) function before calling this in order to clear the screen. Otherwise + the menu item will be drawn on top of whatever is on the screen right now. (In some cases this may be the + desired result, but when in doubt, call oled.fill(0) first). + + You MUST call the display's .show() function after calling this in order to send the buffer to the display + hardware + + :param oled: The display object to draw to + """ + if not self.active_item.is_editable: + self.active_item = self.knob.choice(self.visible_items) + self.active_item.draw(oled) + self._ui_dirty = False + + def register_autoselect_cv(self, menu_item: SettingMenuItem): + """ + Connects a menu item to this menu's CV input + + :param menu_item: The item that wants to subscribe to the CV input + """ + if len(self.autoselect_cv_items) == 0 and len(self.autoselect_knob_items) == 0: + self.autoselect_timer.init(freq=10, mode=Timer.PERIODIC, callback=self.do_autoselect) + self.autoselect_cv_items.append(menu_item) + + def register_autoselect_knob(self, menu_item: SettingMenuItem): + """ + Connects a menu item to this menu's knob input + + :param menu_item: The item that wants to subscribe to the knob input + """ + if len(self.autoselect_cv_items) == 0 and len(self.autoselect_knob_items) == 0: + self.autoselect_timer.init(freq=10, mode=Timer.PERIODIC, callback=self.do_autoselect) + self.autoselect_knob_items.append(menu_item) + + def unregister_autoselect_cv(self, menu_item: SettingMenuItem): + """ + Disconnects a menu item to this menu's CV input + + :param menu_item: The item that wants to unsubscribe from the CV input + """ + self.autoselect_cv_items.remove(menu_item) + if len(self.autoselect_cv_items) == 0 and len(self.autoselect_knob_items) == 0: + self.autoselect_timer.deinit() + + def unregister_autoselect_knob(self, menu_item: SettingMenuItem): + """ + Disconnects a menu item to this menu's knob input + + :param menu_item: The item that wants to unsubscribe from the knob input + """ + self.autoselect_knob_items.remove(menu_item) + if len(self.autoselect_cv_items) == 0 and len(self.autoselect_knob_items) == 0: + self.autoselect_timer.deinit() + + def do_autoselect(self, timer): + """ + Callback function for the autoselection timer + + Reads from ain and/or the autoselect knob and applies that choice to all subscribed menu items + + :param timer: The timer instance that fired this callback + """ + if len(self.autoselect_cv_items) > 0: + ain_percent = self.autoselect_cv.percent() + for item in self.autoselect_cv_items: + item.autoselect(ain_percent) + + if len(self.autoselect_knob_items) > 0: + knob_percent = self.autoselect_knob.percent() + for item in self.autoselect_knob_items: + item.autoselect(knob_percent) + + @property + def ui_dirty(self): + """ + Is the UI currently dirty and needs re-drawing? + + This will be true if the user has pressed the button or rotated the knob sufficiently + to change the active item + """ + return self._ui_dirty or self.active_item.ui_dirty or self.active_item != self.knob.choice(self.visible_items) + + @property + def visible_items(self): + """ + Get the set of visible menu items for the current state of the menu + + Menu items can be shown/hidden by setting their is_visible property. Normally this should be done in + a value-change callback of a menu item to show/hide dependent other items. + """ + items = [] + for item in self.active_items: + if item.is_visible: + items.append(item) + return items diff --git a/software/tests/mocks/micropython.py b/software/tests/mocks/micropython.py index b5af9b39a..b15082cec 100644 --- a/software/tests/mocks/micropython.py +++ b/software/tests/mocks/micropython.py @@ -1,2 +1,10 @@ def const(x): return x + + +def native(x): + return x + + +def viper(x): + return x From 4a2fa9d7a5e4f7ba3fdc5ebe66590778a1fe92bc Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 28 Nov 2025 01:23:17 -0500 Subject: [PATCH 2/5] Add external clock mode to the readme --- software/contrib/pams.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/software/contrib/pams.md b/software/contrib/pams.md index 2c485a21f..56174a124 100644 --- a/software/contrib/pams.md +++ b/software/contrib/pams.md @@ -106,6 +106,9 @@ The submenu for the main clock has the following options: - `Trigger`: the clock will toggle between the running & stopped states on a rising edge - `Reset`: the clock will not change, but all waveforms & euclidean patterns will reset to the beginning + - `Ext. Clk`: the clock's BPM is dynamically calculated based on the input square wave. The input + clock is synchronized to the `x1` outputs. Rapidly changing the external clock rate may result + in synchronization issues. - `Stop-Rst` -- Stop & Reset: if true, all waves & euclidean patterns will reset when the clock starts. Otherwise they will continue from where they stopped From 3cf9c0a3766fb280e3e4114838584e3932d5aea2 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 28 Nov 2025 03:37:09 -0500 Subject: [PATCH 3/5] Don't suppress the screensaver when in external-clock mode --- software/contrib/pams.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/software/contrib/pams.py b/software/contrib/pams.py index 1f7d581fd..47d7cf140 100644 --- a/software/contrib/pams.py +++ b/software/contrib/pams.py @@ -1627,7 +1627,12 @@ def main(self): ssoled.notify_user_interaction() # only re-render the UI if necessary - if self.main_menu.ui_dirty or self.ui_dirty: + if self.ui_dirty and self.clock.bpm.display_override and self.main_menu.active_item == self.clock.bpm: + # re-draw if the external BPM needs updating, but don't suppress the screensaver + ssoled.fill(0) + self.main_menu.draw(ssoled) + self.ui_dirty = False + elif self.main_menu.ui_dirty or self.ui_dirty: ssoled.notify_user_interaction() ssoled.fill(0) self.main_menu.draw(ssoled) From 0e3ecbd4f9113ba3cf13434adfc715da71b754cf Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Fri, 28 Nov 2025 13:09:34 -0500 Subject: [PATCH 4/5] Add a section about configuring VS Code to the programming instructions --- software/programming_instructions.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/software/programming_instructions.md b/software/programming_instructions.md index 4393d12bc..bcbc936a8 100644 --- a/software/programming_instructions.md +++ b/software/programming_instructions.md @@ -237,3 +237,19 @@ As with all hardware, the EuroPi has certain limitations. Some are more obvious Auto-generated API documentation for Europi's core firmware and the user-created `experimental` libraries can be found [here](https://allen-synthesis.github.io/EuroPi/). + +# VS Code Configuration + +If you use [VS Code](code.visualstudio.com) for development, you may find it helpful to create a `.vscode/settings.json` file in the root of the EuroPi repository. Copy the following into `.vscode/settings.json`: + +```json +{ + "python.analysis.extraPaths": [ + "./software", + "./software/firmware", + "./software/tests/mocks" + ] +} +``` + +This will allow the [Python extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python) to properly detect the EuroPi modules and provide Intellisense completion & hints when using a native Python3 interpreter. Note that this will use the `mocks` module to provide definitions for built-in Micropython libraries that do not exist on standard Python3 installations. The code will not _run_ natively on your Windows/Mac/Linux development machine, but the VS Code IDE will at least provide appropriate code suggestions. From 02545b50ad3d6099bd951a98f82847f15bc4e146 Mon Sep 17 00:00:00 2001 From: Chris I-B Date: Sun, 30 Nov 2025 18:36:24 -0500 Subject: [PATCH 5/5] Increase the max clock speed to 300. Add a section about external clocking limitations to the documentation --- software/contrib/pams.md | 19 ++++++++++++++++--- software/contrib/pams.py | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/software/contrib/pams.md b/software/contrib/pams.md index 56174a124..185f0d1f5 100644 --- a/software/contrib/pams.md +++ b/software/contrib/pams.md @@ -97,7 +97,7 @@ The vizualization does not have any submenu items and simply displays the voltag The main clock menu has the following options: -- `BPM` -- the main BPM for the clock. Must be in the range `[1, 240]`. +- `BPM` -- the main BPM for the clock. Must be in the range `[1, 300]`. The submenu for the main clock has the following options: @@ -107,11 +107,23 @@ The submenu for the main clock has the following options: - `Reset`: the clock will not change, but all waveforms & euclidean patterns will reset to the beginning - `Ext. Clk`: the clock's BPM is dynamically calculated based on the input square wave. The input - clock is synchronized to the `x1` outputs. Rapidly changing the external clock rate may result - in synchronization issues. + clock is synchronized to the `x1` outputs. - `Stop-Rst` -- Stop & Reset: if true, all waves & euclidean patterns will reset when the clock starts. Otherwise they will continue from where they stopped +### External Clocking Limitations + +Pam's can only be clocked within the `BPM` range described above. Any external clock signal that +is slower than the minimum BPM (1) or faster than the maximum BPM (300 at the time of writing) will +be clamped within this range. + +Pam's internal clock will be hard-sync'd with the external signal on the external signal's rising +edge, so even at out-of-range speeds the system will make a best-effort to stay synchronized. + +Clocking Pam's with a highly-variable clock source may result in synchronization issues. Because of +the hard-syncing that occurs, any `x1` outputs will remain mostly synchronized, but other outputs +may become desynchronized if the external clock speed varies too much. + ## CV Channel Options Each of the 6 CV output channels has the following options: @@ -378,6 +390,7 @@ least 10ms. The table below shows approximate trigger times for some common BPM | BPM | Trigger length (ms, approx.) | PPQN pulses | |-----|------------------------------|-------------| +| 300 | 12.5 | 3 | | 240 | 10.4 | 2 | | 120 | 10.4 | 1 | | 90 | 13.9 | 1 | diff --git a/software/contrib/pams.py b/software/contrib/pams.py index 47d7cf140..08401add9 100644 --- a/software/contrib/pams.py +++ b/software/contrib/pams.py @@ -457,7 +457,7 @@ class MasterClock: MIN_BPM = 1 ## The absolute fastest the clock can go - MAX_BPM = 240 + MAX_BPM = 300 def __init__(self, bpm): """Create the main clock to run at a given bpm