Launcherpad

I converted a midi device into a macro machine. I did this mostly to manage my video calls.

As a professional manager in a indefinite-work-from-home situation, making and managing video calls has become a large part of my job.

The rest of this post describes what it does, why I picked this device, some of the choices I made, and the code that makes it possible.

What are the features tonight?

Features can change easily with a marker and a shell script so they'll likely be different next week

  • Switch between headphones and speakers
  • Switch between the nice microphone and the one built into the computer
  • Same for cameras
  • Mute and unmute myself, show a bright red/green light to tell me which was which
  • Turn up the volume
  • Turn off the volume
  • Hangup zoom! This is super awkward and having a one-button kill switch makes it much more like a phone call
  • Launch Zoom, copy my "personal meeting room" link to the clipboard.
  • Turn on music (open up my music app)

Why this device?

The obvious answer is "I had it" but I had it because:

  • Midi is an open and well trodden protocol for bi-directional low-latency communication
  • This device feels nice with its silicone buttons and has input and multi-bit output at the same location as the input. Each button has a red and a green light under, and each light has 3 levels, you can get a nice amber out of it.
  • If I want another one, they're abundant and cheap on ebay, and the new ones are even nicer (but more expensive that I'd like)

Some choices I made:

  • State is output as light output. Rephrased: feedback is independent of input. This is really important in interface design, because if your device or your program reflects what the user wishes happened then it will diverge from what is actually happening. In practice the obvious benefit of this is that if another program changes something like the volume or which microphone I'm using, the launcherpad reflects reality, not the last thing that was pressed on the launcherpad.
  • Permanent Markers as input definition. I'm not wild about this but it's really nice for prototyping. I'm hoping that I canp make vinyl stickers or laser engraving or something more permanent.

Some things that might change if this were to become A Thing

  • Almost everything is a redirect to a shell command.
  • Configuration is only semi-independent from the rest of code
  • Color output relies on the shell command outputting a (red),(green) value as its last line, this is easy to pipe things together in unix but it might be limiting in other avenues.

How it works

It's a few hundred lines of python and a library dedicated to converting python to midi for these devices.

#!/usr/bin/env python
import sys
import launchpad_py as launchpad
import random
import subprocess
from pygame import time
import pyautogui
from pprint import pprint
import datetime
pyautogui.PAUSE = 0
pyautogui.FAILSAFE = False

KEYS = {
    "key0": {
        "title": "Music Please",
        "kind": "function_call",
        "func": "music_please",
        "kargs": {},
    },
    "key82": {
        "title": "Output: Dock Headphones",
        "kind": "shell",
        "cmd": '''pacmd set-default-sink alsa_output.usb-Lenovo_ThinkPad_USB-C_Dock_Audio_000000000000-00.analog-stereo''',
        "colors": {
            "on": {
                "cmd": '''pacmd dump |  grep "set-default-sink alsa_output.usb-Lenovo_ThinkPad_USB-C_Dock_Audio_000000000000-00.analog-stereo" && echo "0,2" ''' 
            }
        }
    },
    "key83": {
        "title": "Output: Speakers",
        "kind": "shell",
        "cmd": '''pacmd set-default-sink alsa_output.usb-Dell_Dell_AC511_USB_SoundBar-00.analog-stereo''',
        "colors": {
            "on": {
                "cmd": '''pacmd dump |  grep "set-default-sink alsa_output.usb-Dell_Dell_AC511_USB_SoundBar-00.analog-stereo" && echo "0,2" ''' 
            }
        }
    },
    "key84": {
        "title": "Output: Yeti Headphones",
        "kind": "shell",
        "cmd": '''pacmd set-default-sink alsa_output.usb-Blue_Microphones_Yeti_Nano_2028SG005AS8_888-000154040606-00.iec958-stereo''',
        "colors": {
            "on": {
                "cmd": '''pacmd dump |  grep "set-default-sink alsa_output.usb-Blue_Microphones_Yeti_Nano_2028SG005AS8_888-000154040606-00.iec958-stereo" && echo "0,2" ''' 
            }
        }
    },
    "key85": {
        "title": "Input: Yeti Mic",
        "kind": "shell",
        "cmd": '''pacmd set-default-source alsa_input.usb-Blue_Microphones_Yeti_Nano_2028SG005AS8_888-000154040606-00.analog-stereo''',
        "colors": {
            "on": {
                "cmd": '''pacmd dump |  grep "set-default-source alsa_input.usb-Blue_Microphones_Yeti_Nano_2028SG005AS8_888-000154040606-00.analog-stereo" && echo "0,2" ''' 
            }
        }
    },
    "key86": {
        "title": "Input: Cam Mic",
        "kind": "shell",
        "cmd": '''pacmd set-default-source alsa_input.usb-046d_HD_Pro_Webcam_C920_03D377DF-02.analog-stereo''',
        "colors": {
            "on": {
                "cmd": '''pacmd dump |  grep "set-default-source alsa_input.usb-046d_HD_Pro_Webcam_C920_03D377DF-02.analog-stereo" && echo "0,2" ''' 
            }
        }
    },
    "key99": {
        "title": "Launch Zoom",
        "kind": "shell",
        "cmd": '''xdg-open zoommtg://my-meeting-dontcopythis'''
    },
    "key100": {
        "title": "Copy Zoom Link",
        "kind": "shell",
        "cmd": '''echo 'https://my-meeting-dontcopythis' | xclip -selection clipboard'''
    },
    "key101": {
        "title": "Kill Zoom",
        "kind": "shell",
        "cmd": '''killall zoom'''
    },
    "key102": {
        "title": "Unmute Me",
        "kind": "shell",
        "cmd": '''pactl set-source-volume @DEFAULT_SOURCE@ 80%''',
        "colors": {
            "on": {
                "cmd": '''SOURCE=$(pacmd info | grep "Default source name: " | sed 's\Default source name: \\g'); pacmd dump | grep "set-source-volume $SOURCE" | grep 0x000 || echo 2,3''' 
            }
        }
    },
    "key103": {
        "title": "Volume Up",
        "kind": "shell",
        "cmd": '''pactl set-sink-volume @DEFAULT_SINK@ +10%'''
    },
    "key104": {
        "title": "Volume Zero",
        "kind": "shell",
        "cmd": '''pactl set-sink-volume @DEFAULT_SINK@ 0%''',
        "colors": {
            "on": {
                "cmd": '''SOURCE=$(pacmd info | grep "Default sink name: " | sed 's\Default sink name: \\g'); pacmd dump | grep "set-sink-volume $SOURCE" | grep 0x000 && echo 1,0''' 
            }
        }
    },
    "key118": {
        "title": "Mute Me",
        "kind": "shell",
        "cmd": '''pactl set-source-volume @DEFAULT_SOURCE@ 0%''',
        "colors": {
            "on": {
                "cmd": '''SOURCE=$(pacmd info | grep "Default source name: " | sed 's\Default source name: \\g'); pacmd dump | grep "set-source-volume $SOURCE" | grep 0x000 && echo 3,0''' 
            }
        }
        #
    },
    "key119": {
        "title": "Volume Down",
        "kind": "shell",
        "cmd": '''pactl set-sink-volume @DEFAULT_SINK@ -10%'''
    }
}

