aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authors-ol <s+removethis@s-ol.nu>2022-05-19 19:15:59 +0000
committers-ol <s+removethis@s-ol.nu>2022-05-21 16:51:01 +0000
commit41bc53a7ac16fe96c9cd9de2ef930b945f60b981 (patch)
tree28bb59e92bd957b7a1ed929a972bfbb003c131a2
parentinitial commit (diff)
downloadfirmware-41bc53a7ac16fe96c9cd9de2ef930b945f60b981.tar.gz
firmware-41bc53a7ac16fe96c9cd9de2ef930b945f60b981.zip
initial commit
-rw-r--r--.gitignore10
-rw-r--r--boot.py17
-rw-r--r--code.py5
-rw-r--r--fonts/bitbuntu.pcfbin0 -> 10880 bytes
-rw-r--r--hex33board/__init__.py262
-rw-r--r--hex33board/base.py228
-rw-r--r--hex33board/layout.py1
-rw-r--r--hex33board/matrix.py210
-rw-r--r--hex33board/menu.py244
-rw-r--r--hex33board/util.py27
-rw-r--r--test.wavbin0 -> 221596 bytes
11 files changed, 1004 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cf6c02b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+boot_out.txt
+hex.config.json
+
+__pycache__
+lib
+
+.Trashes
+.fseventsd
+.metadata_never_index
+System Volume Information
diff --git a/boot.py b/boot.py
new file mode 100644
index 0000000..41a76e8
--- /dev/null
+++ b/boot.py
@@ -0,0 +1,17 @@
+import supervisor
+import board
+import digitalio
+import storage
+
+supervisor.set_usb_identification("s-ol", "0x33.board", 0x732D, 0x3362)
+
+col = digitalio.DigitalInOut(board.GP8)
+row = digitalio.DigitalInOut(board.GP10)
+col.switch_to_output(value=True)
+row.switch_to_input(pull=digitalio.Pull.DOWN)
+if row.value:
+ storage.disable_usb_drive()
+ storage.remount("/", False)
+ print("Mounting read-write")
+else:
+ print("Mounting readonly")
diff --git a/code.py b/code.py
new file mode 100644
index 0000000..24bed23
--- /dev/null
+++ b/code.py
@@ -0,0 +1,5 @@
+# import test
+from hex33board import Keyboard
+
+k = Keyboard()
+k.run()
diff --git a/fonts/bitbuntu.pcf b/fonts/bitbuntu.pcf
new file mode 100644
index 0000000..cd6495e
--- /dev/null
+++ b/fonts/bitbuntu.pcf
Binary files differ
diff --git a/hex33board/__init__.py b/hex33board/__init__.py
new file mode 100644
index 0000000..de2619f
--- /dev/null
+++ b/hex33board/__init__.py
@@ -0,0 +1,262 @@
+from __future__ import annotations
+
+import board
+from busio import I2C, UART
+from adafruit_displayio_ssd1306 import SSD1306
+from adafruit_display_text import label
+from displayio import I2CDisplay
+from neopixel import NeoPixel
+from audiopwmio import PWMAudioOut
+from audiocore import WaveFile
+import displayio
+import supervisor
+
+import usb_midi
+from adafruit_midi import MIDI
+
+from .matrix import Matrix
+from .util import ticks_diff, FONT_10, led_map
+from .base import Key, Note, Scale, Layout, WickiHaydenLayout, HarmonicLayout, GerhardLayout, Mode
+from .menu import Settings, SliderSetting, ChoiceSetting, MenuMode, _color_offor
+
+
+class Keyboard:
+ scale: Scale
+ layout: Layout
+ mode: Mode
+
+ keys: list[Key]
+ notes: dict[int, Note]
+
+ matrix: Matrix
+ pixels: NeoPixel
+ display: SSD1306
+
+ midi_usb: MIDI
+ midi_din: MIDI
+
+ audio_out: PWMAudioOut
+
+ def __init__(self):
+ self.matrix = Matrix(
+ [board.GP8, board.GP4, board.GP0, board.GP6, board.GP7, board.GP9],
+ [board.GP5, board.GP1, board.GP2, board.GP3, board.GP10],
+ )
+
+ self.pixels = NeoPixel(board.GP11, 48 + 4, auto_write=False)
+
+ displayio.release_displays()
+ i2c = I2C(sda=board.GP14, scl=board.GP15)
+ bus = I2CDisplay(i2c, device_address=0x3C)
+ self.display = SSD1306(bus, width=128, height=32, rotation=180, auto_refresh=False)
+
+ din = UART(tx=board.GP16, rx=None, baudrate=31250)
+ self.midi_din = MIDI(None, din)
+
+ midi_in = next(p for p in usb_midi.ports if isinstance(p, usb_midi.PortIn))
+ midi_out = next(p for p in usb_midi.ports if isinstance(p, usb_midi.PortOut))
+ self.midi_usb = MIDI(midi_in, midi_out)
+
+ self.audio_out = PWMAudioOut(left_channel=board.GP12, right_channel=board.GP13)
+ self.test_file = WaveFile(open("test.wav", "rb"))
+
+ self.keys = [Key(self, i) for i in range(48)]
+ self.notes = {}
+ self.notes_expiring = {}
+
+ self.settings = Settings({
+ "midi_ch_usb": SliderSetting("MIDI out chan (USB)", 15, fmt="CH{}", thresh = lambda v, t: v == t),
+ "midi_ch_din": SliderSetting("MIDI out chan (Jack)", 15, fmt="CH{}", thresh = lambda v, t: v == t),
+ "midi_vel": SliderSetting("MIDI velocity", 127, default=64),
+ "rgb_bright": SliderSetting("LED brightness", 100, default=100, fmt="{}%", color=_color_offor),
+ "jam_timeout": ChoiceSetting("Jam Mode fade time", [0, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 10, 12, 20], default=6, fmt="{:.2f}s", color=_color_offor),
+ "layout_name": ChoiceSetting("Keyboard Layout", ['wicki/hayden', 'harmonic table', 'gerhard'], default='wicki/hayden'),
+
+ "layout_offset": SliderSetting("Layout start note", 127, default=24),
+ "scale_name": ChoiceSetting("Highlight Scale", ['major', 'minor nat', 'minor harm', 'minor mel', 'minor hung', 'whole', 'penta'], default='major'),
+ "scale_root": SliderSetting("Scale root note", 127, default=43),
+ })
+
+ self.layout = WickiHaydenLayout()
+ self.scale = Scale(0, Scale.STEPS["major"], "major")
+
+ self.settings.on('midi_ch_usb', self.on_midi_ch_usb)
+ self.settings.on('midi_ch_din', self.on_midi_ch_din)
+ self.settings.on('midi_vel', self.on_midi_vel)
+ self.settings.on('rgb_bright', self.on_rgb_bright)
+ self.settings.on('jam_timeout', self.on_jam_timeout)
+ self.settings.on('layout_name', self.on_layout)
+ self.settings.on('layout_offset', self.on_layout)
+ self.settings.on('scale_name', self.on_scale)
+ self.settings.on('scale_root', self.on_scale)
+
+ self.modes = {
+ "base": BaseMode(self),
+ "base_shift": BaseShiftMode(self),
+ "menu": MenuMode(self, settings=self.settings, ui_settings_ids=[
+ "midi_ch_usb", "midi_ch_din", "midi_vel", "rgb_bright", "jam_timeout", "layout_name"
+ ]),
+ }
+ self.mode = self.modes["base"]
+
+ self.settings.load()
+
+ def on_midi_ch_usb(self, ch): self.midi_usb.out_channel = ch
+ def on_midi_ch_din(self, ch): self.midi_din.out_channel = ch
+ def on_midi_vel(self, vel): self.velocity = vel
+ def on_rgb_bright(self, b): self.pixels.brightness = b/100
+ def on_jam_timeout(self, t): self.jam_timeout = t * 1000
+ def on_layout(self, v):
+ name = self.settings.get('layout_name').value
+ offset = self.settings.get('layout_offset').value
+ if name == 'wicki/hayden':
+ self.layout = WickiHaydenLayout(offset)
+ elif name == 'harmonic table':
+ self.layout = HarmonicLayout(offset)
+ elif name == 'gerhard':
+ self.layout = GerhardLayout(offset)
+ self.update_scale()
+ self.audio_out.play(self.test_file)
+ def on_scale(self, v):
+ name = self.settings.get('scale_name').value
+ root = self.settings.get('scale_root').value
+ self.scale = Scale(root, Scale.STEPS[name], name)
+ self.update_scale()
+
+ @property
+ def mode(self) -> Mode:
+ return self._mode
+
+ @mode.setter
+ def mode(self, mode: Mode):
+ if hasattr(self, '_mode'):
+ if self._mode == mode:
+ return
+ self._mode.exit()
+
+ self._mode = mode
+ self._mode.enter()
+
+ def update_scale(self):
+ for key in self.keys:
+ key.update_scale()
+
+ def send_midi(self, message):
+ self.midi_usb.send(message)
+ self.midi_din.send(message)
+
+ def run(self):
+ while True:
+ ticks_ms = supervisor.ticks_ms()
+ for note in self.notes_expiring.values():
+ note.update_expiry(ticks_ms)
+
+ for (i, pressed) in self.matrix.scan_for_changes():
+ if self.mode:
+ handled = self.mode.key_event(i, pressed)
+ if handled:
+ continue
+
+ if i < len(self.keys):
+ key = self.keys[i]
+ if pressed:
+ key.on_press()
+ else:
+ key.on_release()
+
+ for key in self.keys:
+ self.pixels[led_map[key.i]] = key.update_color()
+ self.mode.update_pixels(self.pixels)
+
+ active = [pitch for pitch in self.notes]
+ active.sort()
+ self.modes["base"].notes_label.text = ' '.join(self.scale.label(p) for p in active)
+
+ self.pixels.show()
+ self.display.refresh()
+
+
+class BaseShiftMode(Mode):
+ color = (0.1, 1.0, 1.0)
+
+ def enter(self):
+ self.entered = supervisor.ticks_ms()
+ self.pressed_something = False
+
+ def key_event(self, i: int, pressed: bool) -> bool:
+ if not pressed and i == Key.MENU_I:
+ held = ticks_diff(supervisor.ticks_ms(), self.entered)
+ if not self.pressed_something and held <= 800:
+ # tap: enter menu
+ self.keyboard.mode = self.keyboard.modes["menu"]
+ return True
+
+ self.keyboard.mode = self.keyboard.modes["base"]
+ return True
+
+ if not pressed:
+ return False
+
+ if i < len(self.keyboard.keys):
+ key = self.keyboard.keys[i]
+ self.keyboard.settings.get("scale_root").value = key._pitch
+ self.keyboard.settings.dispatch("scale_root")
+ self.keyboard.modes["base"].update_display()
+ self.pressed_something = True
+ return True
+
+ if i == Key.PREV_I:
+ self.keyboard.settings.get("scale_name").press_prev()
+ self.keyboard.settings.dispatch("scale_name")
+ self.keyboard.modes["base"].update_display()
+ self.pressed_something = True
+ return True
+
+ if i == Key.NEXT_I:
+ self.keyboard.settings.get("scale_name").press_next()
+ self.keyboard.settings.dispatch("scale_name")
+ self.keyboard.modes["base"].update_display()
+ self.pressed_something = True
+ return True
+
+
+class BaseMode(Mode):
+ label: label.Label
+
+ def __init__(self, *args):
+ super().__init__(*args)
+
+ self.scale_label = label.Label(FONT_10, text="", color=0xffffff, x=0, y=3)
+ self.notes_label = label.Label(FONT_10, text="", color=0xffffff, x=2, y=18)
+ self.group.append(self.scale_label)
+ self.group.append(self.notes_label)
+
+ def enter(self):
+ super().enter()
+ self.update_display()
+
+ def update_display(self):
+ self.scale_label.text = self.keyboard.scale.format()
+
+ def key_event(self, i: int, pressed: bool) -> bool:
+ if not pressed:
+ return False
+
+ if i == Key.MENU_I:
+ self.keyboard.mode = self.keyboard.modes["base_shift"]
+ return True
+
+ if i == Key.PREV_I:
+ self.keyboard.layout.offset -= self.keyboard.scale.size
+ self.keyboard.update_scale()
+
+ layout_offset = self.keyboard.settings.get("layout_offset")
+ layout_offset.value -= self.keyboard.scale.size
+ self.keyboard.settings.dispatch("layout_offset")
+ return True
+
+ if i == Key.NEXT_I:
+ layout_offset = self.keyboard.settings.get("layout_offset")
+ layout_offset.value += self.keyboard.scale.size
+ self.keyboard.settings.dispatch("layout_offset")
+ return True
diff --git a/hex33board/base.py b/hex33board/base.py
new file mode 100644
index 0000000..28e6dfb
--- /dev/null
+++ b/hex33board/base.py
@@ -0,0 +1,228 @@
+from __future__ import annotations
+
+import displayio
+import supervisor
+from micropython import const
+from adafruit_midi.note_on import NoteOn
+from adafruit_midi.note_off import NoteOff
+from adafruit_fancyled.adafruit_fancyled import CHSV, clamp_norm
+
+from .util import ticks_diff
+
+
+class Scale:
+ STEPS = {
+ "major": [2, 2, 1, 2, 2, 2, 1],
+ "minor nat": [2, 1, 2, 2, 1, 2, 2],
+ "minor harm": [2, 1, 2, 2, 1, 3, 1],
+ "minor mel": [2, 1, 2, 2, 2, 2, 1],
+ "minor hung": [2, 1, 3, 1, 1, 3, 1],
+ "whole": [2, 2, 2, 2, 2, 2],
+ "penta": [2, 2, 3, 2, 3],
+ }
+
+ # LABELS = {
+ # 'english': 'C C# D D# E F F# G G# A A# B'.split(' '),
+ # 'german': 'C C# D D# E F F# G G# A A# H'.split(' '),
+ # 'sol': 'do do# re re# mi fa fa# sol sol# la la# si'.split(' '),
+ # }
+ LABELS = 'C C# D D# E F F# G G# A A# B'.split(' ')
+
+ root: int
+ name: str
+ notes: list[int]
+ size: int
+
+ def __init__(self, root: int, steps: list[int], name: str):
+ self.root = root
+ self.name = name
+ self.notes = [sum(steps[:i]) for i in range(len(steps))]
+ self.size = sum(steps)
+
+ def get_hsv(self, pitch: int) -> tuple[float, float, float]:
+ relative_pitch = (pitch - self.root) % self.size
+
+ if relative_pitch not in self.notes:
+ # out of scale
+ return (.3, 0.8, 0.0)
+
+ if self.root <= pitch < self.root + self.size:
+ # in core scale
+ return (.1, 1.0, 0.2)
+
+ # in scale
+ return (.7, 0.9, 0.15)
+
+ def label(self, pitch):
+ return self.LABELS[pitch % self.size]
+
+ def format(self):
+ oct = self.root // self.size
+ off = self.root % self.size
+
+ return "scale: {} {} {}".format(
+ self.LABELS[off],
+ oct,
+ self.name,
+ )
+
+
+class Layout:
+ offset: int
+
+ def __init__(self, offset: int = 24):
+ self.offset = offset
+
+ def get_pitch(self, key: Key) -> int:
+ pass
+
+
+class WickiHaydenLayout(Layout):
+ def get_pitch(self, key: Key) -> int:
+ x, y = key.pos
+ return int(self.offset + 2*x + 6*y)
+
+
+class HarmonicLayout(Layout):
+ def get_pitch(self, key: Key) -> int:
+ x, y = key.pos
+ return int(self.offset + 4*x + 5*y)
+
+
+class GerhardLayout(Layout):
+ def get_pitch(self, key: Key) -> int:
+ x, y = key.pos
+ return int(self.offset + 3*x + 2.5*y)
+
+
+class Note:
+ keyboard: Keyboard
+ pitch: int
+
+ end_tick: None | int
+ expiry: float
+
+ def __init__(self, keyboard: Keyboard, pitch: int):
+ self.keyboard = keyboard
+ self.pitch = pitch
+ self.end_tick = None
+ self.expiry = 1.0
+
+ def on(self):
+ self.keyboard.notes[self.pitch] = self
+ self.keyboard.send_midi(NoteOn(self.pitch, self.keyboard.velocity))
+
+ if self.pitch in self.keyboard.notes_expiring:
+ del self.keyboard.notes_expiring[self.pitch]
+
+ def off(self):
+ if self.keyboard.notes.get(self.pitch) is self:
+ self.keyboard.notes_expiring[self.pitch] = self
+ del self.keyboard.notes[self.pitch]
+
+ self.keyboard.send_midi(NoteOff(self.pitch))
+ self.end_tick = supervisor.ticks_ms()
+
+ def update_expiry(self, ticks_ms_now):
+ delta = ticks_diff(ticks_ms_now, self.end_tick)
+
+ if delta > self.keyboard.jam_timeout:
+ del self.keyboard.notes_expiring[self.pitch]
+ self.expiry = 0
+ return
+
+ self.expiry = clamp_norm(1 - delta / self.keyboard.jam_timeout)
+
+
+class Key:
+ MENU_I = const(48+0)
+ PREV_I = const(48+4)
+ NEXT_I = const(48+5)
+
+ keyboard: Keyboard
+
+ i: int
+ '''matrix index for this key.'''
+
+ pos: tuple[float, int]
+ '''logical coordinates for this key.'''
+
+ note: None | Note
+
+ _pitch: int
+ _color: tuple[float, float, float]
+
+ def __init__(self, keyboard: Keyboard, i: int):
+ x = i % 12
+ y = i // 12
+
+ y = 4 - y
+ if y % 2 == 0:
+ x += 0.5
+
+ self.keyboard = keyboard
+ self.i = i
+ self.pos = (x, y)
+ self.note = None
+
+ def update_scale(self):
+ self._pitch = self.keyboard.layout.get_pitch(self)
+ self._hsv = self.keyboard.scale.get_hsv(self._pitch)
+
+ def update_color(self):
+ if self._pitch < 0 or self._pitch > 127:
+ return 0
+
+ hue, sat, key_val = self._hsv
+
+ note_for_pitch = self.keyboard.notes.get(self._pitch)
+ note_for_pitch = note_for_pitch or self.keyboard.notes_expiring.get(self._pitch)
+
+ if self.note:
+ value = 1.0
+ elif note_for_pitch:
+ value = note_for_pitch.expiry * 0.8
+ else:
+ value = 0.0
+
+ rest = 1.0 - key_val
+ value = key_val + value * rest
+ return CHSV(hue, sat, value).pack()
+
+ def on_press(self):
+ if self.note:
+ self.note.off()
+
+ if self._pitch < 0 or self._pitch > 127:
+ return
+
+ self.note = Note(self.keyboard, self._pitch)
+ self.note.on()
+
+ def on_release(self):
+ if not self.note:
+ return
+
+ self.note.off()
+ self.note = None
+
+
+class Mode:
+ keyboard: Keyboard
+ group: displayio.Group = displayio.Group()
+ color: tuple[float, float, float] = (0.5, 1.0, 1.0)
+
+ def __init__(self, keyboard: Keyboard):
+ self.keyboard = keyboard
+
+ def enter(self):
+ self.keyboard.display.show(self.group)
+
+ def exit(self):
+ pass
+
+ def key_event(self, i: int, pressed: bool) -> bool:
+ return False
+
+ def update_pixels(self, pixels: NeoPixel):
+ pixels[0] = CHSV(*self.color).pack()
diff --git a/hex33board/layout.py b/hex33board/layout.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/hex33board/layout.py
@@ -0,0 +1 @@
+
diff --git a/hex33board/matrix.py b/hex33board/matrix.py
new file mode 100644
index 0000000..0fb6796
--- /dev/null
+++ b/hex33board/matrix.py
@@ -0,0 +1,210 @@
+from __future__ import annotations
+
+from digitalio import DigitalInOut, Pull
+
+
+def range_rev(start, stop):
+ return range(stop - 1, start - 1, -1)
+
+
+def mapping_up_down(cols, rows):
+ '''
+ Maps keys according to the following diagram:
+
+ 1 2 3
+ +------
+ 1| A A A
+ 2| A A A
+ |
+ 1| B B B
+ 2| B B B
+ '''
+
+ coord_mapping = list(range(2 * cols * rows))
+ return coord_mapping
+
+
+def mapping_up_down_mirrored(cols, rows):
+ '''
+ Maps keys according to the following diagram:
+
+ 1 2 3
+ +------
+ 1| A A A
+ 2| A A A
+ |
+ 2| B B B
+ 1| B B B
+ '''
+
+ coord_mapping = []
+ coord_mapping.extend(range(cols * rows))
+ coord_mapping.extend(range_rev(cols * rows, 2 * cols * rows))
+ return coord_mapping
+
+
+def mapping_left_right(cols, rows):
+ '''
+ Maps keys according to the following diagram:
+
+ 1 2 3 1 2 3
+ +-------------
+ 1| A A A B B B
+ 2| A A A B B B
+ '''
+
+ size = cols * rows
+ coord_mapping = []
+ for y in range(rows):
+ yy = y * cols
+ coord_mapping.extend(range(yy, yy + cols))
+ coord_mapping.extend(range(yy + size, yy + cols + size))
+ return coord_mapping
+
+
+def mapping_left_right_mirrored(cols, rows):
+ '''
+ Maps keys according to the following diagram:
+
+ 1 2 3 3 2 1
+ +-------------
+ 1| A A A B B B
+ 2| A A A B B B
+ '''
+
+ size = cols * rows
+ coord_mapping = []
+ for y in range(rows):
+ yy = y * 2 * cols
+ coord_mapping.extend(range(yy, yy + cols))
+ coord_mapping.extend(range_rev(yy + size, yy + cols + size))
+ return coord_mapping
+
+
+def mapping_interleave_cols(cols, rows):
+ '''
+ Maps keys according to the following diagram:
+
+ 1 1 2 2 3 3
+ +-------------
+ 1| A B A B A B
+ 2| A B A B A B
+ '''
+
+ coord_mapping = []
+ mat = cols * rows
+ for i in range(mat):
+ coord_mapping.append(i)
+ coord_mapping.append(i + mat)
+ return coord_mapping
+
+
+# these two are actually equivalent
+mapping_interleave_rows = mapping_left_right
+
+
+class Matrix:
+ '''
+ A Scanner for Keyboard Matrices with Diodes in both directions.
+
+ In a bidirectional matrix, each (col, row) crossing can be used twice -
+ once with a ROW2COL diode ("A"), and once with a COL2ROW diode ("B").
+
+ The raw key numbers returned by this scanner are based on this layout ("up_down"):
+
+ C1 C2 C3
+ +-----------
+ R1| A0 A1 A2
+ R2| A3 A4 A5
+ +-----------
+ R1| B6 B7 B8
+ R1| B9 B10 B11
+
+ If the physical layout of the matrix is different, you can pass a function
+ for `mapping`. The function is passed `len_cols` and `len_rows` and should
+ return a `coord_mapping` list.
+ Various common mappings are provided in this module, see:
+ - `kmk.scanners.bidirectional.mapping_left_right`
+ - `kmk.scanners.bidirectional.mapping_left_right_mirrored`
+ - `kmk.scanners.bidirectional.mapping_up_down`
+ - `kmk.scanners.bidirectional.mapping_up_down_mirrored`
+ - `kmk.scanners.bidirectional.mapping_interleave_rows`
+ - `kmk.scanners.bidirectional.mapping_interleave_cols`
+
+ :param cols: A sequence of pins that are the columns for matrix A.
+ :param rows: A sequence of pins that are the rows for matrix A.
+ :param mapping: A coord_mapping generator function, see above.
+ '''
+
+ def __init__(self, cols, rows, mapping=mapping_left_right):
+ self.len_cols = len(cols)
+ self.len_rows = len(rows)
+ self.half_size = self.len_cols * self.len_rows
+ self.keys = self.half_size * 2
+
+ self.coord_mapping = mapping(self.len_cols, self.len_rows)
+
+ # A pin cannot be both a row and column, detect this by combining the
+ # two tuples into a set and validating that the length did not drop
+ #
+ # repr() hackery is because CircuitPython Pin objects are not hashable
+ unique_pins = {repr(c) for c in cols} | {repr(r) for r in rows}
+ assert (
+ len(unique_pins) == self.len_cols + self.len_rows
+ ), 'Cannot use a pin as both a column and row'
+ del unique_pins
+
+ # __class__.__name__ is used instead of isinstance as the MCP230xx lib
+ # does not use the digitalio.DigitalInOut, but rather a self defined one:
+ # https://github.com/adafruit/Adafruit_CircuitPython_MCP230xx/blob/3f04abbd65ba5fa938fcb04b99e92ae48a8c9406/adafruit_mcp230xx/digital_inout.py#L33
+
+ self.cols = [
+ x if x.__class__.__name__ == 'DigitalInOut' else DigitalInOut(x)
+ for x in cols
+ ]
+ self.rows = [
+ x if x.__class__.__name__ == 'DigitalInOut' else DigitalInOut(x)
+ for x in rows
+ ]
+
+ self.state = bytearray(self.keys)
+
+ def scan_for_changes(self):
+ for (inputs, outputs, flip) in [
+ (self.rows, self.cols, False),
+ (self.cols, self.rows, True),
+ ]:
+ for pin in outputs:
+ pin.switch_to_input()
+
+ for pin in inputs:
+ pin.switch_to_input(pull=Pull.DOWN)
+
+ for oidx, opin in enumerate(outputs):
+ opin.switch_to_output(value=True)
+
+ for iidx, ipin in enumerate(inputs):
+ if flip:
+ ba_idx = oidx * len(inputs) + iidx + self.half_size
+ else:
+ ba_idx = iidx * len(outputs) + oidx
+
+ # cast to int to avoid
+ #
+ # >>> xyz = bytearray(3)
+ # >>> xyz[2] = True
+ # Traceback (most recent call last):
+ # File "<stdin>", line 1, in <module>
+ # OverflowError: value would overflow a 1 byte buffer
+ #
+ # I haven't dived too far into what causes this, but it's
+ # almost certainly because bool types in Python aren't just
+ # aliases to int values, but are proper pseudo-types
+ new_val = int(ipin.value)
+ old_val = self.state[ba_idx]
+
+ if old_val != new_val:
+ self.state[ba_idx] = new_val
+ yield self.coord_mapping.index(ba_idx), new_val
+
+ opin.switch_to_input()
diff --git a/hex33board/menu.py b/hex33board/menu.py
new file mode 100644
index 0000000..1254543
--- /dev/null
+++ b/hex33board/menu.py
@@ -0,0 +1,244 @@
+from __future__ import annotations
+
+from adafruit_display_text import label
+from adafruit_fancyled.adafruit_fancyled import CHSV
+import displayio
+import json
+
+from .util import FONT_10, led_map
+from .base import Mode, Key
+
+BLUE = CHSV(0.48, 1.0, 1.0).pack()
+BLUE_DIM = CHSV(0.48, 1.0, 0.15).pack()
+
+CHOICE_NEUTRAL = CHSV(0.0, 0.0, 0.0).pack()
+RED = CHSV(0.95, 1.0, 1.0).pack()
+RED_DIM = CHSV(0.95, 1.0, 0.15).pack()
+GREEN = CHSV(0.27, 1.0, 1.0).pack()
+GREEN_DIM = CHSV(0.27, 1.0, 0.15).pack()
+
+
+def _thresh(val, t):
+ val >= t
+
+
+def _color(val, active: bool) -> tuple[float, float, float]:
+ return BLUE if active else BLUE_DIM
+
+
+def _color_offor(val, active: bool) -> tuple[float, float, float]:
+ if val:
+ return BLUE if active else BLUE_DIM
+ else:
+ return RED if active else RED_DIM
+
+
+class Setting:
+ name: str
+ _value: any
+
+ def __init__(self, name: str, default: any):
+ self.name = name
+ self._value = default
+
+ @property
+ def value(self):
+ return self._value
+
+ @value.setter
+ def value(self, val):
+ self._value = val
+
+
+class SliderSetting(Setting):
+ name: str
+ max: int
+ _value: int
+ fmt: str | None
+
+ def __init__(self, name: str, max: int, default: int = 0, fmt = None, thresh = _thresh, color = _color):
+ super().__init__(name, default)
+
+ self.max = max
+ self.fmt = fmt
+ self.width = min(23, max)
+
+ self.thresh = thresh
+ self.color = color
+
+ def press_prev(self):
+ self._value = (self._value - 1) % (self.max + 1)
+
+ def press_next(self):
+ self._value = (self._value + 1) % (self.max + 1)
+
+ def press_key(self, i):
+ if i > self.width:
+ return
+
+ self._value = int(self.max * i / self.width)
+
+ def get_colors(self) -> list[int]:
+ colors = []
+ for i in range(self.width + 1):
+ thresh = int(self.max * i / self.width)
+ active = self.thresh(self.value, thresh)
+ colors.append(self.color(thresh, active))
+
+ return colors
+
+ def format(self) -> str:
+ if self.fmt:
+ return self.fmt.format(self.value)
+ return str(self.value)
+
+
+class ChoiceSetting(SliderSetting):
+ def __init__(self, name: str, values: list, default=0, **kwargs):
+ default = values.index(default)
+ super().__init__(name, len(values) - 1, default=default, thresh=lambda v, t: v == t, **kwargs)
+ self.values = values
+
+ @property
+ def value(self):
+ return self.values[self._value]
+
+ def get_colors(self) -> list[int]:
+ colors = []
+ value = self.value
+ for i in range(self.width + 1):
+ thresh = self.values[int(self.max * i / self.width)]
+ active = self.thresh(value, thresh)
+ colors.append(self.color(thresh, active))
+ return colors
+
+
+class Settings:
+ settings: dict[str, Setting]
+
+ def __init__(self, settings: dict[str, Setting]):
+ self.settings = settings
+ self.change_handlers = {}
+
+ def on(self, id: str, fn):
+ if id in self.change_handlers:
+ raise ValueError("already have a handler for {}".format(id))
+ self.change_handlers[id] = fn
+
+ def dispatch(self, id: str):
+ self.change_handlers[id](self.settings[id].value)
+
+ def get(self, id: str) -> Setting:
+ return self.settings[id]
+
+ def load(self):
+ try:
+ with open('hex.config.json', 'r') as f:
+ data = json.load(f)
+ for id in self.settings:
+ if id in data:
+ self.settings[id].value = data[id]
+
+ self.dispatch(id)
+ except OSError:
+ pass
+
+ def store(self):
+ data = {}
+ for id in self.settings:
+ data[id] = self.settings[id].value
+
+ try:
+ with open('hex.config.json', 'w') as f:
+ json.dump(data, f)
+ except OSError:
+ pass
+ pass
+
+
+class MenuMode(Mode):
+ color = (1.0, 1.0, 1.0)
+
+ settings: Settings
+ ui_settings: list[Setting]
+
+ SETTING_ACTIVE = CHSV(0.12, 1.0, 1.0).pack()
+ SETTING_ACTIVE_DIM = CHSV(0.12, 1.0, 0.15).pack()
+
+ def __init__(self, *args, settings: Settings, ui_settings_ids: list[str]):
+ super().__init__(*args)
+
+ self.settings = settings
+ self.ui_settings_ids = ui_settings_ids
+ self.ui_settings = [settings.get(id) for id in ui_settings_ids]
+ self.settings_i = 0
+
+ self.group = displayio.Group()
+ self.label_settings = label.Label(FONT_10, text="volume", color=0xffffff, anchor_point=(0, -1), anchored_position=(0, 6))
+ self.label_value = label.Label(FONT_10, text="100%", color=0xffffff, anchor_point=(1, -1), anchored_position=(128, 22))
+ self.group.append(self.label_settings)
+ self.group.append(self.label_value)
+
+ def exit(self):
+ super().exit()
+ self.settings.store()
+
+ def enter(self):
+ super().enter()
+ self.update_display(True)
+
+ @property
+ def setting_id(self) -> str:
+ return self.ui_settings_ids[self.settings_i]
+
+ @property
+ def setting(self) -> Setting:
+ return self.ui_settings[self.settings_i]
+
+ def update_pixels(self, pixels: NeoPixel):
+ pixels.fill(0)
+
+ super().update_pixels(pixels)
+
+ for i, setting in enumerate(self.ui_settings):
+ pixels[led_map[i]] = self.SETTING_ACTIVE_DIM
+ pixels[led_map[self.settings_i]] = self.SETTING_ACTIVE
+
+ for i, color in enumerate(self.setting.get_colors()):
+ pixels[led_map[24+i]] = color
+
+ def update_display(self, update_name=False):
+ if update_name:
+ self.label_settings.text = self.setting.name
+ self.label_value.text = str(self.setting.format())
+
+ def key_event(self, i: int, pressed: bool) -> bool:
+ if pressed and i == Key.MENU_I:
+ self.keyboard.mode = self.keyboard.modes["base"]
+ return True
+
+ if not pressed:
+ return False
+
+ if i < len(self.ui_settings):
+ self.settings_i = i
+ self.update_display(True)
+ return True
+
+ if 23 < i < 48:
+ self.setting.press_key(i - 24)
+ self.update_display()
+ self.settings.dispatch(self.setting_id)
+ return True
+
+ if i == Key.PREV_I:
+ self.setting.press_prev()
+ self.update_display()
+ self.settings.dispatch(self.setting_id)
+ return True
+
+ if i == Key.NEXT_I:
+ self.setting.press_next()
+ self.update_display()
+ self.settings.dispatch(self.setting_id)
+ return True
diff --git a/hex33board/util.py b/hex33board/util.py
new file mode 100644
index 0000000..52bf10d
--- /dev/null
+++ b/hex33board/util.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from adafruit_bitmap_font import bitmap_font
+from micropython import const
+
+FONT_10 = bitmap_font.load_font("fonts/bitbuntu.pcf")
+
+_TICKS_PERIOD = const(1 << 29)
+_TICKS_MAX = const(_TICKS_PERIOD - 1)
+_TICKS_HALFPERIOD = const(_TICKS_PERIOD // 2)
+
+
+def ticks_diff(ticks1, ticks2):
+ ''' Compute the signed difference between two ticks values,
+ assuming that they are within 2**28 ticks
+ '''
+
+ diff = (ticks1 - ticks2) & _TICKS_MAX
+ diff = ((diff + _TICKS_HALFPERIOD) & _TICKS_MAX) - _TICKS_HALFPERIOD
+ return diff
+
+
+led_map = []
+led_map.extend(range(15, 3, -1))
+led_map.extend(range(16, 28))
+led_map.extend(range(39, 27, -1))
+led_map.extend(range(40, 52))
diff --git a/test.wav b/test.wav
new file mode 100644
index 0000000..7541aa5
--- /dev/null
+++ b/test.wav
Binary files differ