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:
Install one of the remote connection examples on your CircuitPython board. These can serve as starting points for your own sketches.
Open the
midi_to_mqtt.py
program in a Python editor.Locate the top section named “Configuration” and follow the prompts to customize the settings.
Customize the MIDI event processing to produce output compatible with your sketch.
Run the script using Python 3, typically as follows:
python3 midi_to_mqtt.py
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)