Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Minor changes and additions #29

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/example_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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...')
Expand Down
4 changes: 2 additions & 2 deletions examples/example_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 43 additions & 33 deletions pymidi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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':
Expand All @@ -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,
)
Expand Down Expand Up @@ -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': {
Expand All @@ -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:
Expand Down
19 changes: 18 additions & 1 deletion pymidi/packets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -88,7 +100,7 @@ def create(self, **kwargs):

RTPHeader = Struct(
'flags' / MIDIPacketHeaderFlags,
'sequence_number' / Int16ub, # always 'K'
'sequence_number' / Int16ub,
)

MIDIPacketHeader = Struct(
Expand Down Expand Up @@ -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)),
Expand All @@ -293,6 +306,10 @@ def create(self, **kwargs):
'controller' / Int8ub,
'value' / Int8ub,
),
'pitch_bend_change': Struct(
'lsb' / Int8ub,
'msb' / Int8ub,
),
},
default=Struct(
'unknown' / GreedyBytes,
Expand Down
15 changes: 11 additions & 4 deletions pymidi/protocol.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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'


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -153,19 +158,21 @@ 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(
command=APPLEMIDI_COMMAND_TIMESTAMP_SYNC,
count=1,
ssrc=self.ssrc,
timestamp_1=packet.timestamp_1,
timestamp_2=now,
timestamp_2=get_timestamp(),
timestamp_3=0,
)
)
self.sendto(response, 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))
5 changes: 2 additions & 3 deletions pymidi/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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))
Expand Down
5 changes: 5 additions & 0 deletions pymidi/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import codecs
import socket
import time
from six import string_types
from builtins import bytes

Expand Down Expand Up @@ -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