MIDI-to-MQTT Bridge (console)

This command-line Python script transmits MQTT messages based on events from a MIDI input device such as the Akai MPD218 Drum Pad Controller. This can be used as a platform for remote control of one or multiple embedded devices.

The MQTT and MIDI configuration must be customized by editing variables with the script before this can be used.

The script is provided all in one file and can be be directly downloaded from midi_to_mqtt.py. The following sections have both documentation and the full code.

Installation Requirements

The code requires a working installation of Python 3 with pySerial, paho-mqtt, and python-rtmidi. For suggestions on setting up your system please see Python 3 Installation.

User Guide

The procedure for use generally follows this sequence:

  1. Install one of the remote connection examples on your CircuitPython board. These can serve as starting points for your own sketches.

  2. Open the midi_to_mqtt.py program in a Python editor.

  3. Locate the top section named “Configuration” and follow the prompts to customize the settings.

  4. Customize the MIDI event processing to produce output compatible with your sketch.

  5. Run the script using Python 3, typically as follows: python3 midi_to_mqtt.py

  6. To stop the script, type Control-C.

If testing locally, it is convenient also to run the MQTT Monitor (PyQt5) in order to simulate the collaborating system.

Full Code

  1#!/usr/bin/env python3
  2
  3"""\
  4midi_to_mqtt.py : sample code in Python to translate MIDI events into MQTT messages
  5
  6No copyright, 2021, Garth Zeglin.  This file is explicitly placed in the public domain.
  7
  8"""
  9#----------------------------------------------------------------
 10# Configuration
 11
 12# The following variables must be customized to set up the network and MIDI
 13# port settings.
 14
 15# IDeATe MQTT server name.
 16mqtt_hostname = "mqtt.ideate.cmu.edu"
 17
 18# IDeATe MQTT server port, specific to each course.
 19# Please see https://mqtt.ideate.cmu.edu for details.
 20mqtt_portnum  = 8884   # 16-223
 21
 22# Username and password, provided by instructor for each course.
 23mqtt_username = ''
 24mqtt_password = ''
 25
 26# MQTT publication topic.  This is usually the students Andrew ID.
 27mqtt_topic = 'orchestra'
 28# mqtt_topic = ''
 29
 30# MQTT receive subscription.  This is usually the partner Andrew ID.
 31mqtt_subscription = 'unspecified'
 32
 33# MIDI port name on macOS for our preferred Akai drum pad.
 34midi_portname = 'MPD218 Port A'
 35
 36#----------------------------------------------------------------
 37# Standard Python modules.
 38import sys, time, signal, platform
 39
 40#----------------------------------------------------------------
 41if mqtt_username == '' or mqtt_password == '' or mqtt_topic == '' or midi_portname == '': 
 42    print("""\
 43This script must be customized before it can be used.  Please edit the file with
 44a Python or text editor and set the variables appropriately in the Configuration
 45section at the top of the file.
 46""")
 47    sys.exit(0)
 48    
 49#----------------------------------------------------------------
 50
 51# Import the MQTT client library.
 52# documentation: https://www.eclipse.org/paho/clients/python/docs/
 53import paho.mqtt.client as mqtt
 54
 55# For documentation on python-rtmidi: https://pypi.org/project/python-rtmidi/
 56import rtmidi
 57
 58#----------------------------------------------------------------
 59class MidiMqtt(object):
 60    """Class to manage a MIDI input connection and process MIDI events into MQTT messages."""
 61    
 62    def __init__(self, portname, client):
 63
 64        # save the client handle
 65        self.client = client
 66
 67        print(f"Opening MIDI input to look for {portname}.")
 68        
 69        # Initialize the MIDI input system and read the currently available ports.
 70        self.midi_in = rtmidi.MidiIn()
 71        
 72        for idx, name in enumerate(self.midi_in.get_ports()):
 73            if portname in name:
 74                print("Found preferred MIDI input device %d: %s" % (idx, name))
 75                self.midi_in.open_port(idx)
 76                self.midi_in.set_callback(self.midi_received)
 77                break
 78            else:
 79                print("Ignoring unselected MIDI device: ", name)
 80
 81        if not self.midi_in.is_port_open():
 82            if platform.system() == 'Windows':
 83                print("Virtual MIDI inputs are not currently supported on Windows, see python-rtmidi documentation.")
 84            else:
 85                print("Creating virtual MIDI input.")
 86                self.midi_in.open_virtual_port(portname)
 87
 88    def decode_mpd218_key(self, key):
 89        """Interpret a MPD218 pad event key value as a row, column, and bank position.
 90        Row 0 is the front/bottom row (Pads 1-4), row 3 is the back/top row (Pads 13-16).
 91        Column 0 is the left, column 3 is the right.
 92        Bank 0 is the A bank, bank 1 is the B bank, bank 2 is the C bank.
 93
 94        :param key: an integer MIDI note value
 95        :return: (row, column, bank)
 96        """
 97        # Decode the key into coordinates on the 4x4 pad grid.
 98        bank = (key - 36) // 16
 99        pos = (key - 36) % 16
