Categories
Blog Music

MIDI Responsive Qt

Challenge: get MIDI keyboard to update Qt controls for some live jam experimentation. Task list:

  • Use a particular channel for a controller. In future I could make a hardware device that changes channel on a button press, say.
  • Respond to dials changing to update Qt controls. Should be as simple as binding MIDI signal to Qt slot.


I went around the houses a little on this, so rather than adding to my current Midi looper project I’ve made a stand-alone Qt app that contains a number of dials that reflect the potentiometer values on my mini controller keyboard:

The code uses rtmidi and I shepherd the events through a Qt.QueuedConnection. This was precautionary as I thought rtmidi’s set_callback would be on a different thread. After some experimentation it appears to be the same as the Qt GUI thread, but I’m suspicious of that so I’ve left the code in.

"""
Midi surface control test. Show dials for control keyboard and update Qt UI.
Made with reference to:
 - https://www.songstuff.com/recording/article/midi_message_format/
 - https://github.com/pyqt/examples/
"""
import rtmidi
import sys
from PyQt5.QtWidgets import (QApplication, QDial, QHBoxLayout, QWidget)
from PyQt5.QtCore import (pyqtSignal, QObject, Qt)


def get_port_by_name(port_name, client):
    for i in range(client.get_port_count()):
        if port_name == client.get_port_name(i):
            return client.open_port(i)

    # Report available ports if not found
    msg = f'Unknown port {port_name}'
    for i in range (client.get_port_count()):
        msg += f'\n - {client.get_port_name(i)}'
    raise Exception(msg)


def status_channel(status):
    return status & 0xf


def is_control_change(status):
    return (status & 0xb0) == 0xb0


class MidiReceiver(QObject):
    # Internal signal for handing over MIDI events in thread-safe manner
    _queue_message = pyqtSignal(int, int, int, int)

    """
    Qt signal for a received MIDI message. Signature is:
        int: status
        int: data1
        int: data2
        int: event time in ms
    """
    process_message = pyqtSignal(int, int, int, int)

    def __init__(self, midi_in_port):
        super().__init__()
        self._midi_in = get_port_by_name(midi_in_port, rtmidi.MidiIn())
        self._midi_in.set_callback(self._midi_in_callback, self)
        self._time_ms = 0
        self._queue_message.connect(self._thread_safe_queue_message, Qt.QueuedConnection)

    def _thread_safe_queue_message(self, status, data1, data2, time):
        self.process_message.emit(status, data1, data2, time)

    @staticmethod
    def _midi_in_callback(msg_time_ms_tup, self):
        self._time_ms += int(msg_time_ms_tup[1] * 1000)
        msg = msg_time_ms_tup[0]
        status = msg[0]
        data1 = msg[1]
        data2 = msg[2]
        self._queue_message.emit(status, data1, data2, self._time_ms)


class MainWindow(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle('Midi control test')
        self.midi_control = MidiReceiver('Midilink 0')
        self.midi_control.process_message.connect(self.process_message)

        control_ids = [74, 71, 81, 91, 16, 80, 19, 2]
        self.dial_map = {}

        layout = QHBoxLayout()
        for control_id in control_ids:
            dial = QDial()
            dial.setMaximum(127)
            layout.addWidget(dial)
            self.dial_map[control_id] = dial
        self.setLayout(layout)

    def process_message(self, status, data1, data2, _time_ms):
        if is_control_change(status):
            if data1 in self.dial_map:
                self.dial_map[data1].setValue(data2)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    main_window = MainWindow()
    main_window.show()
    sys.exit(app.exec_())

Leave a Reply

Your email address will not be published. Required fields are marked *