/ tests / test_MIDI_unittests.py
test_MIDI_unittests.py
  1  # The MIT License (MIT)
  2  #
  3  # Copyright (c) 2019 Kevin J. Walters
  4  #
  5  # Permission is hereby granted, free of charge, to any person obtaining a copy
  6  # of this software and associated documentation files (the "Software"), to deal
  7  # in the Software without restriction, including without limitation the rights
  8  # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9  # copies of the Software, and to permit persons to whom the Software is
 10  # furnished to do so, subject to the following conditions:
 11  #
 12  # The above copyright notice and this permission notice shall be included in
 13  # all copies or substantial portions of the Software.
 14  #
 15  # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 16  # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 17  # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 18  # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 19  # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 20  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 21  # THE SOFTWARE.
 22  
 23  import unittest
 24  from unittest.mock import Mock, MagicMock, call
 25  
 26  import random
 27  import os
 28  
 29  verbose = int(os.getenv("TESTVERBOSE", "2"))
 30  
 31  # adafruit_midi had an import usb_midi
 32  import sys
 33  
 34  # sys.modules['usb_midi'] = MagicMock()
 35  
 36  # Borrowing the dhalbert/tannewt technique from adafruit/Adafruit_CircuitPython_Motor
 37  sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
 38  
 39  # Full monty
 40  from adafruit_midi.channel_pressure import ChannelPressure
 41  from adafruit_midi.control_change import ControlChange
 42  from adafruit_midi.note_off import NoteOff
 43  from adafruit_midi.note_on import NoteOn
 44  from adafruit_midi.pitch_bend import PitchBend
 45  from adafruit_midi.polyphonic_key_pressure import PolyphonicKeyPressure
 46  from adafruit_midi.program_change import ProgramChange
 47  from adafruit_midi.start import Start
 48  from adafruit_midi.stop import Stop
 49  from adafruit_midi.system_exclusive import SystemExclusive
 50  from adafruit_midi.timing_clock import TimingClock
 51  
 52  # Import after messages - opposite to other test file
 53  import adafruit_midi
 54  
 55  
 56  # For loopback/echo tests
 57  def MIDI_mocked_both_loopback(in_c, out_c):
 58      usb_data = bytearray()
 59  
 60      def write(buffer, length):
 61          nonlocal usb_data
 62          usb_data.extend(buffer[0:length])
 63  
 64      def read(length):
 65          nonlocal usb_data
 66          poppedbytes = usb_data[0:length]
 67          usb_data = usb_data[len(poppedbytes) :]
 68          return bytes(poppedbytes)
 69  
 70      mockedPortIn = Mock()
 71      mockedPortIn.read = read
 72      mockedPortOut = Mock()
 73      mockedPortOut.write = write
 74      m = adafruit_midi.MIDI(
 75          midi_out=mockedPortOut, midi_in=mockedPortIn, out_channel=out_c, in_channel=in_c
 76      )
 77      return m
 78  
 79  
 80  def MIDI_mocked_receive(in_c, data, read_sizes):
 81      usb_data = bytearray(data)
 82      chunks = read_sizes
 83      chunk_idx = 0
 84  
 85      def read(length):
 86          nonlocal usb_data, chunks, chunk_idx
 87          # pylint:  disable=no-else-return
 88          if length != 0 and chunk_idx < len(chunks):
 89              # min() to ensure we only read what's asked for and present
 90              poppedbytes = usb_data[0 : min(length, chunks[chunk_idx])]
 91              usb_data = usb_data[len(poppedbytes) :]
 92              if length >= chunks[chunk_idx]:
 93                  chunk_idx += 1
 94              else:
 95                  chunks[chunk_idx] -= len(poppedbytes)
 96              return bytes(poppedbytes)
 97          else:
 98              return bytes()
 99  