100        row = pos // 4
101        col = pos % 4
102        return row, col, bank
103
104    def decode_mpd218_cc(self, cc):
105        """Interpret a MPD218 knob control change event as a knob index and bank position.
106        The MPD218 uses a non-contiguous set of channel indices so this normalizes the result.
107        The knob index ranges from 1 to 6 matching the knob labels.
108        Bank 0 is the A bank, bank 1 is the B bank, bank 2 is the C bank.
109
110        :param cc: an integer MIDI control channel identifier
111        :return: (knob, bank)
112        """
113        if cc < 16:
114            knob = {3:1, 9:2, 12:3, 13:4, 14:5, 15:6}.get(cc)
115            bank = 0
116        else:
117            knob = 1 + ((cc - 16) % 6)
118            bank = 1 + ((cc - 16) // 6)
119        return knob, bank
120
121    def decode_message(self, message):
122        """Decode a MIDI message expressed as a list of integers and perform callbacks
123        for recognized message types.
124
125        :param message: list of integers containing a single MIDI message
126        """
127        if len(message) > 0:
128            status = message[0] & 0xf0
129            channel = (message[0] & 0x0f) + 1
130
131            if len(message) == 2:
132                if status == 0xd0: # == 0xdx, channel pressure, any channel
133                    return self.channel_pressure(channel, message[1])
134
135            elif len(message) == 3:
136                if status == 0x90: # == 0x9x, note on, any channel
137                    return self.note_on(channel, message[1], message[2])
138
139                elif status == 0x80: # == 0x8x, note off, any channel
140                    return self.note_off(channel, message[1], message[2])
141
142                elif status == 0xb0: # == 0xbx, control change, any channel
143                    return self.control_change(channel, message[1], message[2])
144
145                elif status == 0xa0: # == 0xax, polyphonic key pressure, any channel
146                    return self.polyphonic_key_pressure(channel, message[1], message[2])
147    
148    def midi_received(self, data, unused):
149        msg, delta_time = data
150        self.decode_message(msg)
151
152    def note_off(self, channel, key, velocity):
153        """Function to receive messages starting with 0x80 through 0x8F.
154
155        :param channel: integer from 1 to 16
156        :param key: integer from 0 to 127
157        :param velocity: integer from 0 to 127
158        """
159        pass
160
161    def note_on(self, channel, key, velocity):
162        """Function to receive messages starting with 0x90 through 0x9F.
163
164        :param channel: integer from 1 to 16
165        :param key: integer from 0 to 127
166        :param velocity: integer from 0 to 127
167        """
168        topic = mqtt_topic + '/noteOn'
169        payload = "%d %d" % (key, velocity)
170        self.client.publish(topic, payload)
171
172        # apply some logic to route control inputs to other topics
173        row, column, bank = self.decode_mpd218_key(key)
174        topic = mqtt_topic + '/instrument%d' % (column+1)
175        payload = "%d %d" % (row, velocity)
176        self.client.publish(topic, payload)
177        
178    def polyphonic_key_pressure(self, channel, key, value):
179        """Function to receive messages starting with 0xA0 through 0xAF.
180
181        :param channel: integer from 1 to 16
182        :param key: integer from 0 to 127
183        :param value: integer from 0 to 127
184        """
185        pass
186
187    def control_change(self, channel, control, value):
188        """Function to receive messages starting with 0xB0 through 0xBF.
189
190        :param channel: integer from 1 to 16
191        :param control: integer from 0 to 127; some have special meanings
192        :param value: integer from 0 to 127
193        """
194        topic = mqtt_topic + '/controlChange'
195        payload = "%d %d" % (control, value)
196        self.client.publish(topic, payload)
197
198        # apply some logic to route control inputs to other topics
199        knob, bank = self.decode_mpd218_cc(control)
200        topic = mqtt_topic + '/project%d' % (knob)
201        payload = "%d" % (value)
202        self.client.publish(topic, payload)
203
204
205    def channel_pressure(self, channel, value):
206        """Function to receive messages starting with 0xD0 through 0xDF.
207
208        :param channel: integer from 1 to 16
209        :param value: integer from 0 to 127
210        """
211        pass
212
213#----------------------------------------------------------------
214# Global script variables.
215
216midi_port = None
217client = None
218    
219#----------------------------------------------------------------
220# Attach a handler to the keyboard interrupt (control-C).
221def _sigint_handler(signal, frame):
222    print("Keyboard interrupt caught, closing down...")
223    if client is not None:
224        client.loop_stop()
225    sys.exit(0)
226signal.signal(signal.SIGINT, _sigint_handler)        
227
228#----------------------------------------------------------------
229# MQTT networking functions.
230
231#----------------------------------------------------------------
232# The callback for when the broker responds to our connection request.
233def on_connect(client, userdata, flags, rc):
234    print(f"MQTT connected with flags: {flags}, result code: {rc}")
235
236    # Subscribing in on_connect() means that if we lose the connection and
237    # reconnect then subscriptions will be renewed.  The hash mark is a
238    # multi-level wildcard, so this will subscribe to all subtopics of 16223
239    client.subscribe(mqtt_subscription)
240    return
241
242#----------------------------------------------------------------
243# The callback for when a message has been received on a topic to which this
244# client is subscribed.  The message variable is a MQTTMessage that describes
245# all of the message parameters.
246
247# Some useful MQTTMessage fields: topic, payload, qos, retain, mid, properties.
248#   The payload is a binary string (bytes).
249#   qos is an integer quality of service indicator (0,1, or 2)
250#   mid is an integer message ID.
251def on_message(client, userdata, msg):
252    print(f"message received: topic: {msg.topic} payload: {msg.payload}")
253    return
254
255#----------------------------------------------------------------
256# Launch the MQTT network client
257client = mqtt.Client()
258client.enable_logger()
259client.on_connect = on_connect
260client.on_message = on_message
261client.tls_set()
262client.username_pw_set(mqtt_username, mqtt_password)
263
264# Start a background thread to connect to the MQTT network.
265client.connect_async(mqtt_hostname, mqtt_portnum)
266client.loop_start()
267
268#----------------------------------------------------------------
269# Connect to the MIDI port.  MIDI events will be received on a separate thread.
270midi_port = MidiMqtt(midi_portname, client)
271
272# Nothing to do on the main thread.
273while(True):
274    time.sleep(60)