def music_please():
    subprocess.Popen("xdg-open https://music.youtube.com/", shell=True)
    time.wait(300)
    pyautogui.typewrite(['space'])


def main():

    mode = None


    lp = launchpad.Launchpad()
    if lp.Open():
        print("Launchpad Mk1/S/Mini")
        mode = "Mk1"

    if mode is None:
        print("Did not find any Launchpads, meh...")
        return


    # scroll a string from right to left
    #lp.LedCtrlString( "Starting", 0, 3, -1 )
    lp.ButtonFlush()
    lp.Reset()


    reset_count = 15
    iter_count = 0
    
    while 1:
        time.wait( 5 )
        but = lp.ButtonStateRaw()
        
        if but != []:
            lp.LedCtrlRaw(8, random.randint(0,3),random.randint(0,3))
            key, pressed = but
            print('\t\t\t', "[event]", but )

            if pressed:
                lp.LedCtrlRaw( key, 1, 1)

                if f'key{key}' in KEYS:
                    cmd = KEYS[f'key{key}']
                    print(f'Received: {cmd["title"]}')
                    
                    if cmd["kind"] == "shell":
                        print(f'\tshell: \t\t{cmd["cmd"]}')
                        out = subprocess.Popen(cmd["cmd"], shell=True)
                        print('\t', out)
                    elif cmd["kind"] == "function_call":

                        eval(f'''{cmd["func"]}()''')
                    else:
                        print(f'\tUnknown kind: \t\t\{cmd["kind"]}')

            else: 
                lp.LedCtrlRaw(key, 0, 0)
        
        iter_count += 1
        if iter_count == reset_count:
            iter_count = 0

            # Do Keys
            for k, v in KEYS.items():
                if 'colors' in v.keys():

                    key = int(k.replace('key', ''))
                    try:
                        process = subprocess.Popen(v['colors']['on']['cmd'], shell=True, stdout=subprocess.PIPE, 
                           stderr=subprocess.PIPE)
                        # Readlines, if last line has x,y then set them to r,g values, else set zeores
                        out, err = process.communicate()
                        lines = out.decode('utf-8').split('\n')
                        lines = list(filter(None, lines))
                        if lines:
                            try:
                                red, green = [int(x) for x in lines[-1].split(',', 1)]
                                #print(red, green)
                                lp.LedCtrlRaw(key, red, green)
                                continue
                            except ValueError:
                                pass
                    except subprocess.CalledProcessError:
                        pass

                    lp.LedCtrlRaw(key, 0, 0)

    # now quit...
    print("Quitting might raise a 'Bad Pointer' error (~almost~ nothing to worry about...:).\n\n")

    lp.Reset() # turn all LEDs off
    lp.Close() # close the Launchpad (will quit with an error due to a PyGame bug)

    
if __name__ == '__main__':
    main()

Comments and Messages

I won't ever give out your email address. I don't publish comments but if you'd like to write to me then you could use this form.

Issac Kelly