|
| 1 | +# SPDX-FileCopyrightText: 2026 Noe Ruiz for Adafruit Industries |
| 2 | +# SPDX-License-Identifier: MIT |
| 3 | + |
| 4 | +import time |
| 5 | +import board |
| 6 | +import usb_midi |
| 7 | +import adafruit_midi |
| 8 | +from digitalio import DigitalInOut, Direction, Pull |
| 9 | +from adafruit_midi.control_change import ControlChange |
| 10 | +from adafruit_midi.note_off import NoteOff |
| 11 | +from adafruit_midi.note_on import NoteOn |
| 12 | +from adafruit_midi.pitch_bend import PitchBend |
| 13 | +import neopixel |
| 14 | + |
| 15 | +# enable external power pin |
| 16 | +# provides power to the external components |
| 17 | +external_power = DigitalInOut(board.EXTERNAL_POWER) |
| 18 | +external_power.direction = Direction.OUTPUT |
| 19 | +external_power.value = True |
| 20 | + |
| 21 | +# NeoPixel LED Setup |
| 22 | +NUMPIXELS = 72 |
| 23 | +BRIGHTNESS = 1 |
| 24 | +PIN = board.EXTERNAL_NEOPIXELS |
| 25 | +ORDER = neopixel.BGR |
| 26 | +pixels = neopixel.NeoPixel(PIN, NUMPIXELS, brightness=BRIGHTNESS, auto_write=False, pixel_order=ORDER) |
| 27 | + |
| 28 | +# Matrix layout |
| 29 | +MATRIX_ROWS = 6 |
| 30 | +MATRIX_COLS = 12 # One full chromatic octave per row |
| 31 | + |
| 32 | +# MIDI setup - listen on channels 1 and 2 (0-indexed: 0 and 1) |
| 33 | +print(usb_midi.ports) |
| 34 | +midi = adafruit_midi.MIDI( |
| 35 | + midi_in=usb_midi.ports[0], in_channel=(0, 1), midi_out=usb_midi.ports[1], out_channel=0 |
| 36 | +) |
| 37 | + |
| 38 | +# MIDI note number that maps to Pixel 0 (top-left of matrix). |
| 39 | +# 24 = C1, 36 = C2, 48 = C3 |
| 40 | +NOTE_OFFSET = 24 |
| 41 | + |
| 42 | +# Chromatic note names used for print statements |
| 43 | +NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] |
| 44 | + |
| 45 | +# Base color palette - 24 colors spanning two octaves of the chromatic scale. |
| 46 | +# The first 12 colors cover one octave, the next 12 cover the second octave |
| 47 | +# with a distinct shifted palette. Colors then tile across all 6 rows. |
| 48 | +BASE_COLORS = [ |
| 49 | + # Octave A - Warm to Cool spectrum |
| 50 | + (255, 0, 0), # C - Red |
| 51 | + (255, 45, 0), # C# - Red-Orange |
| 52 | + (255, 90, 0), # D - Orange |
| 53 | + (255, 145, 0), # D# - Amber |
| 54 | + (255, 200, 0), # E - Yellow-Orange |
| 55 | + (255, 255, 0), # F - Yellow |
| 56 | + (128, 255, 0), # F# - Yellow-Green |
| 57 | + (0, 255, 0), # G - Green |
| 58 | + (0, 255, 128), # G# - Spring Green |
| 59 | + (0, 255, 255), # A - Cyan |
| 60 | + (0, 128, 255), # A# - Sky Blue |
| 61 | + (0, 0, 255), # B - Blue |
| 62 | + |
| 63 | + # Octave B - Rich and saturated shifted palette |
| 64 | + (64, 0, 255), # C - Indigo |
| 65 | + (128, 0, 255), # C# - Violet |
| 66 | + (200, 0, 255), # D - Purple |
| 67 | + (255, 0, 200), # D# - Magenta |
| 68 | + (255, 0, 128), # E - Hot Pink |
| 69 | + (255, 0, 64), # F - Deep Rose |
| 70 | + (255, 64, 64), # F# - Salmon |
| 71 | + (255, 128, 128), # G - Light Coral |
| 72 | + (255, 200, 128), # G# - Peach |
| 73 | + (255, 255, 128), # A - Pale Yellow |
| 74 | + (128, 255, 128), # A# - Mint |
| 75 | + (128, 255, 255), # B - Ice Blue |
| 76 | +] |
| 77 | + |
| 78 | +# Expand BASE_COLORS to a full 72-entry list by repeating the 24-color pattern. |
| 79 | +PIXEL_COLORS = [BASE_COLORS[i % len(BASE_COLORS)] for i in range(NUMPIXELS)] |
| 80 | + |
| 81 | +FADE_DURATION = 0.1 # Total fade duration in seconds |
| 82 | +FADE_STEPS = 5 # Number of steps in the fade |
| 83 | + |
| 84 | +# Non-blocking fade state per pixel: |
| 85 | +# pixel_index -> {"color": (r,g,b), "step": int, "last_time": float} |
| 86 | +fading_pixels = {} |
| 87 | + |
| 88 | +def note_to_matrix(note): |
| 89 | + """ |
| 90 | + Map a MIDI note to a (row, col) position in the matrix. |
| 91 | + Each row is one octave (12 notes). C always starts at column 0. |
| 92 | + NOTE_OFFSET determines which note maps to (row=0, col=0). |
| 93 | + """ |
| 94 | + offset_note = note - NOTE_OFFSET |
| 95 | + row = (offset_note // MATRIX_COLS) % MATRIX_ROWS |
| 96 | + col = offset_note % MATRIX_COLS |
| 97 | + return row, col |
| 98 | + |
| 99 | +def matrix_to_pixel(row, col): |
| 100 | + """ |
| 101 | + Convert a (row, col) matrix position to a physical pixel index, |
| 102 | + accounting for zigzag wiring. |
| 103 | + Even rows (0, 2, 4) run left to right. |
| 104 | + Odd rows (1, 3, 5) run right to left. |
| 105 | + """ |
| 106 | + if row % 2 == 0: |
| 107 | + return row * MATRIX_COLS + col # Left to right |
| 108 | + else: |
| 109 | + return row * MATRIX_COLS + (MATRIX_COLS - 1 - col) # Right to left |
| 110 | + |
| 111 | +def note_to_pixel(note): |
| 112 | + """Map a MIDI note directly to a physical pixel index via the matrix.""" |
| 113 | + row, col = note_to_matrix(note) |
| 114 | + return matrix_to_pixel(row, col) |
| 115 | + |
| 116 | +def note_to_name(note): |
| 117 | + """Return a human-readable note name and octave, e.g. 'C2' for note 36.""" |
| 118 | + name = NOTE_NAMES[note % 12] |
| 119 | + octave = (note // 12) - 1 |
| 120 | + return f"{name}{octave}" |
| 121 | + |
| 122 | +def color_for_note(note): |
| 123 | + """ |
| 124 | + Look up the color for a note based on its position across two octaves. |
| 125 | + The 24-color palette tiles every two octaves across the matrix rows. |
| 126 | + """ |
| 127 | + offset_note = note - NOTE_OFFSET |
| 128 | + return BASE_COLORS[offset_note % len(BASE_COLORS)] |
| 129 | + |
| 130 | +def color_wipe(color, delay=0.01): |
| 131 | + """Wipe a color across the strip one LED at a time.""" |
| 132 | + for i in range(NUMPIXELS): |
| 133 | + pixels[i] = color |
| 134 | + pixels.show() |
| 135 | + time.sleep(delay) |
| 136 | + |
| 137 | +def boot_sequence(): |
| 138 | + """Animated boot sequence using color wipes.""" |
| 139 | + color_wipe((255, 0, 0), 0.01) # Red wipe |
| 140 | + color_wipe((0, 255, 0), 0.01) # Green wipe |
| 141 | + color_wipe((0, 0, 255), 0.01) # Blue wipe |
| 142 | + color_wipe((0, 0, 0), 0.01) # Wipe off |
| 143 | + |
| 144 | +def update_fades(): |
| 145 | + """Call this every loop iteration to advance any active fades.""" |
| 146 | + now = time.monotonic() |
| 147 | + completed = [] |
| 148 | + for pixel_index, state in fading_pixels.items(): |
| 149 | + step_delay = FADE_DURATION / FADE_STEPS |
| 150 | + if now - state["last_time"] >= step_delay: |
| 151 | + state["step"] += 1 |
| 152 | + state["last_time"] = now |
| 153 | + if state["step"] >= FADE_STEPS: |
| 154 | + pixels[pixel_index] = (0, 0, 0) |
| 155 | + pixels.show() |
| 156 | + completed.append(pixel_index) |
| 157 | + else: |
| 158 | + r, g, b = state["color"] |
| 159 | + factor = (FADE_STEPS - state["step"]) / FADE_STEPS |
| 160 | + pixels[pixel_index] = (int(r * factor), int(g * factor), int(b * factor)) |
| 161 | + pixels.show() |
| 162 | + for pixel_index in completed: |
| 163 | + del fading_pixels[pixel_index] |
| 164 | + |
| 165 | +# Run boot sequence on startup |
| 166 | +boot_sequence() |
| 167 | + |
| 168 | +while True: |
| 169 | + msg = midi.receive() |
| 170 | + |
| 171 | + if isinstance(msg, NoteOn) and msg.velocity > 0: |
| 172 | + pixel_index = note_to_pixel(msg.note) |
| 173 | + color = color_for_note(msg.note) |
| 174 | + note_name = note_to_name(msg.note) |
| 175 | + row, col = note_to_matrix(msg.note) |
| 176 | + # Immediately cancel any active fade on this pixel |
| 177 | + if pixel_index in fading_pixels: |
| 178 | + del fading_pixels[pixel_index] |
| 179 | + pixels[pixel_index] = color |
| 180 | + pixels.show() |
| 181 | + print(f"Note ON: {note_name} ({msg.note}) -> Row {row}, Col {col}, Pixel {pixel_index}, Color {color}, Channel {msg.channel + 1}") |
| 182 | + |
| 183 | + elif isinstance(msg, NoteOff) or (isinstance(msg, NoteOn) and msg.velocity == 0): |
| 184 | + pixel_index = note_to_pixel(msg.note) |
| 185 | + color = color_for_note(msg.note) |
| 186 | + note_name = note_to_name(msg.note) |
| 187 | + row, col = note_to_matrix(msg.note) |
| 188 | + fading_pixels[pixel_index] = { |
| 189 | + "color": color, |
| 190 | + "step": 0, |
| 191 | + "last_time": time.monotonic() |
| 192 | + } |
| 193 | + print(f"Note OFF: {note_name} ({msg.note}) -> Row {row}, Col {col}, Pixel {pixel_index} fading, Channel {msg.channel + 1}") |
| 194 | + |
| 195 | + # Advance all active fades each loop iteration |
| 196 | + update_fades() |
0 commit comments