├── README.md ├── clear.py ├── LICENSE └── led.py /README.md: -------------------------------------------------------------------------------- 1 | # LED strip controlled by piano 2 | 3 | This _Python_ script will listen to MIDI input from a digital piano and update LED lights on an addressable LED strip. It is meant to run on a _Raspberry Pi_. -------------------------------------------------------------------------------- /clear.py: -------------------------------------------------------------------------------- 1 | # clear out all pixels that are turned on 2 | 3 | import board 4 | import neopixel 5 | 6 | NUM_OF_PIXELS = 144 7 | 8 | pixels = neopixel.NeoPixel(board.D18, NUM_OF_PIXELS, brightness=0.2, auto_write=False) 9 | 10 | for x in range (0, NUM_OF_PIXELS): 11 | pixels[x] = (0, 0, 0) 12 | 13 | pixels.show() -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Boris Yakubchik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /led.py: -------------------------------------------------------------------------------- 1 | # to run script: `sudo python3 led.py` 2 | # 3 | # written by Boris Yakubchik 4 | # 5 | # Edit these: 6 | 7 | NUM_OF_LED = 144 8 | BRIGHTNESS = 0.2 # maximum 1 -- best when 1/n is an integer 9 | PIANO_STARTS_WITH = 'B2' 10 | 11 | # ========================================================= 12 | # DO NOT EDIT BELOW 13 | 14 | from time import sleep 15 | from time import time 16 | import math 17 | import threading 18 | 19 | import board 20 | import neopixel 21 | 22 | import mido 23 | 24 | import keyboard 25 | 26 | pixels = neopixel.NeoPixel(board.D18, NUM_OF_LED, brightness=BRIGHTNESS, auto_write=False) 27 | 28 | devices = mido.get_input_names() 29 | piano = [] 30 | 31 | for device in devices: 32 | if device.startswith(PIANO_STARTS_WITH): 33 | piano.append(device) 34 | 35 | if len(piano) == 0: 36 | print('\nIs the piano connected / turned on?\n') 37 | quit() 38 | else: 39 | print('Listening for MIDI from', piano[0]) 40 | 41 | 42 | # ======================================================================= 43 | # Variables 44 | 45 | # array for all BLOBS 46 | all_blobs = {} 47 | 48 | min_bright = math.ceil(1 / BRIGHTNESS) 49 | 50 | # array for pixel color computation - to be written to the LED strip later 51 | rgb = [1] * NUM_OF_LED # fill all of them with 1 52 | 53 | # to throttle keyboard events 54 | throttle_left = 0 55 | throttle_right = 0 56 | throttle_f12 = 0 57 | 58 | # each Blob is a key pressed that evolves over time via its `update` method 59 | class Blob: 60 | def __init__(self, x, r, v, s): 61 | self.x = x # x - location on x axis 62 | self.r = r # radius 63 | self.v = v # value == brightness 64 | self.s = s # status: `down`, `legato`, or `decay` 65 | 66 | def update(self): 67 | if self.s == 'down' or self.s == 'legato': 68 | self.r = min(self.r + 1, 5) 69 | self.v = math.floor(self.v * 0.98) 70 | else: 71 | self.r = self.r + 1 72 | self.v = math.floor(self.v * 0.75) # dim it 73 | 74 | 75 | def add_note_to_workspace(key, velocity): 76 | all_blobs[key] = Blob(map_key_to_x(key), 1, velocity, 'down') 77 | 78 | 79 | def thread_function(name): 80 | while True: 81 | 82 | global rgb 83 | 84 | # print(len(all_blobs)) # to check that blobs disappear after some time 85 | 86 | rgb = [min_bright] * NUM_OF_LED # reset array to min 87 | 88 | for blob_key in all_blobs: 89 | 90 | blob = all_blobs[blob_key] 91 | 92 | blob.update() 93 | 94 | for x in range(math.floor(blob.x - blob.r), math.ceil(blob.x + blob.r)): 95 | 96 | if (x >= 0 and x < NUM_OF_LED): 97 | scaler = (1 - abs(blob.x - x) / blob.r) ** 2 98 | rgb[x] = min(rgb[x] + scaler * blob.v, 255) # never let it go above 255 99 | # this can happen because we add many blobs together 100 | 101 | for idx, val in enumerate(rgb): 102 | # make sure these are integers 103 | pixels[idx] = (math.floor(val), math.floor(val), math.floor(val)) 104 | 105 | pixels.show() 106 | 107 | sleep(0.05) 108 | 109 | to_delete = [] 110 | 111 | # only keep blobs that are above brightness of 1 112 | for blob_key in all_blobs: 113 | if all_blobs[blob_key].v < 2: 114 | to_delete.append(blob_key) 115 | 116 | for key in to_delete: 117 | all_blobs.pop(key) 118 | 119 | 120 | thread = threading.Thread(target=thread_function, args=(1,)) 121 | thread.start() 122 | 123 | legato_pedal = 0 # 0 - not pressed, 1 - pressed 124 | 125 | 126 | def unlegato_all_keys(): 127 | for key in all_blobs: 128 | if all_blobs[key].s == 'legato': 129 | all_blobs[key].s = 'decay' 130 | 131 | 132 | def handle_pedal(pedal, value): 133 | global legato_pedal 134 | if pedal == 67: 135 | throttle_key('left') 136 | elif pedal == 66: 137 | throttle_key('right') 138 | elif pedal == 64: 139 | if legato_pedal == 0 and value != 0: 140 | legato_pedal = 1 141 | print('legato ON') 142 | if value == 0: 143 | legato_pedal = 0 144 | print('legato OFF') 145 | unlegato_all_keys() 146 | 147 | else: 148 | print('unknown!') 149 | print(msg) 150 | 151 | 152 | def throttle_key(key): 153 | global throttle_left 154 | global throttle_right 155 | global throttle_f12 156 | now = time() 157 | 158 | if key == 'left': 159 | 160 | if now < throttle_right + 1 and now > throttle_f12 + 0.5: 161 | throttle_f12 = now 162 | keyboard.press_and_release('f12') 163 | elif now > throttle_left + 0.5: 164 | throttle_left = now 165 | keyboard.press_and_release(key) 166 | 167 | elif key == 'right': 168 | 169 | if now < throttle_left + 1 and now > throttle_f12 + 0.5: 170 | throttle_f12 = now 171 | keyboard.press_and_release('f12') 172 | elif now > throttle_right + 0.5: 173 | throttle_right = now 174 | keyboard.press_and_release(key) 175 | 176 | # takes raw key (21 - 108) and returns x coordinate 177 | def map_key_to_x(key): 178 | # 65 = center between 21 and 108 179 | # 72 = NUM_OF_LEDS / 2 + fudge factor 180 | return (key - 65) * 2 + 72 181 | 182 | 183 | with mido.open_input(piano[0]) as inport: 184 | for msg in inport: 185 | # print(msg) 186 | if hasattr(msg, 'velocity'): 187 | # print(msg.velocity) 188 | if msg.velocity == 64: # represents key-up velocity 189 | if msg.note in all_blobs: # key may have decayed and been removed already 190 | if legato_pedal == 1: 191 | all_blobs[msg.note].s = 'legato' 192 | else: 193 | all_blobs[msg.note].s = 'decay' 194 | else: 195 | add_note_to_workspace(msg.note, msg.velocity) 196 | 197 | elif hasattr(msg, 'control'): 198 | # pedal numbers 64, 65, 66 199 | handle_pedal(msg.control, msg.value) 200 | 201 | else: 202 | pring('something new and unknown!') 203 | print(msg) 204 | --------------------------------------------------------------------------------