diff --git a/.travis.yml b/.travis.yml index c60d5ad..87d4926 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,8 @@ # See https://github.com/adafruit/circuitpython-build-tools for detailed setup # instructions. +# CUSTOMISED to run tests +# dist: xenial language: python python: @@ -42,7 +44,9 @@ install: - pip install --force-reinstall pylint==1.9.2 script: - - pylint adafruit_midi.py + - ([[ -d "tests" ]] && py.test) + - pylint adafruit_midi/*.py + - ([[ -d "tests" ]] && pylint --disable=missing-docstring,invalid-name,bad-whitespace,trailing-whitespace,line-too-long,wrong-import-position,unused-import tests/*.py) - ([[ ! -d "examples" ]] || pylint --disable=missing-docstring,invalid-name,bad-whitespace examples/*.py) - circuitpython-build-bundles --filename_prefix adafruit-circuitpython-midi --library_location . - cd docs && sphinx-build -E -W -b html . _build/html && cd .. diff --git a/README.rst b/README.rst index e1c9af3..0ce3d20 100644 --- a/README.rst +++ b/README.rst @@ -58,21 +58,21 @@ Usage Example import time import random + import usb_midi import adafruit_midi - midi = adafruit_midi.MIDI(out_channel=0) + midi = adafruit_midi.MIDI(midi_out=usb_midi.ports[1], out_channel=0) print("Midi test") - print("Default output channel:", midi.out_channel) - print("Listening on input channel:", midi.in_channel) + print("Default output MIDI channel:", midi.out_channel + 1) while True: - midi.note_on(44, 120) - midi.note_off(44, 120) - midi.control_change(3, 44) - midi.pitch_bend(random.randint(0,16383)) - time.sleep(1) + midi.note_on(44, 120) + midi.note_off(44, 120) + midi.control_change(3, 44) + midi.pitch_bend(random.randint(0,16383)) + time.sleep(1) Contributing diff --git a/adafruit_midi.py b/adafruit_midi.py deleted file mode 100644 index d08cf8a..0000000 --- a/adafruit_midi.py +++ /dev/null @@ -1,143 +0,0 @@ -# The MIT License (MIT) -# -# Copyright (c) 2019 Limor Fried for Adafruit Industries -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -# THE SOFTWARE. -""" -`adafruit_midi` -================================================================================ - -A CircuitPython helper for encoding/decoding MIDI packets over a MIDI or UART connection. - - -* Author(s): Limor Fried - -Implementation Notes --------------------- - -**Hardware:** - - - -**Software and Dependencies:** - -* Adafruit CircuitPython firmware for the supported boards: - https://github.com/adafruit/circuitpython/releases - -""" - -import usb_midi - -__version__ = "0.0.0-auto.0" -__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI.git" - -class MIDI: - """MIDI helper class.""" - - NOTE_ON = 0x90 - NOTE_OFF = 0x80 - PITCH_BEND = 0xE0 - CONTROL_CHANGE = 0xB0 - - def __init__(self, midi_in=usb_midi.ports[0], midi_out=usb_midi.ports[1], *, in_channel=None, - out_channel=0, debug=False): - self._midi_in = midi_in - self._midi_out = midi_out - self._in_channel = in_channel - self._out_channel = out_channel - self._debug = debug - self._outbuf = bytearray(4) - - @property - def in_channel(self): - """The incoming MIDI channel. Must be 0-15. Correlates to MIDI channels 1-16, e.g. - ``in_channel(3)`` will listen on MIDI channel 4. Default is 0.""" - return self._in_channel - - @in_channel.setter - def in_channel(self, channel): - if channel is not None and not 0 <= channel <= 15: - raise RuntimeError("Invalid input channel") - self._in_channel = channel - - @property - def out_channel(self): - """The outgoing MIDI channel. Must be 0-15. Correlates to MIDI channels 1-16, e.g. - ``out_channel(3)`` will send to MIDI channel 4. Default is 0.""" - return self._out_channel - - @out_channel.setter - def out_channel(self, channel): - if not 0 <= channel <= 15: - raise RuntimeError("Invalid output channel") - self._out_channel = channel - - def note_on(self, note, vel, channel=None): - """Sends a MIDI Note On message. - - :param int note: The note number. Must be 0-127. - :param int vel: The note velocity. Must be 0-127. - - """ - self._generic_3(self.NOTE_ON, note, vel, channel) - - def note_off(self, note, vel, channel=None): - """Sends a MIDI Note Off message. - - :param int note: The note number. Must be 0-127. - :param int vel: The note velocity. Must be 0-127. - - """ - self._generic_3(self.NOTE_OFF, note, vel, channel) - - def pitch_bend(self, value, channel=None): - """Send a MIDI Pitch Wheel message. - - :param int value: Range is 0-16383. A ``value`` of 8192 equates to no pitch bend, a value - of less than 8192 equates to a negative pitch bend, and a value of more - than 8192 equates to a positive pitch bend. - - """ - self._generic_3(self.PITCH_BEND, value & 0x7F, value >> 7, channel) - - def control_change(self, control, value, channel=None): - """Sends a MIDI CC message. - - :param int control: The controller number. Must be 0-127. - :param int value: The control value. Must be 0-127. - - """ - self._generic_3(self.CONTROL_CHANGE, control, value, channel) - - def _generic_3(self, cmd, arg1, arg2, channel=None): - if not 0 <= arg1 <= 0x7F: - raise RuntimeError("Argument 1 value %d invalid" % arg1) - if not 0 <= arg2 <= 0x7F: - raise RuntimeError("Argument 2 value %d invalid" % arg2) - if not channel: - channel = self._out_channel - self._outbuf[0] = (cmd & 0xF0) | channel - self._outbuf[1] = arg1 - self._outbuf[2] = arg2 - self._send(self._outbuf, 3) - - def _send(self, packet, num): - if self._debug: - print("Sending: ", [hex(i) for i in packet[:num]]) - self._midi_out.write(packet, num) diff --git a/adafruit_midi/__init__.py b/adafruit_midi/__init__.py new file mode 100644 index 0000000..cd421c5 --- /dev/null +++ b/adafruit_midi/__init__.py @@ -0,0 +1,228 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Limor Fried for Adafruit Industries, Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_midi` +================================================================================ + +A CircuitPython helper for encoding/decoding MIDI packets over a MIDI or UART connection. + + +* Author(s): Limor Fried, Kevin J. Walters + +Implementation Notes +-------------------- + +**Hardware:** + + + +**Software and Dependencies:** + +* Adafruit CircuitPython firmware for the supported boards: + https://github.com/adafruit/circuitpython/releases + +""" + +from .midi_message import MIDIMessage, ALL_CHANNELS + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI.git" + + +class MIDI: + """MIDI helper class. ``midi_in`` or ``midi_out`` *must* be set or both together. + + :param midi_in: an object which implements ``read(length)``, + set to ``usb_midi.ports[0]`` for USB MIDI, default None. + :param midi_out: an object which implements ``write(buffer, length)``, + set to ``usb_midi.ports[1]`` for USB MIDI, default None. + :param in_channel: The input channel(s). + This is used by ``receive`` to filter data. + This can either be an ``int`` for the wire protocol channel number (0-15) + a tuple of ``int`` to listen for multiple channels or ``"ALL"``. + Defaults to None. + :param int out_channel: The wire protocol output channel number (0-15) + used by ``send`` if no channel is specified, + defaults to 0 (MIDI Channel 1). + :param int in_buf_size: Maximum size of input buffer in bytes, default 30. + :param bool debug: Debug mode, default False. + + """ + + NOTE_ON = 0x90 + NOTE_OFF = 0x80 + PITCH_BEND = 0xE0 + CONTROL_CHANGE = 0xB0 + + def __init__(self, midi_in=None, midi_out=None, *, + in_channel=None, out_channel=0, in_buf_size=30, debug=False): + if midi_in is None and midi_out is None: + raise ValueError("No midi_in or midi_out provided") + self._midi_in = midi_in + self._midi_out = midi_out + self._in_channel = in_channel # dealing with pylint inadequacy + self.in_channel = in_channel + self._out_channel = out_channel # dealing with pylint inadequacy + self.out_channel = out_channel + self._debug = debug + # This input buffer holds what has been read from midi_in + self._in_buf = bytearray(0) + self._in_buf_size = in_buf_size + self._outbuf = bytearray(4) + self._skipped_bytes = 0 + + @property + def in_channel(self): + """The incoming MIDI channel. Must be 0-15. Correlates to MIDI channels 1-16, e.g. + ``in_channel = 3`` will listen on MIDI channel 4. + Can also listen on multiple channels, e.g. ``in_channel = (0,1,2)`` + will listen on MIDI channels 1-3 or ``in_channel = "ALL"`` for every channel. + Default is None.""" + return self._in_channel + + @in_channel.setter + def in_channel(self, channel): + if channel is None or (isinstance(channel, int) and 0 <= channel <= 15): + self._in_channel = channel + elif isinstance(channel, str) and channel == "ALL": + self._in_channel = ALL_CHANNELS + elif isinstance(channel, tuple) and all(0 <= c <= 15 for c in channel): + self._in_channel = channel + else: + raise RuntimeError("Invalid input channel") + + @property + def out_channel(self): + """The outgoing MIDI channel. Must be 0-15. Correlates to MIDI channels 1-16, e.g. + ``out_channel = 3`` will send to MIDI channel 4. Default is 0 (MIDI channel 1).""" + return self._out_channel + + @out_channel.setter + def out_channel(self, channel): + if not 0 <= channel <= 15: + raise RuntimeError("Invalid output channel") + self._out_channel = channel + + def receive(self): + """Read messages from MIDI port, store them in internal read buffer, then parse that data + and return the first MIDI message (event). + This maintains the blocking characteristics of the midi_in port. + + :returns MIDIMessage object: Returns object or None for nothing. + """ + ### could check _midi_in is an object OR correct object OR correct interface here? + # If the buffer here is not full then read as much as we can fit from + # the input port + if len(self._in_buf) < self._in_buf_size: + bytes_in = self._midi_in.read(self._in_buf_size - len(self._in_buf)) + if bytes_in: + if self._debug: + print("Receiving: ", [hex(i) for i in bytes_in]) + self._in_buf.extend(bytes_in) + del bytes_in + + (msg, endplusone, + skipped) = MIDIMessage.from_message_bytes(self._in_buf, self._in_channel) + if endplusone != 0: + # This is not particularly efficient as it's copying most of bytearray + # and deleting old one + self._in_buf = self._in_buf[endplusone:] + + self._skipped_bytes += skipped + + # msg could still be None at this point, e.g. in middle of monster SysEx + return msg + + def send(self, msg, channel=None): + """Sends a MIDI message. + + :param msg: Either a MIDIMessage object or a sequence (list) of MIDIMessage objects. + The channel property will be *updated* as a side-effect of sending message(s). + :param int channel: Channel number, if not set the ``out_channel`` will be used. + + """ + if channel is None: + channel = self.out_channel + if isinstance(msg, MIDIMessage): + msg.channel = channel + data = msg.__bytes__() # bytes(object) does not work in uPy + else: + data = bytearray() + for each_msg in msg: + each_msg.channel = channel + data.extend(each_msg.__bytes__()) + + self._send(data, len(data)) + + def note_on(self, note, vel, channel=None): + """Sends a MIDI Note On message. + + :param int note: The note number. Must be 0-127. + :param int vel: The note velocity. Must be 0-127. + + """ + self._generic_3(self.NOTE_ON, note, vel, channel) + + def note_off(self, note, vel, channel=None): + """Sends a MIDI Note Off message. + + :param int note: The note number. Must be 0-127. + :param int vel: The note velocity. Must be 0-127. + + """ + self._generic_3(self.NOTE_OFF, note, vel, channel) + + def pitch_bend(self, value, channel=None): + """Send a MIDI Pitch Wheel message. + + :param int value: Range is 0-16383. A ``value`` of 8192 equates to no pitch bend, a value + of less than 8192 equates to a negative pitch bend, and a value of more + than 8192 equates to a positive pitch bend. + + """ + self._generic_3(self.PITCH_BEND, value & 0x7F, value >> 7, channel) + + def control_change(self, control, value, channel=None): + """Sends a MIDI CC message. + + :param int control: The controller number. Must be 0-127. + :param int value: The control value. Must be 0-127. + + """ + self._generic_3(self.CONTROL_CHANGE, control, value, channel) + + def _generic_3(self, cmd, arg1, arg2, channel=None): + if not 0 <= arg1 <= 0x7F: + raise RuntimeError("Argument 1 value %d invalid" % arg1) + if not 0 <= arg2 <= 0x7F: + raise RuntimeError("Argument 2 value %d invalid" % arg2) + if channel is None: + channel = self._out_channel + self._outbuf[0] = (cmd & 0xF0) | (channel & 0x0f) + self._outbuf[1] = arg1 + self._outbuf[2] = arg2 + self._send(self._outbuf, 3) + + def _send(self, packet, num): + if self._debug: + print("Sending: ", [hex(i) for i in packet[:num]]) + self._midi_out.write(packet, num) diff --git a/adafruit_midi/channel_pressure.py b/adafruit_midi/channel_pressure.py new file mode 100644 index 0000000..927e987 --- /dev/null +++ b/adafruit_midi/channel_pressure.py @@ -0,0 +1,66 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_midi.channel_pressure` +================================================================================ + +Channel Pressure MIDI message. + + +* Author(s): Kevin J. Walters + +Implementation Notes +-------------------- + +""" + +from .midi_message import MIDIMessage + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI.git" + + +class ChannelPressure(MIDIMessage): + """Channel Pressure MIDI message. + + :param int pressure: The pressure, 0-127. + """ + + _STATUS = 0xd0 + _STATUSMASK = 0xf0 + LENGTH = 2 + + def __init__(self, pressure, *, channel=None): + self.pressure = pressure + super().__init__(channel=channel) + if not 0 <= self.pressure <= 127: + raise self._EX_VALUEERROR_OOR + + def __bytes__(self): + return bytes([self._STATUS | (self.channel & self.CHANNELMASK), + self.pressure]) + + @classmethod + def from_bytes(cls, msg_bytes): + return cls(msg_bytes[1], channel=msg_bytes[0] & cls.CHANNELMASK) + +ChannelPressure.register_message_type() diff --git a/adafruit_midi/control_change.py b/adafruit_midi/control_change.py new file mode 100644 index 0000000..3c42d0d --- /dev/null +++ b/adafruit_midi/control_change.py @@ -0,0 +1,70 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_midi.control_change` +================================================================================ + +Control Change MIDI message. + + +* Author(s): Kevin J. Walters + +Implementation Notes +-------------------- + +""" + +from .midi_message import MIDIMessage + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI.git" + + +class ControlChange(MIDIMessage): + """Control Change MIDI message. + + :param int control: The control number, 0-127. + :param int value: The 7bit value of the control, 0-127. + + """ + + _STATUS = 0xb0 + _STATUSMASK = 0xf0 + LENGTH = 3 + + def __init__(self, control, value, *, channel=None): + self.control = control + self.value = value + super().__init__(channel=channel) + if not 0 <= self.control <= 127 or not 0 <= self.value <= 127: + raise self._EX_VALUEERROR_OOR + + def __bytes__(self): + return bytes([self._STATUS | (self.channel & self.CHANNELMASK), + self.control, self.value]) + + @classmethod + def from_bytes(cls, msg_bytes): + return cls(msg_bytes[1], msg_bytes[2], + channel=msg_bytes[0] & cls.CHANNELMASK) + +ControlChange.register_message_type() diff --git a/adafruit_midi/midi_message.py b/adafruit_midi/midi_message.py new file mode 100644 index 0000000..3fbe8fd --- /dev/null +++ b/adafruit_midi/midi_message.py @@ -0,0 +1,339 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_midi.midi_message` +================================================================================ + +An abstract class for objects which represent MIDI messages (events). +When individual messages are imported they register themselves with +:func:register_message_type which makes them recognised +by the parser, :func:from_message_bytes. + +Large messages like :class:SystemExclusive can only be parsed if they fit +within the input buffer in :class:MIDI. + + +* Author(s): Kevin J. Walters + +Implementation Notes +-------------------- + +""" + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI.git" + +# This is a special channel value outside of wire protocol range used to +# represent all of the sixteen channels +ALL_CHANNELS = -1 + +# From C3 - A and B are above G +# Semitones A B C D E F G +NOTE_OFFSET = [21, 23, 12, 14, 16, 17, 19] + +# pylint: disable=no-else-return +def channel_filter(channel, channel_spec): + """ + Utility function to return True iff the given channel matches channel_spec. + """ + if isinstance(channel_spec, int): + if channel_spec == ALL_CHANNELS: + return True + else: + return channel == channel_spec + elif isinstance(channel_spec, tuple): + return channel in channel_spec + else: + raise ValueError("Incorrect type for channel_spec") + + +def note_parser(note): + """If note is a string then it will be parsed and converted to a MIDI note (key) number, e.g. + "C4" will return 60, "C#4" will return 61. If note is not a string it will simply be returned. + + :param note: Either 0-127 int or a str representing the note, e.g. "C#4" + """ + midi_note = note + if isinstance(note, str): + if len(note) < 2: + raise ValueError("Bad note format") + noteidx = ord(note[0].upper()) - 65 # 65 os ord('A') + if not 0 <= noteidx <= 6: + raise ValueError("Bad note") + sharpen = 0 + if note[1] == '#': + sharpen = 1 + elif note[1] == 'b': + sharpen = -1 + # int may throw exception here + midi_note = (int(note[1 + abs(sharpen):]) * 12 + + NOTE_OFFSET[noteidx] + + sharpen) + + return midi_note + + +class MIDIMessage: + """ + The parent class for MIDI messages. + + Class variables: + + * ``_STATUS`` - extracted from status byte with channel replaced by 0s + (high bit is always set by convention). + * ``_STATUSMASK`` - mask used to compared a status byte with ``_STATUS`` value. + * ``LENGTH`` - length for a fixed size message *including* status + or -1 for variable length. + * ``CHANNELMASK`` - mask used to apply a (wire protocol) channel number. + * ``ENDSTATUS`` - the end of message status byte, only set for variable length. + + This is an *abstract* class. + """ + _STATUS = None + _STATUSMASK = None + LENGTH = None + CHANNELMASK = 0x0f + ENDSTATUS = None + + # Commonly used exceptions to save memory + _EX_VALUEERROR_OOR = ValueError("Out of range") + + # Each element is ((status, mask), class) + # order is more specific masks first + _statusandmask_to_class = [] + + def __init__(self, *, channel=None): + self._channel = channel # dealing with pylint inadequacy + self.channel = channel + + @property + def channel(self): + """The channel number of the MIDI message where appropriate. + This is *updated* by MIDI.send() method. + """ + return self._channel + + @channel.setter + def channel(self, channel): + if channel is not None and not 0 <= channel <= 15: + raise "Channel must be 0-15 or None" + self._channel = channel + + @classmethod + def register_message_type(cls): + """Register a new message by its status value and mask. + This is called automagically at ``import`` time for each message. + """ + ### These must be inserted with more specific masks first + insert_idx = len(MIDIMessage._statusandmask_to_class) + for idx, m_type in enumerate(MIDIMessage._statusandmask_to_class): + if cls._STATUSMASK > m_type[0][1]: + insert_idx = idx + break + + MIDIMessage._statusandmask_to_class.insert(insert_idx, + ((cls._STATUS, cls._STATUSMASK), cls)) + + + # pylint: disable=too-many-arguments + @classmethod + def _search_eom_status(cls, buf, eom_status, msgstartidx, msgendidxplusone, endidx): + good_termination = False + bad_termination = False + + msgendidxplusone = msgstartidx + 1 + while msgendidxplusone <= endidx: + # Look for a status byte + # Second rule of the MIDI club is status bytes have MSB set + if buf[msgendidxplusone] & 0x80: + # pylint: disable=simplifiable-if-statement + if buf[msgendidxplusone] == eom_status: + good_termination = True + else: + bad_termination = True + break + else: + msgendidxplusone += 1 + + if good_termination or bad_termination: + msgendidxplusone += 1 + + return (msgendidxplusone, good_termination, bad_termination) + + @classmethod + def _match_message_status(cls, buf, msgstartidx, msgendidxplusone, endidx): + msgclass = None + status = buf[msgstartidx] + known_msg = False + complete_msg = False + bad_termination = False + + # Rummage through our list looking for a status match + for status_mask, msgclass in MIDIMessage._statusandmask_to_class: + masked_status = status & status_mask[1] + if status_mask[0] == masked_status: + known_msg = True + # Check there's enough left to parse a complete message + # this value can be changed later for a var. length msgs + complete_msg = len(buf) - msgstartidx >= msgclass.LENGTH + if not complete_msg: + break + + if msgclass.LENGTH < 0: # indicator of variable length message + (msgendidxplusone, + terminated_msg, + bad_termination) = cls._search_eom_status(buf, + msgclass.ENDSTATUS, + msgstartidx, + msgendidxplusone, + endidx) + if not terminated_msg: + complete_msg = False + else: # fixed length message + msgendidxplusone = msgstartidx + msgclass.LENGTH + break + + return (msgclass, status, + known_msg, complete_msg, bad_termination, + msgendidxplusone) + + # pylint: disable=too-many-locals,too-many-branches + @classmethod + def from_message_bytes(cls, midibytes, channel_in): + """Create an appropriate object of the correct class for the + first message found in some MIDI bytes filtered by channel_in. + + Returns (messageobject, endplusone, skipped) + or for no messages, partial messages or messages for other channels + (None, endplusone, skipped). + """ + endidx = len(midibytes) - 1 + skipped = 0 + preamble = True + + msgstartidx = 0 + msgendidxplusone = 0 + while True: + msg = None + # Look for a status byte + # Second rule of the MIDI club is status bytes have MSB set + while msgstartidx <= endidx and not midibytes[msgstartidx] & 0x80: + msgstartidx += 1 + if preamble: + skipped += 1 + preamble = False + + # Either no message or a partial one + if msgstartidx > endidx: + return (None, endidx + 1, skipped) + + # Try and match the status byte found in midibytes + (msgclass, + status, + known_message, + complete_message, + bad_termination, + msgendidxplusone) = cls._match_message_status(midibytes, + msgstartidx, + msgendidxplusone, + endidx) + channel_match_orna = True + if complete_message and not bad_termination: + try: + msg = msgclass.from_bytes(midibytes[msgstartidx:msgendidxplusone]) + if msg.channel is not None: + channel_match_orna = channel_filter(msg.channel, channel_in) + + except(ValueError, TypeError) as ex: + msg = MIDIBadEvent(midibytes[msgstartidx:msgendidxplusone], ex) + + # break out of while loop for a complete message on good channel + # or we have one we do not know about + if known_message: + if complete_message: + if channel_match_orna: + break + else: # advance to next message + msgstartidx = msgendidxplusone + else: + # Important case of a known message but one that is not + # yet complete - leave bytes in buffer and wait for more + break + else: + msg = MIDIUnknownEvent(status) + # length cannot be known + # next read will skip past leftover data bytes + msgendidxplusone = msgstartidx + 1 + break + + return (msg, msgendidxplusone, skipped) + + # A default method for constructing wire messages with no data. + # Returns an (immutable) bytes with just the status code in. + def __bytes__(self): + """Return the ``bytes`` wire protocol representation of the object + with channel number applied where appropriate.""" + return bytes([self._STATUS]) + + # databytes value present to keep interface uniform but unused + # A default method for constructing message objects with no data. + # Returns the new object. + # pylint: disable=unused-argument + @classmethod + def from_bytes(cls, msg_bytes): + """Creates an object from the byte stream of the wire protocol + representation of the MIDI message.""" + return cls() + + +# DO NOT try to register these messages +class MIDIUnknownEvent(MIDIMessage): + """An unknown MIDI message. + + :param int status: The MIDI status number. + + This can either occur because there is no class representing the message + or because it is not imported. + """ + LENGTH = -1 + + def __init__(self, status): + self.status = status + super().__init__() + + +class MIDIBadEvent(MIDIMessage): + """A bad MIDI message, one that could not be parsed/constructed. + + :param list data: The MIDI status including any embedded channel number + and associated subsequent data bytes. + :param Exception exception: The exception used to store the repr() text representation. + + This could be due to status bytes appearing where data bytes are expected. + The channel property will not be set. + """ + LENGTH = -1 + + def __init__(self, msg_bytes, exception): + self.data = bytes(msg_bytes) + self.exception_text = repr(exception) + super().__init__() diff --git a/adafruit_midi/note_off.py b/adafruit_midi/note_off.py new file mode 100644 index 0000000..3e8382d --- /dev/null +++ b/adafruit_midi/note_off.py @@ -0,0 +1,71 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_midi.note_off` +================================================================================ + +Note Off Change MIDI message. + + +* Author(s): Kevin J. Walters + +Implementation Notes +-------------------- + +""" + +from .midi_message import MIDIMessage, note_parser + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI.git" + + +class NoteOff(MIDIMessage): + """Note Off Change MIDI message. + + :param note: The note (key) number either as an ``int`` (0-127) or a + ``str`` which is parsed, e.g. "C4" (middle C) is 60, "A4" is 69. + :param int velocity: The release velocity, 0-127, defaults to 0. + + """ + + _STATUS = 0x80 + _STATUSMASK = 0xf0 + LENGTH = 3 + + def __init__(self, note, velocity=0, *, channel=None): + self.note = note_parser(note) + self.velocity = velocity + super().__init__(channel=channel) + if not 0 <= self.note <= 127 or not 0 <= self.velocity <= 127: + raise self._EX_VALUEERROR_OOR + + def __bytes__(self): + return bytes([self._STATUS | (self.channel & self.CHANNELMASK), + self.note, self.velocity]) + + @classmethod + def from_bytes(cls, msg_bytes): + return cls(msg_bytes[1], msg_bytes[2], + channel=msg_bytes[0] & cls.CHANNELMASK) + +NoteOff.register_message_type() diff --git a/adafruit_midi/note_on.py b/adafruit_midi/note_on.py new file mode 100644 index 0000000..d0ef9fe --- /dev/null +++ b/adafruit_midi/note_on.py @@ -0,0 +1,71 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_midi.note_on` +================================================================================ + +Note On Change MIDI message. + + +* Author(s): Kevin J. Walters + +Implementation Notes +-------------------- + +""" + +from .midi_message import MIDIMessage, note_parser + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI.git" + + +class NoteOn(MIDIMessage): + """Note On Change MIDI message. + + :param note: The note (key) number either as an ``int`` (0-127) or a + ``str`` which is parsed, e.g. "C4" (middle C) is 60, "A4" is 69. + :param int velocity: The strike velocity, 0-127, 0 is equivalent + to a Note Off, defaults to 127. + """ + + _STATUS = 0x90 + _STATUSMASK = 0xf0 + LENGTH = 3 + + def __init__(self, note, velocity=127, *, channel=None): + self.note = note_parser(note) + self.velocity = velocity + super().__init__(channel=channel) + if not 0 <= self.note <= 127 or not 0 <= self.velocity <= 127: + raise self._EX_VALUEERROR_OOR + + def __bytes__(self): + return bytes([self._STATUS | (self.channel & self.CHANNELMASK), + self.note, self.velocity]) + + @classmethod + def from_bytes(cls, msg_bytes): + return cls(msg_bytes[1], msg_bytes[2], + channel=msg_bytes[0] & cls.CHANNELMASK) + +NoteOn.register_message_type() diff --git a/adafruit_midi/pitch_bend.py b/adafruit_midi/pitch_bend.py new file mode 100644 index 0000000..ea85af6 --- /dev/null +++ b/adafruit_midi/pitch_bend.py @@ -0,0 +1,69 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_midi.pitch_bend` +================================================================================ + +Pitch Bend Change MIDI message. + + +* Author(s): Kevin J. Walters + +Implementation Notes +-------------------- + +""" + +from .midi_message import MIDIMessage + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI.git" + + +class PitchBend(MIDIMessage): + """Pitch Bend Change MIDI message. + + :param int pitch_bend: A 14bit unsigned int representing the degree of + bend from 0 through 8192 (midpoint, no bend) to 16383. + """ + + _STATUS = 0xe0 + _STATUSMASK = 0xf0 + LENGTH = 3 + + def __init__(self, pitch_bend, *, channel=None): + self.pitch_bend = pitch_bend + super().__init__(channel=channel) + if not 0 <= self.pitch_bend <= 16383: + raise self._EX_VALUEERROR_OOR + + def __bytes__(self): + return bytes([self._STATUS | (self.channel & self.CHANNELMASK), + self.pitch_bend & 0x7f, + (self.pitch_bend >> 7) & 0x7f]) + + @classmethod + def from_bytes(cls, msg_bytes): + return cls(msg_bytes[2] << 7 | msg_bytes[1], + channel=msg_bytes[0] & cls.CHANNELMASK) + +PitchBend.register_message_type() diff --git a/adafruit_midi/polyphonic_key_pressure.py b/adafruit_midi/polyphonic_key_pressure.py new file mode 100644 index 0000000..6519ae2 --- /dev/null +++ b/adafruit_midi/polyphonic_key_pressure.py @@ -0,0 +1,70 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_midi.polyphonic_key_pressure` +================================================================================ + +Polyphonic Key Pressure MIDI message. + + +* Author(s): Kevin J. Walters + +Implementation Notes +-------------------- + +""" + +from .midi_message import MIDIMessage, note_parser + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI.git" + + +class PolyphonicKeyPressure(MIDIMessage): + """Polyphonic Key Pressure MIDI message. + + :param note: The note (key) number either as an ``int`` (0-127) or a + ``str`` which is parsed, e.g. "C4" (middle C) is 60, "A4" is 69. + :param int pressure: The pressure, 0-127. + """ + + _STATUS = 0xa0 + _STATUSMASK = 0xf0 + LENGTH = 3 + + def __init__(self, note, pressure, *, channel=None): + self.note = note_parser(note) + self.pressure = pressure + super().__init__(channel=channel) + if not 0 <= self.note <= 127 or not 0 <= self.pressure <= 127: + raise self._EX_VALUEERROR_OOR + + def __bytes__(self): + return bytes([self._STATUS | (self.channel & self.CHANNELMASK), + self.note, self.pressure]) + + @classmethod + def from_bytes(cls, msg_bytes): + return cls(msg_bytes[1], msg_bytes[2], + channel=msg_bytes[0] & cls.CHANNELMASK) + +PolyphonicKeyPressure.register_message_type() diff --git a/adafruit_midi/program_change.py b/adafruit_midi/program_change.py new file mode 100644 index 0000000..5e5d078 --- /dev/null +++ b/adafruit_midi/program_change.py @@ -0,0 +1,66 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_midi.program_change` +================================================================================ + +Program Change MIDI message. + + +* Author(s): Kevin J. Walters + +Implementation Notes +-------------------- + +""" + +from .midi_message import MIDIMessage + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI.git" + + +class ProgramChange(MIDIMessage): + """Program Change MIDI message. + + :param int patch: The new program/patch number to use, 0-127. + """ + + _STATUS = 0xc0 + _STATUSMASK = 0xf0 + LENGTH = 2 + + def __init__(self, patch, *, channel=None): + self.patch = patch + super().__init__(channel=channel) + if not 0 <= self.patch <= 127: + raise self._EX_VALUEERROR_OOR + + def __bytes__(self): + return bytes([self._STATUS | (self.channel & self.CHANNELMASK), + self.patch]) + + @classmethod + def from_bytes(cls, msg_bytes): + return cls(msg_bytes[1], channel=msg_bytes[0] & cls.CHANNELMASK) + +ProgramChange.register_message_type() diff --git a/adafruit_midi/start.py b/adafruit_midi/start.py new file mode 100644 index 0000000..df487c6 --- /dev/null +++ b/adafruit_midi/start.py @@ -0,0 +1,50 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_midi.start` +================================================================================ + +Start MIDI message. + + +* Author(s): Kevin J. Walters + +Implementation Notes +-------------------- + +""" + +from .midi_message import MIDIMessage + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI.git" + + +class Start(MIDIMessage): + """Start MIDI message. + """ + + _STATUS = 0xfa + _STATUSMASK = 0xff + LENGTH = 1 + +Start.register_message_type() diff --git a/adafruit_midi/stop.py b/adafruit_midi/stop.py new file mode 100644 index 0000000..830a18a --- /dev/null +++ b/adafruit_midi/stop.py @@ -0,0 +1,49 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_midi.stop` +================================================================================ + +Stop MIDI message. + + +* Author(s): Kevin J. Walters + +Implementation Notes +-------------------- + +""" + +from .midi_message import MIDIMessage + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI.git" + + +class Stop(MIDIMessage): + """Stop MIDI message. + """ + _STATUS = 0xfc + _STATUSMASK = 0xff + LENGTH = 1 + +Stop.register_message_type() diff --git a/adafruit_midi/system_exclusive.py b/adafruit_midi/system_exclusive.py new file mode 100644 index 0000000..b77e885 --- /dev/null +++ b/adafruit_midi/system_exclusive.py @@ -0,0 +1,76 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_midi.system_exclusive` +================================================================================ + +System Exclusive MIDI message. + + +* Author(s): Kevin J. Walters + +Implementation Notes +-------------------- + +""" + +from .midi_message import MIDIMessage + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI.git" + + +class SystemExclusive(MIDIMessage): + """System Exclusive MIDI message. + + :param list manufacturer_id: The single byte or three byte + manufacturer's id as a list or bytearray of numbers between 0-127. + :param list data: The 7bit data as a list or bytearray of numbers between 0-127. + + This message can only be parsed if it fits within the input buffer in :class:MIDI. + """ + + _STATUS = 0xf0 + _STATUSMASK = 0xff + LENGTH = -1 + ENDSTATUS = 0xf7 + + def __init__(self, manufacturer_id, data): + self.manufacturer_id = bytes(manufacturer_id) + self.data = bytes(data) + super().__init__() + + def __bytes__(self): + return (bytes([self._STATUS]) + + self.manufacturer_id + + self.data + + bytes([self.ENDSTATUS])) + + @classmethod + def from_bytes(cls, msg_bytes): + # -1 on second arg is to avoid the ENDSTATUS which is passed + if msg_bytes[1] != 0: # pylint: disable=no-else-return + return cls(msg_bytes[1:2], msg_bytes[2:-1]) + else: + return cls(msg_bytes[1:4], msg_bytes[4:-1]) + +SystemExclusive.register_message_type() diff --git a/adafruit_midi/timing_clock.py b/adafruit_midi/timing_clock.py new file mode 100644 index 0000000..f00ff19 --- /dev/null +++ b/adafruit_midi/timing_clock.py @@ -0,0 +1,55 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +`adafruit_midi.timing_clock` +================================================================================ + +Timing Clock MIDI message. + + +* Author(s): Kevin J. Walters + +Implementation Notes +-------------------- + +""" + +from .midi_message import MIDIMessage + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_MIDI.git" + + +# Good to have this registered first as it occurs frequently when present +class TimingClock(MIDIMessage): + """Timing Clock MIDI message. + + This occurs 24 times per quarter note when synchronization is in use. + If this is not needed it's best to avoid this sending this high frequency + message to a CircuitPython device to reduce the amount of message processing. + """ + + _STATUS = 0xf8 + _STATUSMASK = 0xff + LENGTH = 1 + +TimingClock.register_message_type() diff --git a/docs/api.rst b/docs/api.rst index a7e57e4..98b27ca 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6,3 +6,40 @@ .. automodule:: adafruit_midi :members: + +.. automodule:: adafruit_midi.channel_pressure + :members: + +.. automodule:: adafruit_midi.control_change + :members: + +.. automodule:: adafruit_midi.midi_message + :members: + +.. automodule:: adafruit_midi.note_off + :members: + +.. automodule:: adafruit_midi.note_on + :members: + +.. automodule:: adafruit_midi.pitch_bend + :members: + +.. automodule:: adafruit_midi.polyphonic_key_pressure + :members: + +.. automodule:: adafruit_midi.program_change + :members: + +.. automodule:: adafruit_midi.start + :members: + +.. automodule:: adafruit_midi.stop + :members: + +.. automodule:: adafruit_midi.system_exclusive + :members: + +.. automodule:: adafruit_midi.timing_clock + :members: + diff --git a/docs/conf.py b/docs/conf.py index 00224f4..2c71034 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ # Uncomment the below if you use native CircuitPython modules such as # digitalio, micropython and busio. List the modules you use. Without it, the # autodoc module docs will fail to generate with a warning. -autodoc_mock_imports = ["usb_midi"] +autodoc_mock_imports = [] intersphinx_mapping = {'python': ('https://docs.python.org/3.4', None),'CircuitPython': ('https://circuitpython.readthedocs.io/en/latest/', None)} @@ -35,8 +35,8 @@ # General information about the project. project = u'Adafruit MIDI Library' -copyright = u'2019 Ladyada' -author = u'Ladyada' +copyright = u'2019 Ladyada & Kevin J. Walters' +author = u'Ladyada & Kevin J. Walters' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/examples/midi_inoutdemo.py b/examples/midi_inoutdemo.py new file mode 100644 index 0000000..4f78dc7 --- /dev/null +++ b/examples/midi_inoutdemo.py @@ -0,0 +1,61 @@ +# midi_inoutdemo - demonstrates receiving and sending MIDI events + +import usb_midi +import adafruit_midi + +# TimingClock is worth importing first if present as it +# will make parsing more efficient for this high frequency event +# Only importing what is used will save a little bit of memory + +# pylint: disable=unused-import +from adafruit_midi.timing_clock import TimingClock +from adafruit_midi.channel_pressure import ChannelPressure +from adafruit_midi.control_change import ControlChange +from adafruit_midi.note_off import NoteOff +from adafruit_midi.note_on import NoteOn +from adafruit_midi.pitch_bend import PitchBend +from adafruit_midi.polyphonic_key_pressure import PolyphonicKeyPressure +from adafruit_midi.program_change import ProgramChange +from adafruit_midi.start import Start +from adafruit_midi.stop import Stop +from adafruit_midi.system_exclusive import SystemExclusive + +from adafruit_midi.midi_message import MIDIUnknownEvent + +midi = adafruit_midi.MIDI(midi_in=usb_midi.ports[0], + midi_out=usb_midi.ports[1], + in_channel=(1, 2, 3), out_channel=0) + +print("Midi Demo in and out") + +# Convert channel numbers at the presentation layer to the ones musicians use +print("Default output channel:", midi.out_channel + 1) +print("Listening on input channels:", tuple([c + 1 for c in midi.in_channel])) + +major_chord = [0, 4, 7] +while True: + while True: + msg_in = midi.receive() # non-blocking read + # For a Note On or Note Off play a major chord + # For any other known event just forward it + if isinstance(msg_in, NoteOn) and msg_in.velocity != 0: + print("Playing major chord with root", msg_in.note, + "from channel", msg_in.channel + 1) + for offset in major_chord: + new_note = msg_in.note + offset + if 0 <= new_note <= 127: + midi.send(NoteOn(new_note, msg_in.velocity)) + + elif (isinstance(msg_in, NoteOff) + or isinstance(msg_in, NoteOn) and msg_in.velocity == 0): + for offset in major_chord: + new_note = msg_in.note + offset + if 0 <= new_note <= 127: + midi.send(NoteOff(new_note, 0x00)) + + elif isinstance(msg_in, MIDIUnknownEvent): + # Message are only known if they are imported + print("Unknown MIDI event status ", msg_in.status) + + elif msg_in is not None: + midi.send(msg_in) diff --git a/examples/midi_intest1.py b/examples/midi_intest1.py new file mode 100644 index 0000000..86bd971 --- /dev/null +++ b/examples/midi_intest1.py @@ -0,0 +1,33 @@ +import time +import usb_midi +import adafruit_midi + +# A subset of messages/events +# pylint: disable=unused-import +from adafruit_midi.timing_clock import TimingClock +#from adafruit_midi.channel_pressure import ChannelPressure +from adafruit_midi.control_change import ControlChange +from adafruit_midi.note_off import NoteOff +from adafruit_midi.note_on import NoteOn +from adafruit_midi.pitch_bend import PitchBend + + +# 0 is MIDI channel 1 +midi = adafruit_midi.MIDI(midi_in=usb_midi.ports[0], in_channel=0) + +print("Midi input test with pauses") + +# Convert channel numbers at the presentation layer to the ones musicians use +print("Input channel:", midi.in_channel + 1 ) + +# play with the pause to simulate code doing other stuff +# in the loop +pauses = [0] * 10 + [.010] * 10 + [0.100] * 10 + [1.0] * 10 + +while True: + for pause in pauses: + msg = midi.receive() + if msg is not None: + print(time.monotonic(), msg) + if pause: + time.sleep(pause) diff --git a/examples/midi_memorycheck.py b/examples/midi_memorycheck.py new file mode 100644 index 0000000..a86c3f4 --- /dev/null +++ b/examples/midi_memorycheck.py @@ -0,0 +1,47 @@ +# Check memory usage + +# pylint: disable=multiple-statements,unused-import,wrong-import-position,no-member + +# The disable for no-member should not really be required +# probably a difference between Python 3 module and micropython +# +# E: 8,21: Module 'gc' has no 'mem_free' member (no-member) + +import time +import random +import gc +gc.collect() ; print(gc.mem_free()) +import usb_midi +gc.collect() ; print(gc.mem_free()) +import adafruit_midi +gc.collect() ; print(gc.mem_free()) + +# Full monty +from adafruit_midi.channel_pressure import ChannelPressure +gc.collect() ; print(gc.mem_free()) +from adafruit_midi.control_change import ControlChange +gc.collect() ; print(gc.mem_free()) +from adafruit_midi.note_off import NoteOff +gc.collect() ; print(gc.mem_free()) +from adafruit_midi.note_on import NoteOn +gc.collect() ; print(gc.mem_free()) +from adafruit_midi.pitch_bend import PitchBend +gc.collect() ; print(gc.mem_free()) +from adafruit_midi.polyphonic_key_pressure import PolyphonicKeyPressure +gc.collect() ; print(gc.mem_free()) +from adafruit_midi.program_change import ProgramChange +gc.collect() ; print(gc.mem_free()) +from adafruit_midi.start import Start +gc.collect() ; print(gc.mem_free()) +from adafruit_midi.stop import Stop +gc.collect() ; print(gc.mem_free()) +from adafruit_midi.system_exclusive import SystemExclusive +gc.collect() ; print(gc.mem_free()) +from adafruit_midi.timing_clock import TimingClock +gc.collect() ; print(gc.mem_free()) + +midi = adafruit_midi.MIDI(midi_in=usb_midi.ports[0], + midi_out=usb_midi.ports[1], + in_channel=0, out_channel=0) + +gc.collect() ; print(gc.mem_free()) diff --git a/examples/midi_simpletest.py b/examples/midi_simpletest.py index 1dea1c3..c5c3282 100644 --- a/examples/midi_simpletest.py +++ b/examples/midi_simpletest.py @@ -1,13 +1,16 @@ import time import random +import usb_midi import adafruit_midi -midi = adafruit_midi.MIDI(out_channel=0) +midi = adafruit_midi.MIDI(midi_out=usb_midi.ports[1], out_channel=0) print("Midi test") -print("Default output channel:", midi.out_channel) -print("Listening on input channel:", midi.in_channel) +# Convert channel numbers at the presentation layer to the ones musicians use +print("Default output channel:", midi.out_channel + 1) +print("Listening on input channel:", + midi.in_channel + 1 if midi.in_channel is not None else None) while True: midi.note_on(44, 120) diff --git a/examples/midi_simpletest2.py b/examples/midi_simpletest2.py new file mode 100644 index 0000000..a0425d5 --- /dev/null +++ b/examples/midi_simpletest2.py @@ -0,0 +1,37 @@ +# simple_test demonstrating both interfaces +import time +import random +import usb_midi +import adafruit_midi +from adafruit_midi.control_change import ControlChange +from adafruit_midi.note_off import NoteOff +from adafruit_midi.note_on import NoteOn +from adafruit_midi.pitch_bend import PitchBend + +midi = adafruit_midi.MIDI(midi_out=usb_midi.ports[1], out_channel=0) + +print("Midi test") + +# Convert channel numbers at the presentation layer to the ones musicians use +print("Default output channel:", midi.out_channel + 1) +print("Listening on input channel:", + midi.in_channel + 1 if midi.in_channel is not None else None) + +while True: + # method per message interface + midi.note_on(44, 120) + time.sleep(0.25) + midi.pitch_bend(random.randint(0, 16383)) + time.sleep(0.25) + midi.note_off(44, 120) + midi.control_change(3, 44) + time.sleep(0.5) + + # send message(s) interface + midi.send(NoteOn(44, 120)) + time.sleep(0.25) + midi.send(PitchBend(random.randint(0, 16383))) + time.sleep(0.25) + midi.send([NoteOff("G#2", 120), + ControlChange(3, 44)]) + time.sleep(0.5) diff --git a/tests/test_MIDIMessage_unittests.py b/tests/test_MIDIMessage_unittests.py new file mode 100644 index 0000000..0126ead --- /dev/null +++ b/tests/test_MIDIMessage_unittests.py @@ -0,0 +1,376 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import unittest +from unittest.mock import Mock, MagicMock + + +import os +verbose = int(os.getenv('TESTVERBOSE', '2')) + +# adafruit_midi had an import usb_midi +import sys +#sys.modules['usb_midi'] = MagicMock() + +# Borrowing the dhalbert/tannewt technique from adafruit/Adafruit_CircuitPython_Motor +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +# Import before messages - opposite to other test file +import adafruit_midi + +# Full monty +from adafruit_midi.channel_pressure import ChannelPressure +from adafruit_midi.control_change import ControlChange +from adafruit_midi.note_off import NoteOff +from adafruit_midi.note_on import NoteOn +from adafruit_midi.pitch_bend import PitchBend +from adafruit_midi.polyphonic_key_pressure import PolyphonicKeyPressure +from adafruit_midi.program_change import ProgramChange +from adafruit_midi.start import Start +from adafruit_midi.stop import Stop +from adafruit_midi.system_exclusive import SystemExclusive +from adafruit_midi.timing_clock import TimingClock + + +class Test_MIDIMessage_from_message_byte_tests(unittest.TestCase): + def test_NoteOn_basic(self): + data = bytes([0x90, 0x30, 0x7f]) + ichannel = 0 + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + self.assertIsInstance(msg, NoteOn) + self.assertEqual(msg.note, 0x30) + self.assertEqual(msg.velocity, 0x7f) + self.assertEqual(msgendidxplusone, 3) + self.assertEqual(skipped, 0) + self.assertEqual(msg.channel, 0) + + def test_NoteOn_awaitingthirdbyte(self): + data = bytes([0x90, 0x30]) + ichannel = 0 + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + self.assertIsNone(msg) + self.assertEqual(msgendidxplusone, skipped, + "skipped must be 0 as it only indicates bytes before a status byte") + self.assertEqual(msgendidxplusone, 0, + "msgendidxplusone must be 0 as buffer must be lest as is for more data") + self.assertEqual(skipped, 0) + + def test_NoteOn_predatajunk(self): + data = bytes([0x20, 0x64, 0x90, 0x30, 0x32]) + ichannel = 0 + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + self.assertIsInstance(msg, NoteOn) + self.assertEqual(msg.note, 0x30) + self.assertEqual(msg.velocity, 0x32) + self.assertEqual(msgendidxplusone, 5, + "data bytes from partial message and messages are removed" ) + self.assertEqual(skipped, 2) + self.assertEqual(msg.channel, 0) + + def test_NoteOn_prepartialsysex(self): + data = bytes([0x01, 0x02, 0x03, 0x04, 0xf7, 0x90, 0x30, 0x32]) + ichannel = 0 + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + # MIDIMessage parsing could be improved to return something that + # indicates its a truncated end of SysEx + self.assertIsInstance(msg, adafruit_midi.midi_message.MIDIUnknownEvent) + self.assertEqual(msg.status, 0xf7) + self.assertEqual(msgendidxplusone, 5, "removal of the end of the partial SysEx data and terminating status byte") + self.assertEqual(skipped, 4, "skipped only counts data bytes so will be 4 here") + self.assertIsNone(msg.channel) + + data = data[msgendidxplusone:] + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + self.assertIsInstance(msg, NoteOn, + "NoteOn is expected if SystemExclusive is loaded otherwise it would be MIDIUnknownEvent") + self.assertEqual(msg.note, 0x30) + self.assertEqual(msg.velocity, 0x32) + self.assertEqual(msgendidxplusone, 3, "NoteOn message removed") + self.assertEqual(skipped, 0) + self.assertEqual(msg.channel, 0) + + def test_NoteOn_postNoteOn(self): + data = bytes([0x90 | 0x08, 0x30, 0x7f, 0x90 | 0x08, 0x37, 0x64]) + ichannel = 8 + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + self.assertIsInstance(msg, NoteOn) + self.assertEqual(msg.note, 0x30) + self.assertEqual(msg.velocity, 0x7f) + self.assertEqual(msgendidxplusone, 3) + self.assertEqual(skipped, 0) + self.assertEqual(msg.channel, 8) + + def test_NoteOn_postpartialNoteOn(self): + data = bytes([0x90, 0x30, 0x7f, 0x90, 0x37]) + ichannel = 0 + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + self.assertIsInstance(msg, NoteOn) + self.assertEqual(msg.note, 0x30) + self.assertEqual(msg.velocity, 0x7f) + self.assertEqual(msgendidxplusone, 3, + "Only first message is removed") + self.assertEqual(skipped, 0) + self.assertEqual(msg.channel, 0) + + def test_NoteOn_preotherchannel(self): + data = bytes([0x90 | 0x05, 0x30, 0x7f, 0x90 | 0x03, 0x37, 0x64]) + ichannel = 3 + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + self.assertIsInstance(msg, NoteOn) + self.assertEqual(msg.note, 0x37) + self.assertEqual(msg.velocity, 0x64) + self.assertEqual(msgendidxplusone, 6, + "Both messages are removed from buffer") + self.assertEqual(skipped, 0) + self.assertEqual(msg.channel, 3) + + def test_NoteOn_preotherchannelplusintermediatejunk(self): + data = bytes([0x90 | 0x05, 0x30, 0x7f, 0x00, 0x00, 0x90 | 0x03, 0x37, 0x64]) + ichannel = 3 + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + self.assertIsInstance(msg, NoteOn) + self.assertEqual(msg.note, 0x37) + self.assertEqual(msg.velocity, 0x64) + self.assertEqual(msgendidxplusone, 8, + "Both messages and junk are removed from buffer") + self.assertEqual(skipped, 0) + self.assertEqual(msg.channel, 3) + + def test_NoteOn_wrongchannel(self): + data = bytes([0x95, 0x30, 0x7f]) + ichannel = 3 + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + self.assertIsNone(msg) + self.assertEqual(msgendidxplusone, 3, + "wrong channel message discarded") + self.assertEqual(skipped, 0) + + def test_NoteOn_partialandpreotherchannel1(self): + data = bytes([0x95, 0x30, 0x7f, 0x93]) + ichannel = 3 + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + self.assertIsNone(msg) + self.assertEqual(msgendidxplusone, 3, + "first message discarded, second partial left") + self.assertEqual(skipped, 0) + + def test_NoteOn_partialandpreotherchannel2(self): + data = bytes([0x95, 0x30, 0x7f, 0x93, 0x37]) + ichannel = 3 + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + self.assertIsNone(msg) + self.assertEqual(msgendidxplusone, 3, + "first message discarded, second partial left") + self.assertEqual(skipped, 0) + + def test_NoteOn_constructor_int(self): + object1 = NoteOn(60, 0x7f) + + self.assertEqual(object1.note, 60) + self.assertEqual(object1.velocity, 0x7f) + self.assertIsNone(object1.channel) + + object2 = NoteOn(60, 0x00) # equivalent of NoteOff + + self.assertEqual(object2.note, 60) + self.assertEqual(object2.velocity, 0x00) + self.assertIsNone(object2.channel) + + object3 = NoteOn(60, 0x50, channel=7) + + self.assertEqual(object3.note, 60) + self.assertEqual(object3.velocity, 0x50) + self.assertEqual(object3.channel, 7) + + object4 = NoteOn(60) # velocity defaults to 127 + + self.assertEqual(object4.note, 60) + self.assertEqual(object4.velocity, 127) + self.assertIsNone(object4.channel) + + def test_SystemExclusive_NoteOn(self): + data = bytes([0xf0, 0x42, 0x01, 0x02, 0x03, 0x04, 0xf7, 0x90 | 14, 0x30, 0x60]) + ichannel = 14 + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + self.assertIsInstance(msg, SystemExclusive) + self.assertEqual(msg.manufacturer_id, bytes([0x42])) # Korg + self.assertEqual(msg.data, bytes([0x01, 0x02, 0x03, 0x04])) + self.assertEqual(msgendidxplusone, 7) + self.assertEqual(skipped, 0, + "If SystemExclusive class is imported then this must be 0") + self.assertIsNone(msg.channel) + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data[msgendidxplusone:], ichannel) + + self.assertIsInstance(msg, NoteOn) + self.assertEqual(msg.note, 48) + self.assertEqual(msg.velocity, 0x60) + self.assertEqual(msgendidxplusone, 3) + self.assertEqual(skipped, 0) + self.assertEqual(msg.channel, 14) + + def test_SystemExclusive_NoteOn_premalterminatedsysex(self): + data = bytes([0xf0, 0x42, 0x01, 0x02, 0x03, 0x04, 0xf0, 0x90, 0x30, 0x32]) + ichannel = 0 + + # 0xf0 is incorrect status to mark end of this message, must be 0xf7 + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + self.assertIsNone(msg) + self.assertEqual(msgendidxplusone, 7) + self.assertEqual(skipped, 0, + "If SystemExclusive class is imported then this must be 0") + + def test_Unknown_SinglebyteStatus(self): + data = bytes([0xfd]) + ichannel = 0 + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + self.assertIsInstance(msg, adafruit_midi.midi_message.MIDIUnknownEvent) + self.assertEqual(msgendidxplusone, 1) + self.assertEqual(skipped, 0) + self.assertIsNone(msg.channel) + + def test_Empty(self): + data = bytes([]) + ichannel = 0 + + (msg, msgendidxplusone, skipped) = adafruit_midi.MIDIMessage.from_message_bytes(data, ichannel) + + self.assertIsNone(msg) + self.assertEqual(msgendidxplusone, 0) + self.assertEqual(skipped, 0) + + +class Test_MIDIMessage_NoteOn_constructor(unittest.TestCase): + def test_NoteOn_constructor_string(self): + object1 = NoteOn("C4", 0x64) + self.assertEqual(object1.note, 60) + self.assertEqual(object1.velocity, 0x64) + + object2 = NoteOn("C3", 0x7f) + self.assertEqual(object2.note, 48) + self.assertEqual(object2.velocity, 0x7f) + + object3 = NoteOn("C#4", 0x00) + self.assertEqual(object3.note, 61) + self.assertEqual(object3.velocity, 0) + + def test_NoteOn_constructor_valueerror1(self): + with self.assertRaises(ValueError): + NoteOn(60, 0x80) # pylint is happier if return value not stored + + def test_NoteOn_constructor_valueerror2(self): + with self.assertRaises(ValueError): + NoteOn(-1, 0x7f) + + def test_NoteOn_constructor_valueerror3(self): + with self.assertRaises(ValueError): + NoteOn(128, 0x7f) + + def test_NoteOn_constructor_upperrange1(self): + object1 = NoteOn("G9", 0x7f) + self.assertEqual(object1.note, 127) + self.assertEqual(object1.velocity, 0x7f) + + def test_NoteOn_constructor_upperrange2(self): + with self.assertRaises(ValueError): + NoteOn("G#9", 0x7f) # just above max note + + def test_NoteOn_constructor_bogusstring(self): + with self.assertRaises(ValueError): + NoteOn("CC4", 0x7f) + + +class Test_MIDIMessage_NoteOff_constructor(unittest.TestCase): + # mostly cut and paste from NoteOn above + def test_NoteOff_constructor_string(self): + object1 = NoteOff("C4", 0x64) + self.assertEqual(object1.note, 60) + self.assertEqual(object1.velocity, 0x64) + + object2 = NoteOff("C3", 0x7f) + self.assertEqual(object2.note, 48) + self.assertEqual(object2.velocity, 0x7f) + + object3 = NoteOff("C#4", 0x00) + self.assertEqual(object3.note, 61) + self.assertEqual(object3.velocity, 0) + + object4 = NoteOff("C#4") # velocity defaults to 0 + self.assertEqual(object4.note, 61) + self.assertEqual(object4.velocity, 0) + + def test_NoteOff_constructor_valueerror1(self): + with self.assertRaises(ValueError): + NoteOff(60, 0x80) + + def test_NoteOff_constructor_valueerror2(self): + with self.assertRaises(ValueError): + NoteOff(-1, 0x7f) + + def test_NoteOff_constructor_valueerror3(self): + with self.assertRaises(ValueError): + NoteOff(128, 0x7f) + + def test_NoteOff_constructor_upperrange1(self): + object1 = NoteOff("G9", 0x7f) + self.assertEqual(object1.note, 127) + self.assertEqual(object1.velocity, 0x7f) + + def test_NoteOff_constructor_upperrange2(self): + with self.assertRaises(ValueError): + NoteOff("G#9", 0x7f) # just above max note + + def test_NoteOff_constructor_bogusstring(self): + with self.assertRaises(ValueError): + NoteOff("CC4", 0x7f) + + + +if __name__ == '__main__': + unittest.main(verbosity=verbose) diff --git a/tests/test_MIDI_unittests.py b/tests/test_MIDI_unittests.py new file mode 100644 index 0000000..e04a437 --- /dev/null +++ b/tests/test_MIDI_unittests.py @@ -0,0 +1,463 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import unittest +from unittest.mock import Mock, MagicMock, call + +import random +import os +verbose = int(os.getenv('TESTVERBOSE', '2')) + +# adafruit_midi had an import usb_midi +import sys +#sys.modules['usb_midi'] = MagicMock() + +# Borrowing the dhalbert/tannewt technique from adafruit/Adafruit_CircuitPython_Motor +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +# Full monty +from adafruit_midi.channel_pressure import ChannelPressure +from adafruit_midi.control_change import ControlChange +from adafruit_midi.note_off import NoteOff +from adafruit_midi.note_on import NoteOn +from adafruit_midi.pitch_bend import PitchBend +from adafruit_midi.polyphonic_key_pressure import PolyphonicKeyPressure +from adafruit_midi.program_change import ProgramChange +from adafruit_midi.start import Start +from adafruit_midi.stop import Stop +from adafruit_midi.system_exclusive import SystemExclusive +from adafruit_midi.timing_clock import TimingClock + +# Import after messages - opposite to other test file +import adafruit_midi + + +# For loopback/echo tests +def MIDI_mocked_both_loopback(in_c, out_c): + usb_data = bytearray() + def write(buffer, length): + nonlocal usb_data + usb_data.extend(buffer[0:length]) + + def read(length): + nonlocal usb_data + poppedbytes = usb_data[0:length] + usb_data = usb_data[len(poppedbytes):] + return bytes(poppedbytes) + + mockedPortIn = Mock() + mockedPortIn.read = read + mockedPortOut = Mock() + mockedPortOut.write = write + m = adafruit_midi.MIDI(midi_out=mockedPortOut, midi_in=mockedPortIn, + out_channel=out_c, in_channel=in_c) + return m + +def MIDI_mocked_receive(in_c, data, read_sizes): + usb_data = bytearray(data) + chunks = read_sizes + chunk_idx = 0 + + def read(length): + nonlocal usb_data, chunks, chunk_idx + # pylint: disable=no-else-return + if length != 0 and chunk_idx < len(chunks): + # min() to ensure we only read what's asked for and present + poppedbytes = usb_data[0:min(length, chunks[chunk_idx])] + usb_data = usb_data[len(poppedbytes):] + if length >= chunks[chunk_idx]: + chunk_idx += 1 + else: + chunks[chunk_idx] -= len(poppedbytes) + return bytes(poppedbytes) + else: + return bytes() + + mockedPortIn = Mock() + mockedPortIn.read = read + + m = adafruit_midi.MIDI(midi_out=None, midi_in=mockedPortIn, + out_channel=in_c, in_channel=in_c) + return m + + +class Test_MIDI_constructor(unittest.TestCase): + def test_no_inout(self): + # constructor likes a bit of in out + with self.assertRaises(ValueError): + adafruit_midi.MIDI() + +class Test_MIDI(unittest.TestCase): + # pylint: disable=too-many-branches + def test_captured_data_one_byte_reads(self): + c = 0 + # From an M-Audio AXIOM controller + raw_data = bytearray([0x90, 0x3e, 0x5f] + + [ 0xd0, 0x10] + + [ 0x90, 0x40, 0x66 ] + + [ 0xb0, 0x1, 0x08 ] + + [ 0x90, 0x41, 0x74 ] + + [ 0xe0, 0x03, 0x40 ]) + m = MIDI_mocked_receive(c, raw_data, [1] * len(raw_data)) + + for unused in range(100): # pylint: disable=unused-variable + msg = m.receive() + if msg is not None: + break + self.assertIsInstance(msg, NoteOn) + self.assertEqual(msg.note, 0x3e) + self.assertEqual(msg.velocity, 0x5f) + self.assertEqual(msg.channel, c) + + # for loops currently absorb any Nones but could + # be set to read precisely the expected number... + for unused in range(100): # pylint: disable=unused-variable + msg = m.receive() + if msg is not None: + break + self.assertIsInstance(msg, ChannelPressure) + self.assertEqual(msg.pressure, 0x10) + self.assertEqual(msg.channel, c) + + for unused in range(100): # pylint: disable=unused-variable + msg = m.receive() + if msg is not None: + break + self.assertIsInstance(msg, NoteOn) + self.assertEqual(msg.note, 0x40) + self.assertEqual(msg.velocity, 0x66) + self.assertEqual(msg.channel, c) + + for unused in range(100): # pylint: disable=unused-variable + msg = m.receive() + if msg is not None: + break + self.assertIsInstance(msg, ControlChange) + self.assertEqual(msg.control, 0x01) + self.assertEqual(msg.value, 0x08) + self.assertEqual(msg.channel, c) + + for unused in range(100): # pylint: disable=unused-variable + msg = m.receive() + if msg is not None: + break + self.assertIsInstance(msg, NoteOn) + self.assertEqual(msg.note, 0x41) + self.assertEqual(msg.velocity, 0x74) + self.assertEqual(msg.channel, c) + + for unused in range(100): # pylint: disable=unused-variable + msg = m.receive() + if msg is not None: + break + self.assertIsInstance(msg, PitchBend) + self.assertEqual(msg.pitch_bend, 8195) + self.assertEqual(msg.channel, c) + + for unused in range(100): # pylint: disable=unused-variable + msg = m.receive() + self.assertIsNone(msg) + + def test_unknown_before_NoteOn(self): + c = 0 + # From an M-Audio AXIOM controller + raw_data = (bytes([0b11110011, 0x10] # Song Select (not yet implemented) + + [ 0b11110011, 0x20] + + [ 0b11110100 ] + + [ 0b11110101 ]) + + bytes(NoteOn("C5", 0x7f, channel=c))) + m = MIDI_mocked_receive(c, raw_data, [2, 2, 1, 1, 3]) + + for unused in range(4): # pylint: disable=unused-variable + msg = m.receive() + self.assertIsInstance(msg, adafruit_midi.midi_message.MIDIUnknownEvent) + self.assertIsNone(msg.channel) + + msg = m.receive() + self.assertIsInstance(msg, NoteOn) + self.assertEqual(msg.note, 0x48) # 0x48 is C5 + self.assertEqual(msg.velocity, 0x7f) + self.assertEqual(msg.channel, c) + + # See https://github.com/adafruit/Adafruit_CircuitPython_MIDI/issues/8 + def test_running_status_when_implemented(self): + c = 8 + raw_data = (bytes(NoteOn("C5", 0x7f, channel=c)) + + bytes([0xe8, 0x72, 0x40] + + [0x6d, 0x40] + + [0x05, 0x41]) + + bytes(NoteOn("D5", 0x7f, channel=c))) + + m = MIDI_mocked_receive(c, raw_data, [3 + 3 + 2 + 3 + 3]) + self.assertIsInstance(m, adafruit_midi.MIDI) # silence pylint! + #self.assertEqual(TOFINISH, WHENIMPLEMENTED) + + def test_somegood_somemissing_databytes(self): + c = 8 + raw_data = (bytes(NoteOn("C5", 0x7f, channel=c)) + + bytes([0xe8, 0x72, 0x40] + + [0xe8, 0x6d ] # Missing last data byte + + [0xe8, 0x5, 0x41 ]) + + bytes(NoteOn("D5", 0x7f, channel=c))) + m = MIDI_mocked_receive(c, raw_data, [3 + 3 + 2 + 3 + 3]) + + msg1 = m.receive() + self.assertIsInstance(msg1, NoteOn) + self.assertEqual(msg1.note, 72) + self.assertEqual(msg1.velocity, 0x7f) + self.assertEqual(msg1.channel, c) + + msg2 = m.receive() + self.assertIsInstance(msg2, PitchBend) + self.assertEqual(msg2.pitch_bend, 8306) + self.assertEqual(msg2.channel, c) + + # The current implementation will read status bytes for data + # In most cases it would be a faster recovery with fewer messages + # lost if the next status byte wasn't consumed + # and parsing restarted from that byte + msg3 = m.receive() + self.assertIsInstance(msg3, adafruit_midi.midi_message.MIDIBadEvent) + self.assertIsInstance(msg3.data, bytes) + self.assertEqual(msg3.data, bytes([0xe8, 0x6d, 0xe8])) + self.assertIsNone(msg3.channel) + + #(msg4, channel4) = m.receive() + #self.assertIsInstance(msg4, PitchBend) + #self.assertEqual(msg4.pitch_bend, 72) + #self.assertEqual(channel4, c) + + msg5 = m.receive() + self.assertIsInstance(msg5, NoteOn) + self.assertEqual(msg5.note, 74) + self.assertEqual(msg5.velocity, 0x7f) + self.assertEqual(msg5.channel, c) + + msg6 = m.receive() + self.assertIsNone(msg6) + + def test_smallsysex_between_notes(self): + m = MIDI_mocked_both_loopback(3, 3) + + m.send([NoteOn("C4", 0x7f), + SystemExclusive([0x1f], [1, 2, 3, 4, 5, 6, 7, 8]), + NoteOff(60, 0x28)]) + + msg1 = m.receive() + self.assertIsInstance(msg1, NoteOn) + self.assertEqual(msg1.note, 60) + self.assertEqual(msg1.velocity, 0x7f) + self.assertEqual(msg1.channel, 3) + + msg2 = m.receive() + self.assertIsInstance(msg2, SystemExclusive) + self.assertEqual(msg2.manufacturer_id, bytes([0x1f])) + self.assertEqual(msg2.data, bytes([1, 2, 3, 4, 5, 6, 7, 8])) + self.assertEqual(msg2.channel, None) # SysEx does not have a channel + + msg3 = m.receive() + self.assertIsInstance(msg3, NoteOff) + self.assertEqual(msg3.note, 60) + self.assertEqual(msg3.velocity, 0x28) + self.assertEqual(msg3.channel, 3) + + msg4 = m.receive() + self.assertIsNone(msg4) + + def test_smallsysex_bytes_type(self): + s = SystemExclusive([0x1f], [100, 150, 200]) + + self.assertIsInstance(s, SystemExclusive) + self.assertEqual(s.manufacturer_id, bytes([0x1f])) + self.assertIsInstance(s.manufacturer_id, bytes) + + # check this really is immutable (pylint also picks this up!) + with self.assertRaises(TypeError): + s.data[0] = 0 # pylint: disable=unsupported-assignment-operation + + self.assertEqual(s.data, bytes([100, 150, 200])) + self.assertIsInstance(s.data, bytes) + + # pylint: disable=too-many-locals + def test_larger_than_buffer_sysex(self): + c = 0 + monster_data_len = 500 + raw_data = (bytes(NoteOn("C5", 0x7f, channel=c)) + + bytes(SystemExclusive([0x02], + [d & 0x7f for d in range(monster_data_len)])) + + bytes(NoteOn("D5", 0x7f, channel=c))) + m = MIDI_mocked_receive(c, raw_data, [len(raw_data)]) + buffer_len = m._in_buf_size # pylint: disable=protected-access + self.assertTrue(monster_data_len > buffer_len, + "checking our SysEx truly is larger than buffer") + + msg1 = m.receive() + self.assertIsInstance(msg1, NoteOn) + self.assertEqual(msg1.note, 72) + self.assertEqual(msg1.velocity, 0x7f) + self.assertEqual(msg1.channel, c) + + # (Ab)using python's rounding down for negative division + # pylint: disable=unused-variable + for unused in range(-(-(1 + 1 + monster_data_len + 1) // buffer_len) - 1): + msg2 = m.receive() + self.assertIsNone(msg2) + + # The current implementation will read SysEx end status byte + # and report it as an unknown + msg3 = m.receive() + self.assertIsInstance(msg3, adafruit_midi.midi_message.MIDIUnknownEvent) + self.assertEqual(msg3.status, 0xf7) + self.assertIsNone(msg3.channel) + + #(msg4, channel4) = m.receive() + #self.assertIsInstance(msg4, PitchBend) + #self.assertEqual(msg4.pitch_bend, 72) + #self.assertEqual(channel4, c) + + msg5 = m.receive() + self.assertIsInstance(msg5, NoteOn) + self.assertEqual(msg5.note, 74) + self.assertEqual(msg5.velocity, 0x7f) + self.assertEqual(msg5.channel, c) + + msg6 = m.receive() + self.assertIsNone(msg6) + +# pylint does not like mock_calls - must be a better way to handle this? +# pylint: disable=no-member +class Test_MIDI_send(unittest.TestCase): + def test_send_basic_single(self): + #def printit(buffer, len): + # print(buffer[0:len]) + mockedPortOut = Mock() + #mockedPortOut.write = printit + + m = adafruit_midi.MIDI(midi_out=mockedPortOut, out_channel=2) + + # Test sending some NoteOn and NoteOff to various channels + nextcall = 0 + m.send(NoteOn(0x60, 0x7f)) + self.assertEqual(mockedPortOut.write.mock_calls[nextcall], + call(b'\x92\x60\x7f', 3)) + nextcall += 1 + m.send(NoteOn(0x64, 0x3f)) + self.assertEqual(mockedPortOut.write.mock_calls[nextcall], + call(b'\x92\x64\x3f', 3)) + nextcall += 1 + m.send(NoteOn(0x67, 0x1f)) + self.assertEqual(mockedPortOut.write.mock_calls[nextcall], + call(b'\x92\x67\x1f', 3)) + nextcall += 1 + + m.send(NoteOn(0x60, 0x00)) # Alternative to NoteOff + self.assertEqual(mockedPortOut.write.mock_calls[nextcall], + call(b'\x92\x60\x00', 3)) + nextcall += 1 + m.send(NoteOff(0x64, 0x01)) + self.assertEqual(mockedPortOut.write.mock_calls[nextcall], + call(b'\x82\x64\x01', 3)) + nextcall += 1 + m.send(NoteOff(0x67, 0x02)) + self.assertEqual(mockedPortOut.write.mock_calls[nextcall], + call(b'\x82\x67\x02', 3)) + nextcall += 1 + + # Setting channel to non default + m.send(NoteOn(0x6c, 0x7f), channel=9) + self.assertEqual(mockedPortOut.write.mock_calls[nextcall], + call(b'\x99\x6c\x7f', 3)) + nextcall += 1 + + m.send(NoteOff(0x6c, 0x7f), channel=9) + self.assertEqual(mockedPortOut.write.mock_calls[nextcall], + call(b'\x89\x6c\x7f', 3)) + nextcall += 1 + + def test_send_badnotes(self): + mockedPortOut = Mock() + + m = adafruit_midi.MIDI(midi_out=mockedPortOut, out_channel=2) + + # Test sending some NoteOn and NoteOff to various channels + nextcall = 0 + m.send(NoteOn(60, 0x7f)) + self.assertEqual(mockedPortOut.write.mock_calls[nextcall], + call(b'\x92\x3c\x7f', 3)) + nextcall += 1 + with self.assertRaises(ValueError): + m.send(NoteOn(64, 0x80)) # Velocity > 127 - illegal value + + with self.assertRaises(ValueError): + m.send(NoteOn(67, -1)) + + # test after exceptions to ensure sending is still ok + m.send(NoteOn(72, 0x7f)) + self.assertEqual(mockedPortOut.write.mock_calls[nextcall], + call(b'\x92\x48\x7f', 3)) + nextcall += 1 + + def test_send_basic_sequences(self): + #def printit(buffer, len): + # print(buffer[0:len]) + mockedPortOut = Mock() + #mockedPortOut.write = printit + + m = adafruit_midi.MIDI(midi_out=mockedPortOut, out_channel=2) + + # Test sending some NoteOn and NoteOff to various channels + nextcall = 0 + # Test sequences with list syntax and pass a tuple too + note_list = [NoteOn(0x6c, 0x51), + NoteOn(0x70, 0x52), + NoteOn(0x73, 0x53)] + note_tuple = tuple(note_list) + m.send(note_list, channel=10) + self.assertEqual(mockedPortOut.write.mock_calls[nextcall], + call(b'\x9a\x6c\x51\x9a\x70\x52\x9a\x73\x53', 9), + "The implementation writes in one go, single 9 byte write expected") + nextcall += 1 + m.send(note_tuple, channel=11) + self.assertEqual(mockedPortOut.write.mock_calls[nextcall], + call(b'\x9b\x6c\x51\x9b\x70\x52\x9b\x73\x53', 9), + "The implementation writes in one go, single 9 byte write expected") + nextcall += 1 + + def test_termination_with_random_data(self): + """Test with a random stream of bytes to ensure that the parsing code + termates and returns, i.e. does not go into any infinite loops. + """ + c = 0 + random.seed(303808) + raw_data = bytearray([random.randint(0, 255) for i in range(50000)]) + m = MIDI_mocked_receive(c, raw_data, [len(raw_data)]) + + noinfiniteloops = False + for unused in range(len(raw_data)): # pylint: disable=unused-variable + m.receive() # not interested in returned tuple + + noinfiniteloops = True # interested in getting to here + self.assertTrue(noinfiniteloops) + + +if __name__ == '__main__': + unittest.main(verbosity=verbose) diff --git a/tests/test_note_parser.py b/tests/test_note_parser.py new file mode 100644 index 0000000..d632e53 --- /dev/null +++ b/tests/test_note_parser.py @@ -0,0 +1,93 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Kevin J. Walters +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import unittest +from unittest.mock import Mock, MagicMock, call + +import random +import os +verbose = int(os.getenv('TESTVERBOSE', '2')) + +# adafruit_midi had an import usb_midi +import sys +#sys.modules['usb_midi'] = MagicMock() + +# Borrowing the dhalbert/tannewt technique from adafruit/Adafruit_CircuitPython_Motor +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from adafruit_midi.midi_message import note_parser + + +class Test_note_parser(unittest.TestCase): + def text_int_passthru(self): + self.assertEqual(note_parser(0), 0) + self.assertEqual(note_parser(70), 70) + self.assertEqual(note_parser(127), 127) + + # it does not range check so these should pass + self.assertEqual(note_parser(-303), -303) + self.assertEqual(note_parser(808), 808) + + def test_good_text(self): + note_prefix = { "Cb": 11, + "C" : 12, + "C#": 13, + "Db": 13, + "D" : 14, + "D#": 15, + "Eb": 15, + "E" : 16, + "Fb": 16, + "E#": 17, + "F" : 17, + "F#": 18, + "Gb": 18, + "G" : 19, + "G#": 20, + "Ab": 20, + "A" : 21, + "A#": 22, + "Bb": 22, + "B" : 23, + "B#": 24, + } + + # test from Cb0 to B#8 + for prefix, base_value in note_prefix.items(): + for octave in range(9): + note = prefix + str(octave) + expected_value = base_value + octave * 12 # 12 semitones in octave + self.assertEqual(note_parser(note), expected_value) + + # re-test with simple C4/A4 tests to catch any bugs in above + self.assertEqual(note_parser("C4"), 60) + self.assertEqual(note_parser("A4"), 69) + + def test_bad_text(self): + + for text_note in ["H", "H4", "asdfasdfasdf", "000", "999"]: + with self.assertRaises(ValueError): + note_parser(text_note) + + +if __name__ == '__main__': + unittest.main(verbosity=verbose)