Repository: cuthbertLab/music21
Pull request #687 (2020-12-01 22:12:35)

Improve readability of parsing PITCH_BEND and POLYPHONIC_KEY_PRESSURE messages
Noticed while debugging that the `else` case was capturing more than NOTE_ON and NOTE_OFF. So I looked up what was being captured and wrote cases for them. Needs a test file; none of the .midi files in the repo have PITCH_BEND messages, for instance.

coveralls: 
[![Coverage Status](https://coveralls.io/builds/35375424/badge)](https://coveralls.io/builds/35375424)

Coverage decreased (-0.004%) to 90.965% when pulling **00f14292b2f0fbc5374c2fc54fdff92aba966103 on jacobtylerwalls:channel-voice-messages** into **9a85ee5abc1161acb9de9179f3dea43a26dc5024 on cuthbertLab:master**.

mscuthbert: It is possible to generate a MIDI file with PITCH_BEND messages just by writing from music21 a score with quarter tones or other microtones -- that would at least give some data for a test.  THANKS!
jacobtylerwalls: Coveralls observed that the PITCH_BEND case was already tested by the doctest for MidiFile:
https://github.com/cuthbertLab/music21/blob/506b28c109360d3054604dff77468da184ddcb86/music21/midi/__init__.py#L1235-L1239

Patch:
diff --git a/.github/scripts/install_ubuntu_deps.sh b/.github/scripts/install_ubuntu_deps.sh
new file mode 100755
index 000000000..a62ac93a9
--- /dev/null
+++ b/.github/scripts/install_ubuntu_deps.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+set -e  # error if anything returns non-zero exit code
+
+# needed for some tests that add Desktop
+mkdir ~/Desktop
+
+sudo apt-get install -y libpng-dev
+sudo apt-get install -y python-qt4
+wget -q http://lilypond.org/downloads/binaries/linux-64/lilypond-2.19.83-1.linux-64.sh
+sh lilypond-2.19.83-1.linux-64.sh --batch
+export PATH=/home/runner/bin:$PATH
+pip3 install -r requirements.txt
+pip3 install coveralls
+pip3 install scipy
+pip3 install python-Levenshtein
+pip3 install setuptools
+pip3 install coverage
+cd music21
+python -m compileall music21
diff --git a/.github/workflows/maincheck.yml b/.github/workflows/maincheck.yml
new file mode 100644
index 000000000..18b4dd013
--- /dev/null
+++ b/.github/workflows/maincheck.yml
@@ -0,0 +1,29 @@
+name: maincheck
+
+on:
+    push:
+        branches:
+        - master
+        - '*CI*'
+    pull_request:
+        branches:
+        - '*'
+
+jobs:
+  run_tests:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: [3.6, 3.7, 3.8, 3.9]
+    steps:
+    - uses: actions/setup-python@v2
+      with:
+        python-version: ${{ matrix.python-version }}
+    - uses: actions/checkout@v2
+    - name: Install Ubuntu dependencies
+      run: ./.github/scripts/install_ubuntu_deps.sh
+    - name: Setup Lilypond
+      run: python -c 'from music21 import environment; environment.UserSettings()["lilypondPath"] = "/home/runner/bin/lilypond"'
+    - name: Run Main Test script
+      run: python -c 'from music21.test.testSingleCoreAll import ciMain as ci; ci()'
+
diff --git a/.github/workflows/pythonpylint.yml b/.github/workflows/pythonpylint.yml
index c3a0bf25a..d9a958d84 100644
--- a/.github/workflows/pythonpylint.yml
+++ b/.github/workflows/pythonpylint.yml
@@ -8,14 +8,14 @@ on:
   pull_request:
     branches:
       - '*'
-        
+
 jobs:
   lint:
     runs-on: ubuntu-latest
     strategy:
       max-parallel: 4
       matrix:
-        python-version: [3.7]
+        python-version: [3.8]
 
     steps:
     - uses: actions/checkout@v1
@@ -38,7 +38,7 @@ jobs:
     strategy:
       max-parallel: 4
       matrix:
-        python-version: [3.7]
+        python-version: [3.8]
 
     steps:
     - uses: actions/checkout@v1
diff --git a/.travis.yml b/.travis.yml
index 37878ed73..aedc25a02 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -5,12 +5,6 @@ matrix:
         - python: 3.6
           dist: xenial
           sudo: false
-        - python: 3.7
-          dist: xenial
-          sudo: true
-        - python: 3.8
-          dist: xenial
-          sudo: true
 
 services:
   - xvfb
@@ -38,7 +32,7 @@ install:
     - python -m compileall music21
 
 script:
-    - python -c 'from music21.test.testSingleCoreAll import travisMain as tm; tm()'
+    - python -c 'from music21.test.testSingleCoreAll import ciMain as ci; ci()'
 
 after_success:
     - if [ "$TRAVIS_PYTHON_VERSION" == "3.6" ]; then coveralls; fi
diff --git a/music21/key.py b/music21/key.py
index 30ece2582..2081cf155 100644
--- a/music21/key.py
+++ b/music21/key.py
@@ -1072,9 +1072,12 @@ class Key(KeySignature, scale.DiatonicScale):
                        method='correlationCoefficient',
                        *args,
                        **keywords):
-        '''Provide a measure of tonal ambiguity for Key determined with one of many methods.
+        '''
+        Provide a measure of tonal ambiguity for Key
+        determined with one of many methods.
 
-        The `correlationCoefficient` assumes that the alternateInterpretations list has
+        The `correlationCoefficient` assumes that the
+        alternateInterpretations list has
         been filled from the use of a KeyWeightKeyAnalysis subclass.
 
         >>> littlePiece = converter.parse('tinyNotation: 4/4 c4 d e f g a b cc ee gg ee cc')
diff --git a/music21/midi/__init__.py b/music21/midi/__init__.py
index 009fce244..f7d007e7a 100644
--- a/music21/midi/__init__.py
+++ b/music21/midi/__init__.py
@@ -829,12 +829,20 @@ class MidiEvent:
                 self.parameter1 = byte1  # this is the controller id
                 self.parameter2 = byte2  # this is the controller value
             return midiBytes[3:]
-        else:
-            # NOTE_ON and NOTE_OFF
+        elif self.type == ChannelVoiceMessages.PITCH_BEND:
+            self.parameter1 = byte1  # least significant byte
+            self.parameter2 = byte2  # most significant byte
+            return midiBytes[3:]
+        elif self.type in (ChannelVoiceMessages.NOTE_ON, ChannelVoiceMessages.NOTE_OFF):
             # next two bytes:  pitch, velocity
             self.pitch = byte1
             self.velocity = byte2
             return midiBytes[3:]
+        elif self.type == ChannelVoiceMessages.POLYPHONIC_KEY_PRESSURE:
+            self.parameter1 = byte1  # pitch
+            self.parameter2 = byte2  # pressure
+            return midiBytes[3:]
+        raise TypeError(f'expected ChannelVoiceMessage, got {self.type}')  # pragma: no cover
 
     def read(self, midiBytes):
         r'''
@@ -1938,6 +1946,33 @@ class Test(unittest.TestCase):
         #    print(n, n.quarterLength)
         # s.show()
 
+    def testReadPolyphonicKeyPressure(self):
+        from music21 import midi
+
+        mt = midi.MidiTrack()
+        # Example string from MidiTrack doctest
+        mt.read(b'MTrk\x00\x00\x00\x16\x00\xff\x03\x00\x00'
+                + b'\xe0\x00@\x00\x90CZ\x88\x00\x80C\x00\x88\x00\xff/\x00')
+
+        pressure = MidiEvent()
+        pressure.type = ChannelVoiceMessages.POLYPHONIC_KEY_PRESSURE
+        pressure.channel = 1
+        pressure.parameter1 = 60
+        pressure.parameter2 = 90
+        mt.events.insert(len(mt.events) - 2, mt.events[-2])  # copy DeltaTime event
+        mt.events.insert(len(mt.events) - 2, pressure)
+
+        writingFile = midi.MidiFile()
+        writingFile.tracks = [mt]
+        byteStr = writingFile.writestr()
+        readingFile = midi.MidiFile()
+        readingFile.readstr(byteStr)
+
+        pressureEventRead = [e for e in readingFile.tracks[0].events
+                            if e.type == ChannelVoiceMessages.POLYPHONIC_KEY_PRESSURE][0]
+        self.assertEqual(pressureEventRead.parameter1, 60)
+        self.assertEqual(pressureEventRead.parameter2, 90)
+
 
 # ------------------------------------------------------------------------------
 # define presented order in documentation
diff --git a/music21/midi/translate.py b/music21/midi/translate.py
index fb7a4265c..ceb8206b6 100644
--- a/music21/midi/translate.py
+++ b/music21/midi/translate.py
@@ -689,22 +689,39 @@ def midiEventsToInstrument(eventList):
     '''
     Convert a single MIDI event into a music21 Instrument object.
     '''
+    from music21 import midi as midiModule
+
     if not common.isListLike(eventList):
         event = eventList
     else:  # get the second event; first is delta time
         event = eventList[1]
 
     from music21 import instrument
+    decoded: str = ''
     try:
         if isinstance(event.data, bytes):
             # MuseScore writes MIDI files with null-terminated
             # instrument names.  Thus stop before the byte-0x0
             decoded = event.data.decode('utf-8').split('\x00')[0]
+            decoded = decoded.strip()
             i = instrument.fromString(decoded)
         else:
             i = instrument.instrumentFromMidiProgram(event.data)
     except (instrument.InstrumentException, UnicodeDecodeError):  # pragma: no cover
         i = instrument.Instrument()
+    # Set partName or instrumentName with literal value from parsing
+    if decoded:
+        # Except for lousy instrument names
+        if (
+            decoded.lower() in ('instrument', 'inst')
+            or decoded.lower().replace('instrument ', '').isdigit()
+            or decoded.lower().replace('inst ', '').isdigit()
+        ):
+            return i
+        elif event.type == midiModule.MetaEvents.SEQUENCE_TRACK_NAME:
+            i.partName = decoded
+        elif event.type == midiModule.MetaEvents.INSTRUMENT_NAME:
+            i.instrumentName = decoded
     return i
 
 
@@ -3218,6 +3235,12 @@ class Test(unittest.TestCase):
         self.assertIsInstance(instruments[0], instrument.Oboe)
         self.assertEqual(instruments[0].quarterLength, 0)
 
+        # Unrecognized instrument 'a'
+        dirLib = common.getSourceFilePath() / 'midi' / 'testPrimitive'
+        fp = dirLib / 'test15.mid'
+        s2 = converter.parse(fp)
+        self.assertEqual(s2.parts[0].partName, 'a')
+
     def testImportZeroDurationNote(self):
         '''
         Musescore places zero duration notes in multiple voice scenarios
@@ -3261,6 +3284,18 @@ class Test(unittest.TestCase):
         i = midiEventsToInstrument(event)
         self.assertIsInstance(i, instrument.Flute)
 
+    def testLousyInstrumentName(self):
+        from music21 import midi as midiModule
+
+        lousyNames = ('    ', 'Instrument 20', 'Instrument', 'Inst 2', 'instrument')
+        for name in lousyNames:
+            with self.subTest(name=name):
+                event = midiModule.MidiEvent()
+                event.data = bytes(name, 'utf-8')
+                event.type = midiModule.MetaEvents.INSTRUMENT_NAME
+                i = midiEventsToInstrument(event)
+                self.assertIsNone(i.instrumentName)
+
 
 # ------------------------------------------------------------------------------
 _DOC_ORDER = [streamToMidiFile, midiFileToStream]
diff --git a/music21/stream/__init__.py b/music21/stream/__init__.py
index 39a0d2346..b6fd35309 100644
--- a/music21/stream/__init__.py
+++ b/music21/stream/__init__.py
@@ -5756,6 +5756,9 @@ class Stream(core.StreamCoreMixin, base.Music21Object):
             groups = []
             for i, v in enumerate(srcObj.voices):
                 groups.append((v.flat, i))
+            elsNotOfVoice = srcObj.getElementsNotOfClass('Voice')
+            if len(elsNotOfVoice) > 0:
+                groups.insert(0, (elsNotOfVoice, None))
         else:  # create a single collection
             groups = [(srcObj, None)]
         # environLocal.printDebug(['offsetMap', groups])


Test patch:
diff --git a/music21/test/coverageM21.py b/music21/test/coverageM21.py
index 138329114..3e0651de1 100644
--- a/music21/test/coverageM21.py
+++ b/music21/test/coverageM21.py
@@ -35,7 +35,7 @@ exclude_lines = [
 
 def getCoverage(overrideVersion=False):
     if overrideVersion or sys.version_info.minor == 6:
-        # run on Py 3.6 -- to get Py 3.5/3.7 timing...
+        # run on Py 3.6 -- to get Py 3.7/3.8 timing...
         try:
             import coverage
             cov = coverage.Coverage(omit=omit_modules)
diff --git a/music21/test/testSingleCoreAll.py b/music21/test/testSingleCoreAll.py
index 9b6ae5300..30f31d011 100644
--- a/music21/test/testSingleCoreAll.py
+++ b/music21/test/testSingleCoreAll.py
@@ -119,8 +119,8 @@ def main(testGroup=('test',), restoreEnvironmentDefaults=False, limit=None, verb
     return returnCode
 
 
-def travisMain():
-    # the main call for travis-ci tests.
+def ciMain():
+    # the main call for ci tests.
     # exits with the returnCode
     returnCode = main(verbosity=1)
     sys.exit(returnCode)
diff --git a/music21/test/testStream.py b/music21/test/testStream.py
index b468682d0..9d6416a8a 100644
--- a/music21/test/testStream.py
+++ b/music21/test/testStream.py
@@ -3893,7 +3893,9 @@ class Test(unittest.TestCase):
         '''Testing voices making routines within make notation
         '''
         from music21 import stream
+        from music21.instrument import Xylophone
         s = stream.Stream()
+        s.insert(0, Xylophone())
         s.insert(0, note.Note('C4', quarterLength=8))
         s.repeatInsert(note.Note('b-4', quarterLength=0.5), [x * 0.5 for x in range(16)])
         s.repeatInsert(note.Note('f#5', quarterLength=2), [0, 2, 4, 6])
@@ -3912,6 +3914,9 @@ class Test(unittest.TestCase):
             for n in m.voices[1].notes:  # middle voice has beams
                 self.assertGreater(len(n.beams), 0)
 
+        # check instruments
+        self.assertIsInstance(sPost.getInstruments(recurse=True)[0], Xylophone)
+
     def testMakeNotationC(self):
         '''Test creating diverse, overlapping durations and notes
         '''