diff --git a/CHANGELOG.md b/CHANGELOG.md index ee083ee..2177ddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ * Bugfix: Fix crash when sender name contains non-ascii characters (#18). * Internal: Switched from `pipenv` to `poetry`. * Internal: Added `black` for code formatting. +* Improvement: Calculates latency to client and outputs as info. +* Improvement: Decodes pitch blend change messages. +* Breaking change: `on_midi_commands` callback handler now passes whole MIDI packet rather than a list of commands - this is useful if the journal (or other data) is required. +* Improvement: RTP sequence numbers now increment +* Improvement: Timestamps don't overflow 32-bit field (issue #34) +* Improvement: disconnt() method for client (issue #28) ## v0.5.0 (2020-01-12) diff --git a/README.md b/README.md index 9c06f9c..c0f95c7 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,8 @@ class MyHandler(server.Handler): def on_peer_disconnected(self, peer): print('Peer disconnected: {}'.format(peer)) - def on_midi_commands(self, peer, command_list): - for command in command_list: + def on_midi_commands(self, peer, midi_packet): + for command in midi_packet.command.midi_list: if command.command == 'note_on': key = command.params.key velocity = command.params.velocity diff --git a/examples/example_client.py b/examples/example_client.py index fd28479..187c417 100644 --- a/examples/example_client.py +++ b/examples/example_client.py @@ -48,7 +48,7 @@ def main(): host = '0.0.0.0' port = 5004 logger.info(f'Connecting to RTP-MIDI server @ {host}:{port} ...') - client.connect('0.0.0.0', port) + client.connect(host, port) logger.info('Connecting!') while True: logger.info('Striking key...') diff --git a/examples/example_server.py b/examples/example_server.py index 478299b..5acbf9a 100644 --- a/examples/example_server.py +++ b/examples/example_server.py @@ -59,8 +59,8 @@ def on_peer_connected(self, peer): def on_peer_disconnected(self, peer): self.logger.info('Peer disconnected: {}'.format(peer)) - def on_midi_commands(self, peer, command_list): - for command in command_list: + def on_midi_commands(self, peer, midi_packet): + for command in midi_packet.command.midi_list: if command.command == 'note_on': key = command.params.key velocity = command.params.velocity diff --git a/pymidi/client.py b/pymidi/client.py index 677c4b2..de53861 100644 --- a/pymidi/client.py +++ b/pymidi/client.py @@ -6,12 +6,12 @@ import socket import sys import random -import time from pymidi import packets from pymidi import protocol from pymidi import utils from pymidi.utils import b2h +from pymidi.utils import get_timestamp from construct import ConstructError try: @@ -31,28 +31,35 @@ class AlreadyConnected(ClientError): class Client(object): - def __init__(self, name='PyMidi', ssrc=None): + def __init__(self, name='PyMidi', ssrc=None, sourcePort=None): """Creates a new Client instance.""" self.ssrc = ssrc or random.randint(0, 2 ** 32 - 1) - self.socket = None + self.socket = [None,None] # Need to have a command and data socket on the client side self.host = None self.port = None + self.sourcePort = sourcePort or 5004 + self.name = name or 'PyMidi' + self.sequenceNumber = 1 def connect(self, host, port): if self.host and self.port: raise ClientError(f'Already connected to {self.host}:{self.port}') - self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) pkt = packets.AppleMIDIExchangePacket.create( protocol_version=2, command=protocol.APPLEMIDI_COMMAND_INVITATION, initiator_token=random.randint(0, 2 ** 32 - 1), ssrc=self.ssrc, + name=self.name ) - for target_port in (port, port + 1): - logger.info(f'Sending exchange packet to port {target_port}...') - self.socket.sendto(pkt, (host, target_port)) - packet = self.get_next_packet() + + for index in (0, 1): + self.socket[index] = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.socket[index].bind(('0.0.0.0', self.sourcePort+index)) + + logger.info(f'Sending exchange packet to port {port+index} from port {self.sourcePort+index}...') + self.socket[index].sendto(pkt, (host, port+index)) + packet = self.get_next_packet(self.socket[index]) if not packet: raise Exception('No packet received') if packet._name != 'AppleMIDIExchangePacket': @@ -62,14 +69,32 @@ def connect(self, host, port): self.host = host self.port = port + def disconnect(self): + if not self.socket[0]: + raise ClientError(f'Not connected to anywhere') + + pkt = packets.AppleMIDIExchangePacket.create( + protocol_version=2, + command=protocol.APPLEMIDI_COMMAND_EXIT, + initiator_token=0, + ssrc=self.ssrc, + name=None + ) + self.socket[0].sendto(pkt, (self.host, self.port)) + + for index in (0, 1): self.socket[index].close() + + self.socket = [None,None] + self.host = None + self.port = None + def sync_timestamps(self, port): - ts1 = int(time.time() * 1000) packet = packets.AppleMIDITimestampPacket.create( command=protocol.APPLEMIDI_COMMAND_TIMESTAMP_SYNC, ssrc=self.ssrc, count=count, padding=0, - timestamp_1=ts1, + timestamp_1=get_timestamp(), timestamp_2=0, timestamp_3=0, ) @@ -104,25 +129,9 @@ def _send_note(self, notestr, command, velocity=80, channel=1): } ], } - self._send_rtp_command(command) - - def _send_rtp_command(self, command): - header = packets.MIDIPacketHeader.create( - rtp_header={ - 'flags': { - 'v': 0x2, - 'p': 0, - 'x': 0, - 'cc': 0, - 'm': 0x1, - 'pt': 0x61, - }, - 'sequence_number': ord('K'), - }, - timestamp=int(time.time()), - ssrc=self.ssrc, - ) + self._send_rtp_command(self.socket[1], command) + def _send_rtp_command(self, socket, command): packet = packets.MIDIPacket.create( header={ 'rtp_header': { @@ -134,19 +143,20 @@ def _send_rtp_command(self, command): 'm': 0x1, 'pt': 0x61, }, - 'sequence_number': ord('K'), + 'sequence_number': self.sequenceNumber, }, - 'timestamp': int(time.time()), + 'timestamp': get_timestamp(), 'ssrc': self.ssrc, }, command=command, journal='', ) - self.socket.sendto(packet, (self.host, self.port + 1)) + socket.sendto(packet, (self.host, self.port + 1)) + self.sequenceNumber += 1 - def get_next_packet(self): - data, addr = self.socket.recvfrom(1024) + def get_next_packet(self, socket): + data, addr = socket.recvfrom(1024) command = data[2:4] try: if data[0:2] == protocol.APPLEMIDI_PREAMBLE: diff --git a/pymidi/packets.py b/pymidi/packets.py index b01de85..3918c28 100644 --- a/pymidi/packets.py +++ b/pymidi/packets.py @@ -10,6 +10,7 @@ COMMAND_NOTE_ON = 0x90 COMMAND_AFTERTOUCH = 0xA0 COMMAND_CONTROL_MODE_CHANGE = 0xB0 +COMMAND_PITCH_BEND_CHANGE = 0xE0 def to_string(pkt): @@ -31,6 +32,10 @@ def to_string(pkt): items.append( '{} {} {}'.format(command, entry.params.controller, entry.params.value) ) + elif command == 'aftertouch': + items.append('{} {} {}'.format(command, entry.params.key, entry.params.touch)) + elif command == 'pitch_bend_change': + items.append('{} {} {}'.format(command, entry.params.lsb, entry.params.msb)) else: items.append(command) detail = ' '.join(('[{}]'.format(i) for i in items)) @@ -75,6 +80,13 @@ def create(self, **kwargs): 'timestamp_3' / Int64ub, ) +AppleMIDIReceiverFeedbackPacket = Struct( + '_name' / Computed('AppleMIDIReceiverFeedbackPacket'), + 'preamble' / Const(b'\xff\xffRS'), + 'ssrc' / Int32ub, + 'sequence_number' / Int32ub, +) + MIDIPacketHeaderFlags = Bitwise( Struct( 'v' / BitsInteger(2), # always 0x2 @@ -88,7 +100,7 @@ def create(self, **kwargs): RTPHeader = Struct( 'flags' / MIDIPacketHeaderFlags, - 'sequence_number' / Int16ub, # always 'K' + 'sequence_number' / Int16ub, ) MIDIPacketHeader = Struct( @@ -270,6 +282,7 @@ def create(self, **kwargs): note_off=COMMAND_NOTE_OFF, aftertouch=COMMAND_AFTERTOUCH, control_mode_change=COMMAND_CONTROL_MODE_CHANGE, + pitch_bend_change=COMMAND_PITCH_BEND_CHANGE ), ), 'channel' / If(_this.command_byte, Computed(_this.command_byte & 0x0F)), @@ -293,6 +306,10 @@ def create(self, **kwargs): 'controller' / Int8ub, 'value' / Int8ub, ), + 'pitch_bend_change': Struct( + 'lsb' / Int8ub, + 'msb' / Int8ub, + ), }, default=Struct( 'unknown' / GreedyBytes, diff --git a/pymidi/protocol.py b/pymidi/protocol.py index 4b15ad4..a9f44bb 100644 --- a/pymidi/protocol.py +++ b/pymidi/protocol.py @@ -1,9 +1,9 @@ import logging import random -import time from pymidi import packets from pymidi.utils import b2h +from pymidi.utils import get_timestamp from construct import ConstructError # Command messages are preceded with this sequence. @@ -14,6 +14,7 @@ APPLEMIDI_COMMAND_INVITATION_ACCEPTED = b'OK' APPLEMIDI_COMMAND_INVITATION_REJECTED = b'NO' APPLEMIDI_COMMAND_TIMESTAMP_SYNC = b'CK' +APPLEMIDI_COMMAND_JOURNAL_SYNCHRONIZATION = b'RS' APPLEMIDI_COMMAND_EXIT = b'BY' @@ -105,10 +106,14 @@ def handle_command_message(self, command, data, addr): return peer = self._disconnect_peer(ssrc) self.logger.info('Peer {} exited'.format(peer)) + elif command == APPLEMIDI_COMMAND_JOURNAL_SYNCHRONIZATION: + # + # To be implemented + # + self.logger.warning('Ignoring unsupported command (journal sync): {}'.format(command)) else: self.logger.warning('Ignoring unrecognized command: {}'.format(command)) - class ControlProtocol(BaseProtocol): def __init__(self, data_protocol=None, *args, **kwargs): super(ControlProtocol, self).__init__(*args, **kwargs) @@ -153,7 +158,6 @@ def handle_timestamp(self, data, addr): if self.logger.isEnabledFor(logging.DEBUG): self.logger.debug(packet) - now = int(time.time() * 10000) # units of 100 microseconds if packet.count == 0: response = packets.AppleMIDITimestampPacket.build( dict( @@ -161,7 +165,7 @@ def handle_timestamp(self, data, addr): count=1, ssrc=self.ssrc, timestamp_1=packet.timestamp_1, - timestamp_2=now, + timestamp_2=get_timestamp(), timestamp_3=0, ) ) @@ -169,3 +173,6 @@ def handle_timestamp(self, data, addr): elif packet.count == 2: offset_estimate = ((packet.timestamp_3 + packet.timestamp_1) / 2) - packet.timestamp_2 self.logger.debug('offset estimate: {}'.format(offset_estimate)) + + latency = (packet.timestamp_3-packet.timestamp_1)/10 + self.logger.info('Peer {} latency: {}ms'.format(self.peers_by_ssrc[packet.ssrc].name,latency)) diff --git a/pymidi/server.py b/pymidi/server.py index e3fcb18..d65a950 100644 --- a/pymidi/server.py +++ b/pymidi/server.py @@ -25,7 +25,7 @@ def on_peer_connected(self, peer): def on_peer_disconnected(self, peer): pass - def on_midi_commands(self, peer, command_list): + def on_midi_commands(self, peer, midi_packet): pass @@ -75,9 +75,8 @@ def _peer_disconnected_cb(self, peer): handler.on_peer_disconnected(peer) def _midi_command_cb(self, peer, midi_packet): - commands = midi_packet.command.midi_list for handler in self.handlers: - handler.on_midi_commands(peer, commands) + handler.on_midi_commands(peer, midi_packet) def _build_control_protocol(self, host, port, family): logger.info('Control socket on {}:{}'.format(host, port)) diff --git a/pymidi/utils.py b/pymidi/utils.py index d9a50b7..37a0fa4 100644 --- a/pymidi/utils.py +++ b/pymidi/utils.py @@ -1,5 +1,6 @@ import codecs import socket +import time from six import string_types from builtins import bytes @@ -51,3 +52,7 @@ def validate_addr(addr): raise ValueError('First param of address {} is not a valid ip'.format(repr(addr))) if not isinstance(addr[1], int): raise ValueError('Second param of address {} is not an int'.format(repr(addr))) + + +def get_timestamp(): + return int((time.time() * 10000) % 0x4000000) # RTP header timestamp is 32 bits