[#293] Merged midi plugin and backend.

Closes: #293
This commit is contained in:
Fabio Manganiello 2024-05-06 02:26:27 +02:00
parent 6a8c83f99b
commit 3c88593e9a
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
8 changed files with 480 additions and 236 deletions

View file

@ -7,7 +7,6 @@ Backends
:caption: Backends: :caption: Backends:
platypush/backend/http.rst platypush/backend/http.rst
platypush/backend/midi.rst
platypush/backend/nodered.rst platypush/backend/nodered.rst
platypush/backend/redis.rst platypush/backend/redis.rst
platypush/backend/tcp.rst platypush/backend/tcp.rst

View file

@ -1,6 +0,0 @@
``midi``
==========================
.. automodule:: platypush.backend.midi
:members:

View file

@ -1,136 +0,0 @@
import time
from threading import Timer
from platypush.backend import Backend
from platypush.message.event.midi import MidiMessageEvent
class MidiBackend(Backend):
"""
This backend will listen for events from a MIDI device and post a
MidiMessageEvent whenever a new MIDI event happens.
"""
def __init__(
self,
device_name=None,
port_number=None,
midi_throttle_time=None,
*args,
**kwargs
):
"""
:param device_name: Name of the MIDI device. *N.B.* either
`device_name` or `port_number` must be set.
Use :meth:`platypush.plugins.midi.MidiPlugin.query_ports` to get the
available ports indices and names
:type device_name: str
:param port_number: MIDI port number
:type port_number: int
:param midi_throttle_time: If set, the MIDI events will be throttled -
max one per selected time frame (in seconds). Set this parameter if
you want to synchronize MIDI events with plugins that normally
operate with a lower throughput.
:type midi_throttle_time: int
"""
import rtmidi
super().__init__(*args, **kwargs)
if (device_name and port_number is not None) or (
not device_name and port_number is None
):
raise RuntimeError(
'Either device_name or port_number (not both) '
+ 'must be set in the MIDI backend configuration'
)
self.midi_throttle_time = midi_throttle_time
self.midi = rtmidi.MidiIn()
self.last_trigger_event_time = None
self.midi_flush_timeout = None
ports = self.midi.get_ports()
if not ports:
raise RuntimeError('No MIDI devices available')
if device_name:
if device_name not in ports:
raise RuntimeError('MIDI device "{}" not found'.format(device_name))
self.port_number = ports.index(device_name)
self.device_name = device_name
if port_number:
if port_number < 0 or port_number >= len(ports):
raise RuntimeError('MIDI port {} not found')
self.port_number = port_number
self.device_name = ports[port_number]
self.midi.set_callback(self._on_midi_message())
def _on_midi_message(self):
def flush_midi_message(message):
def _f():
self.logger.info(
'Flushing throttled MIDI message {} to the bus'.format(message)
)
delay = time.time() - self.last_trigger_event_time
self.bus.post(MidiMessageEvent(message=message, delay=delay))
return _f
# noinspection PyUnusedLocal
def callback(message, data):
# rtmidi will provide a tuple in the format
# (midi_message, time_since_last_event)
delay = message[1]
message = message[0]
if self.midi_throttle_time and self.last_trigger_event_time:
event_delta = time.time() - self.last_trigger_event_time
if event_delta < self.midi_throttle_time:
self.logger.debug('Skipping throttled message {}'.format(message))
if self.midi_flush_timeout:
self.midi_flush_timeout.cancel()
self.midi_flush_timeout = Timer(
self.midi_throttle_time - event_delta,
flush_midi_message(message),
)
self.midi_flush_timeout.start()
return
self.last_trigger_event_time = time.time()
self.bus.post(MidiMessageEvent(message=message, delay=delay))
return callback
def run(self):
super().run()
self.midi.open_port(self.port_number)
self.logger.info(
'Initialized MIDI backend, listening for events on device {}'.format(
self.device_name
)
)
while not self.should_stop():
try:
time.sleep(1)
except Exception as e:
self.logger.exception(e)
if self.midi:
self.midi.close_port()
self.midi = None
# vim:sw=4:ts=4:et:

View file

@ -1,15 +0,0 @@
manifest:
events:
platypush.message.event.midi.MidiMessageEvent: when a new MIDI event is received
install:
apt:
- python3-rtmidi
dnf:
- python-rtmidi
pacman:
- rtmidi
- python-rtmidi
pip:
- rtmidi
package: platypush.backend.midi
type: backend

View file

@ -1,22 +1,57 @@
from abc import ABC
from typing import Any, List, Optional
from platypush.message.event import Event from platypush.message.event import Event
class MidiMessageEvent(Event): class MidiEvent(Event, ABC):
""" """
Event triggered upon received MIDI message Base class for MIDI events.
""" """
def __init__(self, message, delay=None, *args, **kwargs): def __init__(self, *args, device: Optional[str], port: Optional[int], **kwargs):
""" """
:param message: Received MIDI message :param device: The MIDI device name.
:type message: tuple[int] :param port: The MIDI device port number.
:param delay: Time in seconds since the previous MIDI event (default: None)
:type delay: float
""" """
super().__init__(*args, device=device, port=port, **kwargs)
super().__init__(*args, message=message, delay=delay, **kwargs)
class MidiMessageEvent(MidiEvent):
"""
Event triggered upon received MIDI message.
"""
def __init__(self, *args, message: List[int], data: Optional[Any] = None, **kwargs):
"""
:param message: The received MIDI message.
:param data: Additional data associated to the event.
"""
super().__init__(*args, message=message, data=data, **kwargs)
class MidiDeviceConnectedEvent(MidiEvent):
"""
Event triggered when a MIDI device is connected.
"""
class MidiDeviceDisconnectedEvent(MidiEvent):
"""
Event triggered when a MIDI device is disconnected.
"""
class MidiDeviceAddedEvent(MidiEvent):
"""
Event triggered when a MIDI device is added to the list of available devices.
"""
class MidiDeviceRemovedEvent(MidiEvent):
"""
Event triggered when a MIDI device is removed from the list of available devices.
"""
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -1,84 +1,197 @@
import time import time
import re
from threading import RLock, Timer
from typing import Optional, Sequence, Union
from platypush.plugins import Plugin, action import rtmidi
from platypush.message.event.midi import MidiMessageEvent
from platypush.plugins import RunnablePlugin, action
from ._model import MidiDevice, MidiDeviceType, MidiDevices
class MidiPlugin(Plugin): class MidiPlugin(RunnablePlugin):
""" """
Virtual MIDI controller plugin. It allows you to send custom MIDI messages Virtual MIDI controller plugin. It allows you to send custom MIDI messages
to any connected devices. to any connected MIDI devices and listen for MIDI events.
""" """
_played_notes = set() _played_notes = set()
def __init__(self, device_name='Platypush virtual MIDI output', **kwargs): def __init__(
self,
device_name: str = 'Platypush MIDI plugin',
midi_devices: Optional[Sequence[Union[str, int]]] = None,
poll_interval: Optional[float] = 5.0,
event_resolution: Optional[float] = None,
**kwargs,
):
""" """
:param device_name: MIDI virtual device name (default: *Platypush virtual MIDI output*) :param device_name: Name of the MIDI device associated to this plugin.
:type device_name: str :param midi_devices: List of MIDI devices to open and monitor for
events, by name or by port number. If set, and ``poll_interval``
is set, then the plugin will only listen for events from these
devices. If not set, and ``poll_interval`` is set, then the plugin
will listen for events from all available MIDI devices (default).
:param poll_interval: How often the plugin should scan for new MIDI
devices. Set this to 0 or null to disable polling.
:param event_resolution: If set, then the plugin will throttle
MIDI events to the specified time resolution. If an event is
triggered within the specified time resolution, then the event will
be throttled and the last event will be discarded. This is useful
to avoid sending too many MIDI messages in a short time frame.
Default: no throttling.
""" """
import rtmidi super().__init__(poll_interval=poll_interval, **kwargs)
super().__init__(**kwargs)
self._devices = MidiDevices()
self.device_name = device_name self.device_name = device_name
self.midiout = rtmidi.MidiOut() self._devices_to_monitor = {
available_ports = self.midiout.get_ports() self._to_device_id(dev) for dev in (midi_devices or [])
}
self._midi_out = None
self._midi_in = None
self._last_midi_event = None
self._last_event_trigger_time = None
self._event_timer = None
self._event_timer_lock = RLock()
self._event_resolution = event_resolution
self.logger.info(
'Initialized MIDI plugin on virtual device %s', self.device_name
)
if available_ports: @staticmethod
self.midiout.open_port(0) def _to_device_id(device: Union[str, int]) -> Union[str, int]:
self.logger.info('Initialized MIDI plugin on port 0') try:
else: device = int(device)
self.midiout.open_virtual_port(self.device_name) except ValueError:
self.logger.info( pass
'Initialized MIDI plugin on virtual device {}'.format(self.device_name)
return device
@property
def midi_in(self) -> rtmidi.MidiIn: # type: ignore
if not self._midi_in:
self._midi_in = rtmidi.MidiIn() # type: ignore
self._midi_in.open_port()
return self._midi_in
@property
def midi_out(self) -> rtmidi.MidiOut: # type: ignore
if not self._midi_out:
self._midi_out = rtmidi.MidiOut() # type: ignore
self._midi_out.open_port()
return self._midi_out
def start_event_timer(self):
def flush_last_event():
self._last_event_trigger_time = time.time()
if not self._last_midi_event:
return
self._bus.post(self._last_midi_event)
with self._event_timer_lock:
if not self._event_timer:
self._event_timer = Timer(self._event_resolution or 0, flush_last_event)
self._event_timer.start()
return self._event_timer
def _on_midi_message(self, device: Optional[MidiDevice] = None):
def callback(message, data):
# rtmidi will provide a tuple in the format
# (midi_message, time_since_last_event)
# delay = message[1]
message = message[0]
self._last_midi_event = event = MidiMessageEvent(
message=message,
data=data,
device=device.name if device else None,
port=device.port if device else None,
) )
@action if self._event_resolution and self._last_event_trigger_time:
def send_message(self, values): event_delta = time.time() - self._last_event_trigger_time
""" if event_delta < self._event_resolution:
:param values: Values is expected to be a list containing the MIDI command code and the command parameters - self.start_event_timer()
see reference at https://ccrma.stanford.edu/~craig/articles/linuxmidi/misc/essenmidi.html self.logger.debug('Skipping throttled message: %s', message)
:type values: list[int] return
Available MIDI commands: self._last_event_trigger_time = time.time()
* ``0x80`` Note Off self._bus.post(event)
* ``0x90`` Note On
* ``0xA0`` Aftertouch
* ``0xB0`` Continuous controller
* ``0xC0`` Patch change
* ``0xD0`` Channel Pressure
* ``0xE0`` Pitch bend
* ``0xF0`` Start of system exclusive message
* ``0xF1`` MIDI Time Code Quarter Frame (Sys Common)
* ``0xF2`` Song Position Pointer (Sys Common)
* ``0xF3`` Song Select
* ``0xF6`` Tune Request (Sys Common)
* ``0xF7`` End of system exclusive message
* ``0xF8`` Timing Clock (Sys Realtime)
* ``0xFA`` Start (Sys Realtime)
* ``0xFB`` Continue (Sys Realtime)
* ``0xFC`` Stop (Sys Realtime)
* ``0xFE`` Active Sensing (Sys Realtime)
* ``0xFF`` System Reset (Sys Realtime)
"""
self.midiout.send_message(values) return callback
@action @action
def play_note(self, note, velocity, duration=0): def send_message(
self, values: Sequence[int], device: Optional[Union[int, str]] = None
):
"""
:param values: Values is expected to be a list containing the MIDI
command code and the command parameters - `see reference
<https://ccrma.stanford.edu/~craig/articles/linuxmidi/misc/essenmidi.html>`_.
Available MIDI commands:
* ``0x80`` Note Off
* ``0x90`` Note On
* ``0xA0`` Aftertouch
* ``0xB0`` Continuous controller
* ``0xC0`` Patch change
* ``0xD0`` Channel Pressure
* ``0xE0`` Pitch bend
* ``0xF0`` Start of system exclusive message
* ``0xF1`` MIDI Time Code Quarter Frame (Sys Common)
* ``0xF2`` Song Position Pointer (Sys Common)
* ``0xF3`` Song Select
* ``0xF6`` Tune Request (Sys Common)
* ``0xF7`` End of system exclusive message
* ``0xF8`` Timing Clock (Sys Realtime)
* ``0xFA`` Start (Sys Realtime)
* ``0xFB`` Continue (Sys Realtime)
* ``0xFC`` Stop (Sys Realtime)
* ``0xFE`` Active Sensing (Sys Realtime)
* ``0xFF`` System Reset (Sys Realtime)
:param device: MIDI port to send the message to, by number or by name.
If None then the message will be sent to the default port allocated
for the plugin.
"""
if isinstance(device, (str, int)):
if isinstance(device, str):
dev = self._devices.by_type(MidiDeviceType.OUTPUT).get(device)
else:
dev = self._devices.by_port(device)
assert dev, f'Could not find device by name {device}'
assert (
dev.device_type == MidiDeviceType.OUTPUT
), f'The device {device} is not an output device'
midi_out = dev.midi_out
if not (midi_out and midi_out.is_port_open()):
dev.open()
else:
midi_out = self.midi_out
assert midi_out and midi_out.is_port_open(), 'No MIDI output port available'
midi_out.send_message(values)
@action
def play(self, note: int, velocity: int, duration: float = 0):
""" """
Play a note with selected velocity and duration. Play a note with selected velocity and duration.
:param note: MIDI note in range 0-127 with #60 = C4 :param note: MIDI note in range 0-127 with #60 = C4
:type note: int
:param velocity: MIDI note velocity in range 0-127 :param velocity: MIDI note velocity in range 0-127
:type velocity: int
:param duration: Note duration in seconds. Pass 0 if you don't want the note to get off :param duration: Note duration in seconds. Pass 0 if you don't want the note to get off
:type duration: float
""" """
self.send_message([0x90, note, velocity]) # Note on self.send_message([0x90, note, velocity]) # Note on
self._played_notes.add(note) self._played_notes.add(note)
@ -88,42 +201,110 @@ class MidiPlugin(Plugin):
self._played_notes.remove(note) self._played_notes.remove(note)
@action @action
def release_note(self, note): def release(self, note: int):
""" """
Release a played note. Release a played note.
:param note: MIDI note in range 0-127 with #60 = C4 :param note: MIDI note in range 0-127 with #60 = C4
:type note: int
""" """
self.send_message([0x80, note, 0]) # Note off self.send_message([0x80, note, 0]) # Note off
self._played_notes.remove(note) self._played_notes.remove(note)
@action @action
def release_all_notes(self): def release_all(self):
""" """
Release all the notes being played. Release all the notes being played.
""" """
played_notes = self._played_notes.copy() played_notes = self._played_notes.copy()
for note in played_notes: for note in played_notes:
self.release_note(note) self.release(note)
@action @action
def query_ports(self): def query(self):
""" """
:returns: dict: A list of the available MIDI ports with index and name :returns: dict: A list of the available MIDI ports with index and name.
Format: ``port_index: device_name``.
.. code-block:: json
{
"in": {
0: "Midi Through:Midi Through Port-0 14:0",
1: "MPK mini 3:MPK mini 3 MIDI 1 32:0",
2: "X-TOUCH MINI:X-TOUCH MINI MIDI 1 36:0",
3: "RtMidiOut Client:Platypush MIDI plugin 129:0"
},
"out": {
0: "Midi Through:Midi Through Port-0 14:0",
1: "MPK mini 3:MPK mini 3 MIDI 1 32:0",
2: "X-TOUCH MINI:X-TOUCH MINI MIDI 1 36:0"
}
}
""" """
in_ports = {int(port): dev for port, dev in enumerate(self.midi_in.get_ports())}
out_ports = {
int(port): dev for port, dev in enumerate(self.midi_out.get_ports())
}
import rtmidi for device_type, devices in (
(MidiDeviceType.INPUT, in_ports),
(MidiDeviceType.OUTPUT, out_ports),
):
for port, name in devices.items():
dev = MidiDevice(
name=name,
port=port,
device_type=device_type,
callback=self._on_midi_message,
)
in_ports = rtmidi.MidiIn().get_ports() self._devices.add(dev)
out_ports = rtmidi.MidiOut().get_ports() dev = self._devices.by_port(port)
assert dev, f'Could not find device by port {port}'
if not self._is_self(dev) and (
not self._devices_to_monitor
or port in self._devices_to_monitor
or name in self._devices_to_monitor
):
dev.open()
return { return {
'in': dict(enumerate(in_ports)), 'in': in_ports,
'out': dict(enumerate(out_ports)), 'out': out_ports,
} }
def _is_self(self, device: MidiDevice):
return device.name == self.device_name or re.search(
r'^RtMidi(In|Out) Client:', device.name
)
def cleanup(self):
self.release_all()
if self._midi_in:
self._midi_in.close_port()
self._midi_in = None
if self._midi_out:
self._midi_out.close_port()
self._midi_out = None
self._devices.close()
def main(self):
# Don't run the polling logic if the poll_interval is not set
if not self.poll_interval:
self.wait_stop()
return
while not self.should_stop():
self.query()
self.wait_stop(self.poll_interval)
def stop(self):
self.cleanup()
super().stop()
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -0,0 +1,180 @@
from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable, Dict, Optional, Sequence, Union
import rtmidi
from platypush.context import get_bus
from platypush.message.event.midi import (
MidiDeviceAddedEvent,
MidiDeviceConnectedEvent,
MidiDeviceDisconnectedEvent,
MidiDeviceRemovedEvent,
)
class MidiDeviceType(Enum):
"""
Enum for MIDI device types.
"""
INPUT = 'input'
OUTPUT = 'output'
@dataclass
class MidiDevice:
"""
Data class for MIDI devices.
"""
name: str
port: int
device_type: MidiDeviceType
midi_in: Optional[rtmidi.MidiIn] = None # type: ignore
midi_out: Optional[rtmidi.MidiOut] = None # type: ignore
callback: Optional[
Callable[
[Optional["MidiDevice"]],
Callable[[Sequence[int], Optional[Any]], Optional[Any]],
]
] = None
def open(self):
"""
Open the MIDI device.
"""
if self.device_type == MidiDeviceType.INPUT and not self.midi_in:
get_bus().post(MidiDeviceConnectedEvent(device=self.name, port=self.port))
self.midi_in = rtmidi.MidiIn() # type: ignore
self.midi_in.open_port(self.port)
if self.callback:
self.midi_in.set_callback(self.callback(self))
elif self.device_type == MidiDeviceType.OUTPUT and not self.midi_out:
self.midi_out = rtmidi.MidiOut() # type: ignore
self.midi_out.open_port(self.port)
def close(self):
"""
Close the MIDI device.
"""
if self.midi_in:
self.midi_in.close_port()
self.midi_in = None
get_bus().post(
MidiDeviceDisconnectedEvent(device=self.name, port=self.port)
)
if self.midi_out:
self.midi_out.close_port()
self.midi_out = None
get_bus().post(
MidiDeviceDisconnectedEvent(device=self.name, port=self.port)
)
def is_open(self) -> bool:
"""
Check if the MIDI device is open.
:returns: True if the MIDI device is open, False otherwise.
"""
return (self.midi_in is not None and self.midi_in.is_port_open()) or (
self.midi_out is not None and self.midi_out.is_port_open()
)
class MidiDevices:
"""
Class to manage MIDI devices.
"""
def __init__(self):
self._devices_by_port = {}
self._devices_by_name = {}
self._devices_by_type = {MidiDeviceType.INPUT: {}, MidiDeviceType.OUTPUT: {}}
def add(self, device: MidiDevice):
"""
Add a MIDI device to the list of available devices.
:param device: The MIDI device to add.
"""
if (
device.port not in self._devices_by_port
or self._devices_by_port[device.port].name != device.name
):
self._devices_by_port[device.port] = device
if device.name not in self._devices_by_name:
get_bus().post(MidiDeviceAddedEvent(device=device.name, port=device.port))
self._devices_by_name[device.name] = device
if device.port not in self._devices_by_type[device.device_type]:
self._devices_by_type[device.device_type][device.name] = device
def remove(self, device: MidiDevice):
"""
Remove a MIDI device from the list of available devices.
:param device: The MIDI device to remove.
"""
device.close()
del self._devices_by_port[device.port]
del self._devices_by_name[device.name]
del self._devices_by_type[device.device_type][device.name]
get_bus().post(MidiDeviceRemovedEvent(device=device.name, port=device.port))
def by_port(self, port: int) -> Optional[MidiDevice]:
"""
Get a MIDI device by port.
:param port: The port of the MIDI device.
:returns: The MIDI device if found, None otherwise.
"""
return self._devices_by_port.get(port)
def by_name(self, name: str) -> Optional[MidiDevice]:
"""
Get a MIDI device by name.
:param name: The name of the MIDI device.
:returns: The MIDI device if found, None otherwise.
"""
return self._devices_by_name.get(name)
def by_type(self, device_type: MidiDeviceType) -> Dict[str, MidiDevice]:
"""
Get all MIDI devices of a certain type.
:param device_type: The type of the MIDI devices.
:returns: A list of MIDI devices of the specified type.
"""
return self._devices_by_type[device_type]
def get(self, device: Union[str, int]) -> Optional[MidiDevice]:
"""
Get a MIDI device by name or port.
:param device: The name or port of the MIDI device.
:returns: The MIDI device if found, None otherwise.
"""
if isinstance(device, str):
return self.by_name(device)
if isinstance(device, int):
return self.by_port(device)
return None
def close(self):
"""
Close and clear all MIDI devices.
"""
devices = list(self._devices_by_port.values())
for dev in devices:
self.remove(dev)
self._devices_by_port.clear()
self._devices_by_name.clear()
self._devices_by_type = {MidiDeviceType.INPUT: {}, MidiDeviceType.OUTPUT: {}}
# vim:sw=4:ts=4:et:

View file

@ -1,13 +1,19 @@
manifest: manifest:
events: {} events:
- platypush.message.event.midi.MidiDeviceAddedEvent
- platypush.message.event.midi.MidiDeviceConnectedEvent
- platypush.message.event.midi.MidiDeviceDisconnectedEvent
- platypush.message.event.midi.MidiDeviceRemovedEvent
- platypush.message.event.midi.MidiMessageEvent
install: install:
apt: apt:
- python3-rtmidi - python3-rtmidi
dnf: dnf:
- python-rtmidi - python-rtmidi
pacman: pacman:
- rtmidi
- python-rtmidi - python-rtmidi
pip: pip:
- python-rtmidi - rtmidi
package: platypush.plugins.midi package: platypush.plugins.midi
type: plugin type: plugin