100      mockedPortIn = Mock()
101      mockedPortIn.read = read
102  
103      m = adafruit_midi.MIDI(
104          midi_out=None, midi_in=mockedPortIn, out_channel=in_c, in_channel=in_c
105      )
106      return m
107  
108  
109  class Test_MIDI_constructor(unittest.TestCase):
110      def test_no_inout(self):
111          # constructor likes a bit of in out
112          with self.assertRaises(ValueError):
113              adafruit_midi.MIDI()
114  
115  
116  class Test_MIDI(unittest.TestCase):
117      # pylint: disable=too-many-branches
118      def test_captured_data_one_byte_reads(self):
119          c = 0
120          # From an M-Audio AXIOM controller
121          raw_data = bytearray(
122              [0x90, 0x3E, 0x5F]
123              + [0xD0, 0x10]
124              + [0x90, 0x40, 0x66]
125              + [0xB0, 0x1, 0x08]
126              + [0x90, 0x41, 0x74]
127              + [0xE0, 0x03, 0x40]
128          )
129          m = MIDI_mocked_receive(c, raw_data, [1] * len(raw_data))
130  
131          for unused in range(100):  # pylint: disable=unused-variable
132              msg = m.receive()
133              if msg is not None:
134                  break
135          self.assertIsInstance(msg, NoteOn)
136          self.assertEqual(msg.note, 0x3E)
137          self.assertEqual(msg.velocity, 0x5F)
138          self.assertEqual(msg.channel, c)
139  
140          # for loops currently absorb any Nones but could
141          # be set to read precisely the expected number...
142          for unused in range(100):  # pylint: disable=unused-variable
143              msg = m.receive()
144              if msg is not None:
145                  break
146          self.assertIsInstance(msg, ChannelPressure)
147          self.assertEqual(msg.pressure, 0x10)
148          self.assertEqual(msg.channel, c)
149  
150          for unused in range(100):  # pylint: disable=unused-variable
151              msg = m.receive()
152              if msg is not None:
153                  break
154          self.assertIsInstance(msg, NoteOn)
155          self.assertEqual(msg.note, 0x40)
156          self.assertEqual(msg.velocity, 0x66)
157          self.assertEqual(msg.channel, c)
158  
159          for unused in range(100):  # pylint: disable=unused-variable
160              msg = m.receive()
161              if msg is not None:
162                  break
163          self.assertIsInstance(msg, ControlChange)
164          self.assertEqual(msg.control, 0x01)
165          self.assertEqual(msg.value, 0x08)
166          self.assertEqual(msg.channel, c)
167  
168          for unused in range(100):  # pylint: disable=unused-variable
169              msg = m.receive()
170              if msg is not None:
171                  break
172          self.assertIsInstance(msg, NoteOn)
173          self.assertEqual(msg.note, 0x41)
174          self.assertEqual(msg.velocity, 0x74)
175          self.assertEqual(msg.channel, c)
176  
177          for unused in range(100):  # pylint: disable=unused-variable
178              msg = m.receive()
179              if msg is not None:
180                  break
181          self.assertIsInstance(msg, PitchBend)
182          self.assertEqual(msg.pitch_bend, 8195)
183          self.assertEqual(msg.channel, c)
184  
185          for unused in range(100):  # pylint: disable=unused-variable
186              msg = m.receive()
187              self.assertIsNone(msg)
188  
189      def test_unknown_before_NoteOn(self):
190          c = 0
191          # From an M-Audio AXIOM controller
192          raw_data = bytes(
193              [0b11110011, 0x10]  # Song Select (not yet implemented)
194              + [0b11110011, 0x20]
195              + [0b11110100]
196              + [0b11110101]
197          ) + bytes(NoteOn("C5", 0x7F, channel=c))
198          m = MIDI_mocked_receive(c, raw_data, [2, 2, 1, 1, 3])
199  
200          for unused in range(4):  # pylint: disable=unused-variable
201              msg = m.receive()
202              self.assertIsInstance(msg, adafruit_midi.midi_message.MIDIUnknownEvent)
203              self.assertIsNone(msg.channel)
204  
205          msg = m.receive()
206          self.assertIsInstance(msg, NoteOn)
207          self.assertEqual(msg.note, 0x48)  # 0x48 is C5
208          self.assertEqual(msg.velocity, 0x7F)
209          self.assertEqual(msg.channel, c)
210  
211      # See https://github.com/adafruit/Adafruit_CircuitPython_MIDI/issues/8
212      def test_running_status_when_implemented(self):
213          c = 8
214          raw_data = (
215              bytes(NoteOn("C5", 0x7F, channel=c))
216              + bytes([0xE8, 0x72, 0x40] + [0x6D, 0x40] + [0x05, 0x41])
217              + bytes(NoteOn("D5", 0x7F, channel=c))
218          )
219  
220          m = MIDI_mocked_receive(c, raw_data, [3 + 3 + 2 + 3 + 3])
221          self.assertIsInstance(m, adafruit_midi.MIDI)  # silence pylint!
222          # self.assertEqual(TOFINISH, WHENIMPLEMENTED)
223  
224      def test_somegood_somemissing_databytes(self):
225          c = 8
226          raw_data = (
227              bytes(NoteOn("C5", 0x7F, channel=c))
228              + bytes(
229                  [0xE8, 0x72, 0x40]
230                  + [0xE8, 0x6D]  # Missing last data byte
231                  + [0xE8, 0x5, 0x41]
232              )
233              + bytes(NoteOn("D5", 0x7F, channel=c))
234          )
235          m = MIDI_mocked_receive(c, raw_data, [3 + 3 + 2 + 3 + 3])
236  
237          msg1 = m.receive()
238          self.assertIsInstance(msg1, NoteOn)
239          self.assertEqual(msg1.note, 72)
240          self.assertEqual(msg1.velocity, 0x7F)
241          self.assertEqual(msg1.channel, c)
242  
243          msg2 = m.receive()
244          self.assertIsInstance(msg2, PitchBend)
245          self.assertEqual(msg2.pitch_bend, 8306)
246          self.assertEqual(msg2.channel, c)
247  
248          # The current implementation will read status bytes for data
249          # In most cases it would be a faster recovery with fewer messages
250          # lost if the next status byte wasn't consumed
251          # and parsing restarted from that byte
252          msg3 = m.receive()
253          self.assertIsInstance(msg3, adafruit_midi.midi_message.MIDIBadEvent)
254          self.assertIsInstance(msg3.data, bytes)
255          self.assertEqual(msg3.data, bytes([0xE8, 0x6D, 0xE8]))
256          self.assertIsNone(msg3.channel)
257  
258          # (msg4, channel4) = m.receive()
259          # self.assertIsInstance(msg4, PitchBend)
260          # self.assertEqual(msg4.pitch_bend, 72)
261          # self.assertEqual(channel4, c)
262  
263          msg5 = m.receive()
264          self.assertIsInstance(msg5, NoteOn)
265          self.assertEqual(msg5.note, 74)
266          self.assertEqual(msg5.velocity, 0x7F)
267          self.assertEqual(msg5.channel, c)
268  
269          msg6 = m.receive()
270          self.assertIsNone(msg6)
271  
272      def test_smallsysex_between_notes(self):
273          m = MIDI_mocked_both_loopback(3, 3)
274  
275          m.send(
276              [
277                  NoteOn("C4", 0x7F),
278                  SystemExclusive([0x1F], [1, 2, 3, 4, 5, 6, 7, 8]),
279                  NoteOff(60, 0x28),
280              ]
281          )
282  
283          msg1 = m.receive()
284          self.assertIsInstance(msg1, NoteOn)
285          self.assertEqual(msg1.note, 60)
286          self.assertEqual(msg1.velocity, 0x7F)
287          self.assertEqual(msg1.channel, 3)
288  
289          msg2 = m.receive()
290          self.assertIsInstance(msg2, SystemExclusive)
291          self.assertEqual(msg2.manufacturer_id, bytes([0x1F]))
292          self.assertEqual(msg2.data, bytes([1, 2, 3, 4, 5, 6, 7, 8]))
293          self.assertEqual(msg2.channel, None)  # SysEx does not have a channel
294  
295          msg3 = m.receive()
296          self.assertIsInstance(msg3, NoteOff)
297          self.assertEqual(msg3.note, 60)
298          self.assertEqual(msg3.velocity, 0x28)
299          self.assertEqual(msg3.channel, 3)
300  
301          msg4 = m.receive()
302          self.assertIsNone(msg4)
303  
304      def test_smallsysex_bytes_type(self):
305          s = SystemExclusive([0x1F], [100, 150, 200])
306  
307          self.assertIsInstance(s, SystemExclusive)
308          self.assertEqual(s.manufacturer_id, bytes([0x1F]))
309          self.assertIsInstance(s.manufacturer_id, bytes)
310  
311          # check this really is immutable (pylint also picks this up!)
312          with self.assertRaises(TypeError):
313              s.data[0] = 0  # pylint: disable=unsupported-assignment-operation
314  
315          self.assertEqual(s.data, bytes([100, 150, 200]))
316          self.assertIsInstance(s.data, bytes)
317  
318      # pylint: disable=too-many-locals
319      def test_larger_than_buffer_sysex(self):
320          c = 0
321          monster_data_len = 500
322          raw_data = (
323              bytes(NoteOn("C5", 0x7F, channel=c))
324              + bytes(
325                  SystemExclusive([0x02], [d & 0x7F for d in range(monster_data_len)])
326              )
327              + bytes(NoteOn("D5", 0x7F, channel=c))
328          )
329          m = MIDI_mocked_receive(c, raw_data, [len(raw_data)])
330          buffer_len = m._in_buf_size  # pylint: disable=protected-access
331          self.assertTrue(
332              monster_data_len > buffer_len,
333              "checking our SysEx truly is larger than buffer",
334          )
335  
336          msg1 = m.receive()
337          self.assertIsInstance(msg1, NoteOn)
338          self.assertEqual(msg1.note, 72)
339          self.assertEqual(msg1.velocity, 0x7F)
340          self.assertEqual(msg1.channel, c)
341  
342          # (Ab)using python's rounding down for negative division
343          # pylint: disable=unused-variable
344          for unused in range(-(-(1 + 1 + monster_data_len + 1) // buffer_len) - 1):
345              msg2 = m.receive()
346              self.assertIsNone(msg2)
347  
348          # The current implementation will read SysEx end status byte
349          # and report it as an unknown
350          msg3 = m.receive()
351          self.assertIsInstance(msg3, adafruit_midi.midi_message.MIDIUnknownEvent)
352          self.assertEqual(msg3.status, 0xF7)
353          self.assertIsNone(msg3.channel)
354  
355          # (msg4, channel4) = m.receive()
356          # self.assertIsInstance(msg4, PitchBend)
357          # self.assertEqual(msg4.pitch_bend, 72)
358          # self.assertEqual(channel4, c)
359  
360          msg5 = m.receive()
361          self.assertIsInstance(msg5, NoteOn)
362          self.assertEqual(msg5.note, 74)
363          self.assertEqual(msg5.velocity, 0x7F)
364          self.assertEqual(msg5.channel, c)
365  
366          msg6 = m.receive()
367          self.assertIsNone(msg6)
368  
369  
370  # pylint does not like mock_calls - must be a better way to handle this?
371  # pylint: disable=no-member
372  class Test_MIDI_send(unittest.TestCase):
373      def test_send_basic_single(self):
374          # def printit(buffer, len):
375          #    print(buffer[0:len])
376          mockedPortOut = Mock()
377          # mockedPortOut.write = printit
378  
379          m = adafruit_midi.MIDI(midi_out=mockedPortOut, out_channel=2)
380  
381          # Test sending some NoteOn and NoteOff to various channels
382          nextcall = 0
383          m.send(NoteOn(0x60, 0x7F))
384          self.assertEqual(
385              mockedPortOut.write.mock_calls[nextcall], call(b"\x92\x60\x7f", 3)
386          )
387          nextcall += 1
388          m.send(NoteOn(0x64, 0x3F))
389          self.assertEqual(
390              mockedPortOut.write.mock_calls[nextcall], call(b"\x92\x64\x3f", 3)
391          )
392          nextcall += 1
393          m.send(NoteOn(0x67, 0x1F))
394          self.assertEqual(
395              mockedPortOut.write.mock_calls[nextcall], call(b"\x92\x67\x1f", 3)
396          )
397          nextcall += 1
398  
399          m.send(NoteOn(0x60, 0x00))  # Alternative to NoteOff
400          self.assertEqual(
401              mockedPortOut.write.mock_calls[nextcall], call(b"\x92\x60\x00", 3)
402          )
403          nextcall += 1
404          m.send(NoteOff(0x64, 0x01))
405          self.assertEqual(
406              mockedPortOut.write.mock_calls[nextcall], call(b"\x82\x64\x01", 3)
407          )
408          nextcall += 1
409          m.send(NoteOff(0x67, 0x02))
410          self.assertEqual(
411              mockedPortOut.write.mock_calls[nextcall], call(b"\x82\x67\x02", 3)
412          )
413          nextcall += 1
414  
415          # Setting channel to non default
416          m.send(NoteOn(0x6C, 0x7F), channel=9)
417          self.assertEqual(
418              mockedPortOut.write.mock_calls[nextcall], call(b"\x99\x6c\x7f", 3)
419          )
420          nextcall += 1
421  
422          m.send(NoteOff(0x6C, 0x7F), channel=9)
423          self.assertEqual(
424              mockedPortOut.write.mock_calls[nextcall], call(b"\x89\x6c\x7f", 3)
425          )
426          nextcall += 1
427  
428      def test_send_badnotes(self):
429          mockedPortOut = Mock()
430  
431          m = adafruit_midi.MIDI(midi_out=mockedPortOut, out_channel=2)
432  
433          # Test sending some NoteOn and NoteOff to various channels
434          nextcall = 0
435          m.send(NoteOn(60, 0x7F))
436          self.assertEqual(
437              mockedPortOut.write.mock_calls[nextcall], call(b"\x92\x3c\x7f", 3)
438          )
439          nextcall += 1
440          with self.assertRaises(ValueError):
441              m.send(NoteOn(64, 0x80))  # Velocity > 127 - illegal value
442  
443          with self.assertRaises(ValueError):
444              m.send(NoteOn(67, -1))
445  
446          # test after exceptions to ensure sending is still ok
447          m.send(NoteOn(72, 0x7F))
448          self.assertEqual(
449              mockedPortOut.write.mock_calls[nextcall], call(b"\x92\x48\x7f", 3)
450          )
451          nextcall += 1
452  
453      def test_send_basic_sequences(self):
454          # def printit(buffer, len):
455          #    print(buffer[0:len])
456          mockedPortOut = Mock()
457          # mockedPortOut.write = printit
458  
459          m = adafruit_midi.MIDI(midi_out=mockedPortOut, out_channel=2)
460  
461          # Test sending some NoteOn and NoteOff to various channels
462          nextcall = 0
463          # Test sequences with list syntax and pass a tuple too
464          note_list = [NoteOn(0x6C, 0x51), NoteOn(0x70, 0x52), NoteOn(0x73, 0x53)]
465          note_tuple = tuple(note_list)
466          m.send(note_list, channel=10)
467          self.assertEqual(
468              mockedPortOut.write.mock_calls[nextcall],
469              call(b"\x9a\x6c\x51\x9a\x70\x52\x9a\x73\x53", 9),
470              "The implementation writes in one go, single 9 byte write expected",
471          )
472          nextcall += 1
473          m.send(note_tuple, channel=11)
474          self.assertEqual(
475              mockedPortOut.write.mock_calls[nextcall],
476              call(b"\x9b\x6c\x51\x9b\x70\x52\x9b\x73\x53", 9),
477              "The implementation writes in one go, single 9 byte write expected",
478          )
479          nextcall += 1
480  
481      def test_termination_with_random_data(self):
482          """Test with a random stream of bytes to ensure that the parsing code
483          termates and returns, i.e. does not go into any infinite loops.
484          """
485          c = 0
486          random.seed(303808)
487          raw_data = bytearray([random.randint(0, 255) for i in range(50000)])
488          m = MIDI_mocked_receive(c, raw_data, [len(raw_data)])
489  
490          noinfiniteloops = False
491          for unused in range(len(raw_data)):  # pylint: disable=unused-variable
492              m.receive()  # not interested in returned tuple
493  
494          noinfiniteloops = True  # interested in getting to here
495          self.assertTrue(noinfiniteloops)
496  
497  
498  if __name__ == "__main__":
499      unittest.main(verbosity=verbose)