Repository: cuthbertLab/music21
Pull request #1778 (2025-05-16 01:57:03)

midi.translate improvements
Followup from #1769 gave me an opportunity to find places to improve midi translate.  Pinging @oxygen-dioxide since some changes took place since then

* Change `getNotesFromEvents()` from potentially O(n^2) to O(n) while retaining sort order.

* Pass encoding to parsing of instrument names in addition to lyrics (renamed "encoding_type" to "encoding" -- missed in prior review)

* create TimingNoteEvent which is a typed NamedTuple that keeps track of when a MidiEvent NoteOn started, stopped, and what the NoteOn event was.  Replaced the old tuple of tuples.

* Faster gathering of Chords (remove potential O(n^2) search).

* Remove unused special case for single-note streams.

* opFrac many offsets and durations in MIDI for absolutely correct tuplets, etc.

* Misc: Update braille.translate SegmentKey to typing.NamedTuple

* Misc: Search code for places where we were still casting to float for Python 2 division!

* Misc: Add typing to text/Trigram and some layout things

This is part of the non-backwards compatible MIDI.translate guts rewrite discussed on the music21 list.

Coverage % is expected to drop because the amount of code has been reduced a lot.


Patch:
diff --git a/music21/_version.py b/music21/_version.py
index 1f44314e2..7f4c69b00 100644
--- a/music21/_version.py
+++ b/music21/_version.py
@@ -50,7 +50,7 @@ so change it if a bug or new feature creates a problem with using old pickles.
 '''
 from __future__ import annotations
 
-__version__ = '9.6.0b4'
+__version__ = '9.6.0b5'
 
 def get_version_tuple(vv):
     v = vv.split('.')
diff --git a/music21/alpha/analysis/aligner.py b/music21/alpha/analysis/aligner.py
index 77e95d29b..4a95339ad 100644
--- a/music21/alpha/analysis/aligner.py
+++ b/music21/alpha/analysis/aligner.py
@@ -862,7 +862,7 @@ class StreamAligner:
             raise AlignmentTracebackException('Traceback of best alignment did not end properly')
 
         self.changesCount = Counter(elem[2] for elem in self.changes)
-        self.similarityScore = float(self.changesCount[ChangeOps.NoChange]) / len(self.changes)
+        self.similarityScore = self.changesCount[ChangeOps.NoChange] / len(self.changes)
 
     def showChanges(self, show=False):
         '''
diff --git a/music21/analysis/discrete.py b/music21/analysis/discrete.py
index 41a9124dc..6674ae6f2 100644
--- a/music21/analysis/discrete.py
+++ b/music21/analysis/discrete.py
@@ -407,7 +407,7 @@ class KeyWeightKeyAnalysis(DiscreteAnalysis):
             # environLocal.printDebug(['added likely key', likelyKeys[pc]])
         return likelyKeys
 
-    def _getDifference(self, keyResults, pcDistribution, weightType) -> None|list[int|float]:
+    def _getDifference(self, keyResults, pcDistribution, weightType) -> None|list[float]:
         '''
         Takes in a list of numerical probable key results and returns the
         difference of the top two keys.
@@ -416,14 +416,14 @@ class KeyWeightKeyAnalysis(DiscreteAnalysis):
         if keyResults is None:
             return None
 
-        solution: list[int|float] = [0.0] * 12
+        solution: list[float] = [0.0] * 12
         top = [0.0] * 12
         bottomRight = [0.0] * 12
         bottomLeft = [0.0] * 12
 
         toneWeights = self.getWeights(weightType)
-        profileAverage = float(sum(toneWeights)) / len(toneWeights)
-        histogramAverage = float(sum(pcDistribution)) / len(pcDistribution)
+        profileAverage = sum(toneWeights) / len(toneWeights)
+        histogramAverage = sum(pcDistribution) / len(pcDistribution)
 
         for i in range(len(solution)):
             for j in range(len(toneWeights)):
@@ -437,9 +437,9 @@ class KeyWeightKeyAnalysis(DiscreteAnalysis):
                     pcDistribution[j] - histogramAverage) ** 2)
 
                 if bottomRight[i] == 0 or bottomLeft[i] == 0:
-                    solution[i] = 0
+                    solution[i] = 0.0
                 else:
-                    solution[i] = float(top[i]) / ((bottomRight[i] * bottomLeft[i]) ** 0.5)
+                    solution[i] = float(top[i] / ((bottomRight[i] * bottomLeft[i]) ** 0.5))
         return solution
 
     def solutionLegend(self, compress=False):
diff --git a/music21/base.py b/music21/base.py
index 446b6d8e2..8627ae6a1 100644
--- a/music21/base.py
+++ b/music21/base.py
@@ -27,7 +27,7 @@ available after importing `music21`.
 <class 'music21.base.Music21Object'>
 
 >>> music21.VERSION_STR
-'9.6.0b4'
+'9.6.0b5'
 
 Alternatively, after doing a complete import, these classes are available
 under the module "base":
diff --git a/music21/braille/segment.py b/music21/braille/segment.py
index d994d3581..24d0c1656 100644
--- a/music21/braille/segment.py
+++ b/music21/braille/segment.py
@@ -147,8 +147,11 @@ MAX_ELEMENTS_IN_SEGMENT = 48  # 8 measures of 6 notes, etc. each
 
 _ThreeDigitNumber = namedtuple('_ThreeDigitNumber', ['hundreds', 'tens', 'ones'])
 
-SegmentKey = namedtuple('SegmentKey', ['measure', 'ordinal', 'affinity', 'hand'])
-SegmentKey.__new__.__defaults__ = (0, 0, None, None)
+class SegmentKey(t.NamedTuple):
+    measure: int = 0
+    ordinal: int = 0
+    affinity: Affinity|None = None
+    hand: str|None = None
 
 
 # ------------------------------------------------------------------------------
diff --git a/music21/converter/subConverters.py b/music21/converter/subConverters.py
index 828bd7373..7d93b8dd4 100644
--- a/music21/converter/subConverters.py
+++ b/music21/converter/subConverters.py
@@ -79,6 +79,7 @@ class SubConverter:
 
     def __init__(self, **keywords) -> None:
         self._stream: stream.Score|stream.Part|stream.Opus = stream.Score()
+        # TODO: unify keywords so that they are used by both parseFile and parseData
         self.keywords: dict[str, t.Any] = keywords
 
     def parseData(self, dataString, number: int|None = None):
@@ -1023,7 +1024,11 @@ class ConverterMidi(SubConverter):
     registerInputExtensions = ('mid', 'midi')
     registerOutputExtensions = ('mid',)
 
-    def parseData(self, strData, number=None):
+    def __init__(self, *, encoding='utf-8', **keywords):
+        self.encoding = encoding
+        super().__init__(**keywords)
+
+    def parseData(self, strData, number=None, *, encoding: str = ''):
         '''
         Get MIDI data from a binary string representation.
 
@@ -1033,13 +1038,22 @@ class ConverterMidi(SubConverter):
         `quantizePost` controls whether to quantize the output. (Default: True)
         `quarterLengthDivisors` allows for overriding the default quantization units
         in defaults.quantizationQuarterLengthDivisors. (Default: (4, 3)).
+
+        If encoding is not provided use the encoding of the Converter
+        (default "utf-8")
         '''
         from music21.midi import translate as midiTranslate
-        self.stream = midiTranslate.midiStringToStream(strData, **self.keywords)
+        self.stream = midiTranslate.midiStringToStream(
+            strData,
+            encoding=encoding or self.encoding,
+            **self.keywords
+        )
 
     def parseFile(self,
                   filePath: pathlib.Path|str,
                   number: int|None = None,
+                  *,
+                  encoding: str = '',
                   **keywords):
         '''
         Get MIDI data from a file path.
@@ -1050,9 +1064,17 @@ class ConverterMidi(SubConverter):
         `quantizePost` controls whether to quantize the output. (Default: True)
         `quarterLengthDivisors` allows for overriding the default quantization units
         in defaults.quantizationQuarterLengthDivisors. (Default: (4, 3)).
+
+        If encoding is not provided use the encoding of the Converter
+        (default "utf-8")
         '''
         from music21.midi import translate as midiTranslate
-        midiTranslate.midiFilePathToStream(filePath, inputM21=self.stream, **keywords)
+        midiTranslate.midiFilePathToStream(
+            filePath,
+            inputM21=self.stream,
+            encoding=encoding or self.encoding,
+            **keywords
+        )
 
     def write(self,
               obj,
diff --git a/music21/layout.py b/music21/layout.py
index 31324f001..b748cde1c 100644
--- a/music21/layout.py
+++ b/music21/layout.py
@@ -176,26 +176,29 @@ class ScoreLayout(LayoutBase):
         if staffLayoutList is not None:
             self.staffLayoutList = staffLayoutList
 
-    def tenthsToMillimeters(self, tenths):
+    def tenthsToMillimeters(self, tenths: int|float) -> int|float:
         '''
         given the scalingMillimeters and scalingTenths,
         return the value in millimeters of a number of
         musicxml "tenths" where a tenth is a tenth of the distance
-        from one staff line to another
+        from one staff line to another.
 
         returns 0.0 if either of scalingMillimeters or scalingTenths
         is undefined.
 
-
         >>> sl = layout.ScoreLayout(scalingMillimeters=2.0, scalingTenths=10)
-        >>> print(sl.tenthsToMillimeters(10))
+        >>> sl.tenthsToMillimeters(10)
         2.0
-        >>> print(sl.tenthsToMillimeters(17))  # printing to round
+
+        Numbers are rounded to a maximum of 6 digits (but because they are floats,
+        there may be inaccuracies.
+
+        >>> sl.tenthsToMillimeters(17)
         3.4
         '''
         if self.scalingMillimeters is None or self.scalingTenths is None:
             return 0.0
-        millimetersPerTenth = float(self.scalingMillimeters) / self.scalingTenths
+        millimetersPerTenth = self.scalingMillimeters / self.scalingTenths
         return round(millimetersPerTenth * tenths, 6)
 
 
diff --git a/music21/midi/base.py b/music21/midi/base.py
index e5e3fbb91..8717dceb7 100644
--- a/music21/midi/base.py
+++ b/music21/midi/base.py
@@ -1283,6 +1283,9 @@ class MidiEvent(prebase.ProtoM21Object):
         >>> me2.channel = 12
         >>> me1.matchedNoteOff(me2)
         False
+
+        Note that this method is no longer used in MIDI Parsing
+        because it is inefficient.
         '''
         if self.isNoteOn() and other.isNoteOff():
             if self.pitch == other.pitch and self.channel == other.channel:
diff --git a/music21/midi/translate.py b/music21/midi/translate.py
index 4e78c2b70..898fca092 100644
--- a/music21/midi/translate.py
+++ b/music21/midi/translate.py
@@ -23,6 +23,7 @@ import warnings
 
 from music21 import chord
 from music21 import common
+from music21.common.numberTools import opFrac
 from music21 import defaults
 from music21 import duration
 from music21 import dynamics
@@ -37,6 +38,7 @@ from music21 import percussion
 from music21 import pitch
 from music21 import stream
 from music21 import tempo
+from music21 import volume
 
 from music21.midi.base import (
     MidiTrack, DeltaTime, MidiFile, MidiEvent,
@@ -48,6 +50,7 @@ from music21.midi.percussion import MIDIPercussionException, PercussionMapper
 
 if t.TYPE_CHECKING:
     from music21 import base
+    from music21.common.types import OffsetQLIn
 
 
 environLocal = environment.Environment('midi.translate')
@@ -55,6 +58,11 @@ PERCUSSION_MAPPER = PercussionMapper()
 
 NotRestType = t.TypeVar('NotRestType', bound=note.NotRest)
 
+class TimedNoteEvent(t.NamedTuple):
+    onTime: int
+    offTime: int
+    event: MidiEvent
+
 # ------------------------------------------------------------------------------
 class TranslateException(exceptions21.Music21Exception):
     pass
@@ -67,7 +75,7 @@ class TranslateWarning(UserWarning):
 # Durations
 
 
-def offsetToMidiTicks(o, addStartDelay=False):
+def offsetToMidiTicks(o: OffsetQLIn, addStartDelay: bool = False) -> int:
     '''
     Helper function to convert a music21 offset value to MIDI ticks,
     depends on *defaults.ticksPerQuarter* and *defaults.ticksAtStart*.
@@ -97,7 +105,7 @@ def offsetToMidiTicks(o, addStartDelay=False):
     return ticks
 
 
-def durationToMidiTicks(d):
+def durationToMidiTicks(d: duration.Duration) -> int:
     # noinspection PyShadowingNames
     '''
     Converts a :class:`~music21.duration.Duration` object to midi ticks.
@@ -126,9 +134,11 @@ def durationToMidiTicks(d):
     return int(round(d.quarterLength * defaults.ticksPerQuarter))
 
 
-def ticksToDuration(ticks,
-                    ticksPerQuarter: int = defaults.ticksPerQuarter,
-                    inputM21DurationObject=None):
+def ticksToDuration(
+    ticks: int,
+    ticksPerQuarter: int = defaults.ticksPerQuarter,
+    inputM21DurationObject: duration.Duration|None = None
+) -> duration.Duration:
     # noinspection PyShadowingNames
     '''
     Converts a number of MIDI Ticks to a music21 duration.Duration() object.
@@ -178,7 +188,7 @@ def ticksToDuration(ticks,
         d = inputM21DurationObject
 
     # given a value in ticks
-    d.quarterLength = float(ticks) / ticksPerQuarter
+    d.quarterLength = opFrac(ticks / ticksPerQuarter)
     return d
 
 
@@ -191,7 +201,7 @@ def getStartEvents(
     channel: int = 1,
     instrumentObj: instrument.Instrument|None = None,
     *,
-    encoding_type: str = 'utf-8',
+    encoding: str = 'utf-8',
 ) -> list[DeltaTime|MidiEvent]:
     '''
     Returns a list of midi.MidiEvent objects found at the beginning of a track.
@@ -230,7 +240,7 @@ def getStartEvents(
 
     me = MidiEvent(midiTrack, channel=channel)
     me.type = MetaEvents.SEQUENCE_TRACK_NAME
-    me.data = partName.encode(encoding_type, 'ignore')
+    me.data = partName.encode(encoding, 'ignore')
     events.append(me)
 
     # additional allocation of instruments may happen elsewhere
@@ -342,26 +352,18 @@ def _constructOrUpdateNotRestSubclass(
     return nr
 
 def midiEventsToNote(
-    eventTuple: tuple[tuple[int, MidiEvent],
-                      tuple[int, MidiEvent]],
+    timedNoteEvent: TimedNoteEvent,
     ticksPerQuarter: int = defaults.ticksPerQuarter,
 ) -> note.Note|note.Unpitched:
     # noinspection PyShadowingNames
     '''
-    Convert from a tuple of two tuples of an int and a midi.MidiEvent objects
-    to a music21 Note.  The events should be arranged as follows:
-
-        ((int timeStart, midiEvent1), (int timeEnd, midiEvent2))
-
-    It is assumed, but not checked, that midiEvent2 is an
-    appropriate Note_Off command.  Thus, only three elements are really needed.
+    Convert a TimedNoteEvent to a music21.note.Note or a music21.note.Unpitched.
+    A timed note event is a tuple of (onTime, offTime, MidiEvent) where the
+    MidiEvent is the Note On.  (The Note Off event is not used).
 
-    N.B. this takes in a list of music21 MidiEvent objects so see [...] on how to
-    convert raw MIDI data to MidiEvent objects
+    This method is called as part of the midiToStream() conversion.
 
-    In this example, we start a NOTE_ON event at offset 1.0 that lasts
-    for 2.0 quarter notes until we
-    send a zero-velocity NOTE_ON (=NOTE_OFF) event for the same pitch.
+    In this example, we start a NOTE_ON event at offset 1.0 (at the standard 10080 to quarter)
 
     >>> mt = midi.MidiTrack(1)
     >>> dt1 = midi.DeltaTime(mt)
@@ -372,15 +374,21 @@ def midiEventsToNote(
     >>> me1.pitch = 45
     >>> me1.velocity = 94
 
+
+    This lasts until we send a NOTE_OFF event at offset 2.0.
+
     >>> dt2 = midi.DeltaTime(mt)
     >>> dt2.time = 20160
-
     >>> me2 = midi.MidiEvent(mt)
     >>> me2.type = midi.ChannelVoiceMessages.NOTE_ON
     >>> me2.pitch = 45
     >>> me2.velocity = 0
 
-    >>> n = midi.translate.midiEventsToNote(((dt1.time, me1), (dt2.time, me2)))
+    Another system will package dt1, dt2 and me1 into a TimedNoteEvent tuple.
+    In this example, me2 is not used.
+
+    >>> tne1 = midi.translate.TimedNoteEvent(dt1.time, dt2.time, me1)
+    >>> n = midi.translate.midiEventsToNote(tne1)
     >>> n.pitch
     <music21.pitch.Pitch A2>
     >>> n.duration.quarterLength
@@ -388,10 +396,10 @@ def midiEventsToNote(
     >>> n.volume.velocity
     94
 
-    If channel is 10, an Unpitched element is returned.
+    If the channel is 10, an Unpitched element is returned.
 
     >>> me1.channel = 10
-    >>> unp = midi.translate.midiEventsToNote(((dt1.time, me1), (dt2.time, me2)))
+    >>> unp = midi.translate.midiEventsToNote(tne1)
     >>> unp
     <music21.note.Unpitched 'Tom-Tom'>
 
@@ -404,7 +412,7 @@ def midiEventsToNote(
     :class:`~music21.instrument.UnpitchedPercussion` instance is given:
 
     >>> me1.pitch = 1
-    >>> unp = midi.translate.midiEventsToNote(((dt1.time, me1), (dt2.time, me2)))
+    >>> unp = midi.translate.midiEventsToNote(tne1)
     >>> unp.storedInstrument
     <music21.instrument.UnpitchedPercussion 'Percussion'>
 
@@ -412,9 +420,9 @@ def midiEventsToNote(
         :class:`~music21.note.Unpitched` instance if the event is on Channel 10.
     * Changed in v8: `inputM21` is no longer supported.
         The only supported usage now is two tuples.
+    * Changed in v9.7: Expects a single TimedNoteEvent
     '''
-    tOn, eOn = eventTuple[0]
-    tOff, unused_eOff = eventTuple[1]
+    tOn, tOff, eOn = timedNoteEvent
 
     returnClass: type[note.Unpitched]|type[note.Note]
     if eOn.channel == 10:
@@ -554,27 +562,15 @@ def noteToMidiEvents(
 # ------------------------------------------------------------------------------
 # Chords
 def midiEventsToChord(
-    eventList: Sequence[tuple[tuple[int, MidiEvent],
-                              tuple[int, MidiEvent]]],
+    timedNoteList: Sequence[TimedNoteEvent],
     ticksPerQuarter: int = defaults.ticksPerQuarter,
 ) -> chord.ChordBase:
     # noinspection PyShadowingNames
     '''
-    Creates a Chord from a list of :class:`~music21.midi.DeltaTime` or integers
-    and :class:`~music21.midi.MidiEvent` objects grouped as follows::
+    Creates a Chord from a list of TimedNoteEvents which
+    store the noteOn tick, noteOff tick, and Note-On MidiEvent for each note.
 
-        [((time_pitch1_on, pitch1_note_on_event),
-          (time_pitch1_off, pitch1_note_off_event)
-          ),
-         ((time_pitch2_on, pitch2_note_on_event),
-          (time_pitch2_off, pitch2_note_off_event)
-          ),
-
-    (etc.)
-
-    :func:`~music21.midi.translate.midiEventsToNote`.
-
-    All DeltaTime objects except the first (for the first note on)
+    Timings of all objects except the first (for the first note on)
     and last (for the last note off) are ignored.
 
     >>> mt = midi.MidiTrack(1)
@@ -592,7 +588,7 @@ def midiEventsToChord(
     >>> me3 = midi.MidiEvent(mt)
     >>> me3.type = midi.ChannelVoiceMessages.NOTE_OFF
 
-    The pitch of the NOTE_OFF events are not checked by this function.  They
+    The pitches of the NOTE_OFF events are not checked by this function.  They
     are assumed to have been aligned by the previous parser.
 
     >>> me3.pitch = 45
@@ -612,47 +608,46 @@ def midiEventsToChord(
     >>> me4.pitch = 46
     >>> me4.velocity = 0
 
-    >>> c = midi.translate.midiEventsToChord([((dt1.time, me1), (dt3.time, me3)),
-    ...                                       ((dt2.time, me2), (dt4.time, me4))])
+    >>> tne1 = midi.translate.TimedNoteEvent(dt1.time, dt3.time, me1)
+    >>> tne2 = midi.translate.TimedNoteEvent(dt2.time, dt4.time, me2)
+
+    >>> c = midi.translate.midiEventsToChord([tne1, tne2])
     >>> c
     <music21.chord.Chord A2 B-2>
     >>> c.duration.quarterLength
     2.0
 
-    If the channel is set to 10, then a PercussionChord is returned:
+    If the channel of the last element is set to 10, then a PercussionChord is returned:
 
     >>> me2.channel = 10
-    >>> midi.translate.midiEventsToChord([((dt1.time, me1), (dt3.time, me3)),
-    ...                                   ((dt2.time, me2), (dt4.time, me4))])
+    >>> midi.translate.midiEventsToChord([tne1, tne2])
     <music21.percussion.PercussionChord [Tom-Tom Hi-Hat Cymbal]>
 
     * Changed in v7: Uses the last DeltaTime in the list to get the end time.
     * Changed in v7.3: Returns a :class:`~music21.percussion.PercussionChord` if
       any event is on channel 10.
     * Changed in v8: inputM21 is no longer supported.  Flat list format is removed.
+    * Changed in v9.7: expects a list of TimedNoteEvents
     '''
     tOn: int = 0  # ticks
     tOff: int = 0  # ticks
 
-    from music21 import volume
     pitches: list[pitch.Pitch] = []
     volumes = []
 
-    firstOn: MidiEvent = eventList[0][0][1]
+    firstOn: MidiEvent = timedNoteList[0].event
     any_channel_10 = False
     # this is a format provided by the Stream conversion of
     # midi events; it pre-groups events for a chord together in nested pairs
     # of abs start time and the event object
-    for onPair, offPair in eventList:
-        tOn, eOn = onPair
+    for tOn, tOff, eOn in timedNoteList:
         if eOn.channel == 10:
             any_channel_10 = True
-        tOff, unused_eOff = offPair
         p = pitch.Pitch()
         p.midi = eOn.pitch
         pitches.append(p)
         v = volume.Volume(velocity=eOn.velocity)
-        v.velocityIsRelative = False  # velocity is absolute coming from
+        v.velocityIsRelative = False  # velocity is absolute coming from MIDI
         volumes.append(v)
 
     returnClass: type[percussion.PercussionChord]|type[chord.Chord]
@@ -660,6 +655,7 @@ def midiEventsToChord(
         returnClass = percussion.PercussionChord
     else:
         returnClass = chord.Chord
+
     c = _constructOrUpdateNotRestSubclass(
         firstOn,
         tOn,
@@ -852,7 +848,9 @@ def instrumentToMidiEvents(
 # Meta events
 
 def midiEventsToInstrument(
-    eventList: MidiEvent|tuple[int, MidiEvent]
+    eventList: MidiEvent|tuple[int, MidiEvent],
+    *,
+    encoding: str = 'utf-8',
 ) -> instrument.Instrument:
     '''
     Convert a single MIDI event into a music21 Instrument object.
@@ -884,7 +882,7 @@ def midiEventsToInstrument(
         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 = event.data.decode(encoding).split('\x00')[0]
             decoded = decoded.strip()
             i = instrument.fromString(decoded)
         elif event.channel == 10 and isinstance(event.data, int):
@@ -1321,7 +1319,6 @@ def getPacketFromMidiEvent(
     return post
 
 
-# noinspection PyTypeChecker
 def elementToMidiEventList(
     el: base.Music21Object
 ) -> list[MidiEvent|DeltaTime]|None:
@@ -1339,9 +1336,8 @@ def elementToMidiEventList(
     [<music21.midi.MidiEvent NOTE_ON, track=None, channel=1, pitch=60, velocity=90>,
      <music21.midi.MidiEvent NOTE_OFF, track=None, channel=1, pitch=60, velocity=0>]
     '''
-    # remove Jetbrains "noinspection PyTypeChecker" when
-    # https://youtrack.jetbrains.com/issue/PY-48011/Pattern-Matching-Type-inference
-    # is fixed.  Until then, don't implement more Structural Pattern Matching.
+    # "Code is unreachable" PyCharm bug.
+    # https://youtrack.jetbrains.com/issue/PY-79770
     match el:
         case note.Rest():
             return None
@@ -1368,7 +1364,7 @@ def elementToMidiEventList(
             # note: tempo indications need to be in channel one for most playback
             return tempoToMidiEvents(el, includeDeltaTime=False)
         case instrument.Instrument():
-            # first instrument will have been gathered above with get start elements
+            # the first instrument will have been gathered above with get start elements
             return instrumentToMidiEvents(el, includeDeltaTime=False)
         case _:
             # other objects may have already been added
@@ -1389,7 +1385,7 @@ def streamToPackets(
 
     In converting from a Stream to MIDI, this is called first,
     resulting in a collection of packets by offset.
-    Then, packets to events is called.
+    Then, getPacketFromMidiEvent is called.
     '''
     # store all events by offset without delta times
     # as (absTime, event)
@@ -1719,8 +1715,8 @@ def filterPacketsByTrackId(
 
 
 def packetsToDeltaSeparatedEvents(
-        packets: list[dict[str, t.Any]],
-        midiTrack: MidiTrack,
+    packets: list[dict[str, t.Any]],
+    midiTrack: MidiTrack,
 ) -> list[MidiEvent|DeltaTime]:
     '''
     Given a list of packets (which already contain MidiEvent objects)
@@ -1833,52 +1829,92 @@ def getTimeForEvents(
 
 
 def getNotesFromEvents(
-    events: list[tuple[int, MidiEvent]]
-) -> list[tuple[tuple[int, MidiEvent],
-                tuple[int, MidiEvent]]]:
+    timedEvents: list[tuple[int, MidiEvent]]
+) -> list[TimedNoteEvent]:
     '''
-    Returns a list of Tuples of MIDI events that are pairs of note-on and
-    note-off events.
-    '''
-    notes = []  # store pairs of pairs
-    memo = set()   # store already matched note off
-    for i, eventTuple in enumerate(events):
-        if i in memo:
-            continue
-        unused_t, e = eventTuple
-        # for each note on event, we need to search for a match in all future
-        # events
-        if not e.isNoteOn():
-            continue
-        match = None
-        # environLocal.printDebug(['midiTrackToStream(): isNoteOn', e])
-        for j in range(i + 1, len(events)):
-            if j in memo:
-                continue
-            unused_tSub, eSub = events[j]
-            if e.matchedNoteOff(eSub):
-                memo.add(j)
-                match = i, j
-                break
-        if match is not None:
-            i, j = match
-            pairs = (events[i], events[j])
-            notes.append(pairs)
-        else:
-            pass
-            # environLocal.printDebug([
-            #    'midiTrackToStream(): cannot find a note off for a note on', e])
-    return notes
+    Takes in a list of tuples of (tickTime, MidiEvent) and returns a list of
+    TimedNoteEvent objects which contain the note on time, the note off time,
+    and the MidiEvent of the note on.  (The note off MidiEvent is not included)
+
+    If timedEvents is sorted in non-decreasing order of tickTime, then the
+    output here will also be sorted in non-decreasing order of note on time.
+
+    Example: here are two pitches, represented as note on and note off events:
+
+    >>> CVM = midi.ChannelVoiceMessages
+    >>> ev1on = midi.MidiEvent(type=CVM.NOTE_ON)
+    >>> ev1on.pitch = 60
+    >>> ev2on = midi.MidiEvent(type=CVM.NOTE_ON)
+    >>> ev2on.pitch = 62
+    >>> ev1off = midi.MidiEvent(type=CVM.NOTE_OFF)
+    >>> ev1off.pitch = 60
+    >>> ev2off = midi.MidiEvent(type=CVM.NOTE_OFF)
+    >>> ev2off.pitch = 62
+
+    >>> events = [(0, ev1on), (1, ev2on), (2, ev2off), (3, ev1off)]
 
-def getLyricsFromEvents(
+    >>> notes = midi.translate.getNotesFromEvents(events)
+    >>> len(notes)
+    2
+    >>> notes
+    [TimedNoteEvent(onTime=0, offTime=3, event=<music21.midi.MidiEvent NOTE_ON...>),
+     TimedNoteEvent(onTime=1, offTime=2, event=<music21.midi.MidiEvent NOTE_ON...>)]
+    >>> (notes[0].event.pitch, notes[1].event.pitch)
+    (60, 62)
+    '''
+    notes: list[TimedNoteEvent] = []  # store tuples of (onTime, offTime, midiEvent)
+
+    # matching pitch and channel to off time
+    # Q: do we need to match channel? These should already be separated by channel, right?
+    awaitingNoteOn: dict[tuple[int|None, int], int] = {}
+    # pitch will never be None for noteOn or noteOff, but mypy does not know that.
+
+    # Iterate backwards: we see note offs first and then await
+    # their corresponding note ons.  We do this so that the note list is
+    # sorted by (decreasing) note-on time, not note-off time, which is
+    # the order we will expect, but without having to do an O(n log n) sort
+    # at the end.
+    for time, event in timedEvents[::-1]:
+        if event.isNoteOff():
+            awaitingNoteOn[event.pitch, event.channel] = time
+        elif event.isNoteOn():
+            try:
+                # we had .pop() instead of dict access which would be quite a bit
+                # better to have, but there are a number of cases, including the
+                # test09 midi set where there are multiple note ons at the same
+                # point with multiple note offs at the same time point for the
+                # same pitch and channel.  Better to have the notes turned off
+                # from some previous note off event then to lose it.
+                offTime = awaitingNoteOn[event.pitch, event.channel]
+                tne = TimedNoteEvent(time, offTime, event)
+                notes.append(tne)
+            except KeyError:  # pragma: no cover
+                pass
+                # raise TranslateException(
+                #     f' cannot find note off for pitch {event.pitch} and channel {event.channel}'
+                # )
+
+    # could also raise a warning on note offs without a note on.
+    return notes[::-1]  # back to increasing order.
+
+
+def lyricTimingsFromEvents(
     events: list[tuple[int, MidiEvent]],
-    encoding_type: str = 'utf-8',
+    *,
+    encoding: str = 'utf-8',
 ) -> dict[int, str]:
+    '''
+    From a list of timed events, that is a tuple of a tick time and a MidiEvent
+    Return a dictionary mapping a tick time to a string representing a lyric.
+
+    If more than one lyric is found at a given tick time, the last one found is
+    stored.
+    '''
     lyrics: dict[int, str] = {}
     for time, e in events:
         if e.type == MetaEvents.LYRIC and isinstance(e.data, bytes):
             try:
-                lyrics[time] = e.data.decode(encoding_type)
+                lyrics[time] = e.data.decode(encoding)
             except UnicodeDecodeError:
                 warnings.warn(
                     f'Unable to decode lyrics from {e}',
@@ -1887,12 +1923,23 @@ def getLyricsFromEvents(
 
 
 def getMetaEvents(
-    events: list[tuple[int, MidiEvent]]
+    events: list[tuple[int, MidiEvent]],
+    *,
+    encoding: str = 'utf-8',
 ) -> list[tuple[int, base.Music21Object]]:
-    metaEvents: list[tuple[int, base.Music21Object]] = []  # store pairs of abs time, m21 object
+    '''
+    Translate MidiEvents whose type is a MetaEvent into Music21Objects.
+
+    Note: this does not translate MetaEvent.LYRIC, since that becomes a
+    bare string.
+
+    New in 9.7: add encoding
+    '''
+    # store pairs of abs time, m21 object
+    metaEvents: list[tuple[int, base.Music21Object]] = []
     last_program: int = -1
     for timeEvent, e in events:
-        metaObj = None
+        metaObj: base.Music21Object|None = None
         if e.type == MetaEvents.TIME_SIGNATURE:
             # time signature should be 4 bytes
             metaObj = midiEventsToTimeSignature(e)
@@ -1903,7 +1950,7 @@ def getMetaEvents(
         elif e.type in (MetaEvents.INSTRUMENT_NAME, MetaEvents.SEQUENCE_TRACK_NAME):
             # midiEventsToInstrument() WILL NOT have knowledge of the current
             # program, so set it here
-            metaObj = midiEventsToInstrument(e)
+            metaObj = midiEventsToInstrument(e, encoding=encoding)
             if last_program != -1:
                 # Only update if we have had an initial PROGRAM_CHANGE
                 metaObj.midiProgram = last_program
@@ -1911,21 +1958,23 @@ def getMetaEvents(
             # midiEventsToInstrument() WILL set the program on the instance
             metaObj = midiEventsToInstrument(e)
             last_program = e.parameter1
-        elif e.type == MetaEvents.MIDI_PORT:
-            pass
-        else:
-            pass
+        # elif e.type == MetaEvents.MIDI_PORT:
+        #     pass
+        # else:
+        #     pass
+
         if metaObj:
             pair = (timeEvent, metaObj)
             metaEvents.append(pair)
 
     return metaEvents
 
-def insertConductorEvents(conductorPart: stream.Part,
-                          target: stream.Part,
-                          *,
-                          isFirst: bool = False,
-                          ):
+def insertConductorEvents(
+    conductorPart: stream.Part,
+    target: stream.Part,
+    *,
+    isFirst: bool = False,
+):
     '''
     Insert a deepcopy of any TimeSignature, KeySignature, or MetronomeMark
     found in the `conductorPart` into the `target` Part at the same offset.
@@ -1934,8 +1983,8 @@ def insertConductorEvents(conductorPart: stream.Part,
     '''
     for e in conductorPart.getElementsByClass(
             ('TimeSignature', 'KeySignature', 'MetronomeMark')):
-        # create a deepcopy of the element so a flat does not cause
-        # multiple references of the same
+        # create a deepcopy of the element so flatten() does not have
+        # multiple references of the same object in the same Stream
         eventCopy = copy.deepcopy(e)
         if 'TempoIndication' in eventCopy.classes and not isFirst:
             eventCopy.style.hideObjectOnPrint = True
@@ -1951,7 +2000,7 @@ def midiTrackToStream(
     conductorPart: stream.Part|None = None,
     isFirst: bool = False,
     quarterLengthDivisors: Sequence[int] = (),
-    encoding_type: str = 'utf-8',
+    encoding: str = 'utf-8',
     **keywords
 ) -> stream.Part:
     # noinspection PyShadowingNames
@@ -2024,125 +2073,94 @@ def midiTrackToStream(
         s = inputM21
 
     # get events without DeltaTimes
-    events = getTimeForEvents(mt)
+    timedEvents = getTimeForEvents(mt)
 
     # need to build chords and notes
-    notes = getNotesFromEvents(events)
-    metaEvents = getMetaEvents(events)
-    lyricsDict = getLyricsFromEvents(events, encoding_type=encoding_type)
+    notes: list[TimedNoteEvent] = getNotesFromEvents(timedEvents)
+    metaEvents = getMetaEvents(timedEvents, encoding=encoding)
+    lyricsDict = lyricTimingsFromEvents(timedEvents, encoding=encoding)
 
-    # first create meta events
+    # first create MetaEvents
     for tick, obj in metaEvents:
         # environLocal.printDebug(['insert midi meta event:', t, obj])
-        s.coreInsert(tick / ticksPerQuarter, obj)
+        s.coreInsert(opFrac(tick / ticksPerQuarter), obj)
     s.coreElementsChanged()
     deduplicate(s, inPlace=True)
     # environLocal.printDebug([
     #    'midiTrackToStream(): found notes ready for Stream import', len(notes)])
 
     # collect notes with similar start times into chords
-    # create a composite list of both notes and chords
-    # composite = []
-    chordSub: list[tuple[tuple[int, MidiEvent],
-                         tuple[int, MidiEvent]]] = []
-    i = 0
-    iGathered = []  # store a list of indexes of gathered values put into chords
+    chordSub: list[TimedNoteEvent] = []
+
+    # store whether any given note index has already been gathered into a chord.
+    # changed in May 2025 -- O(n) space, but O(1) time; previously O(n) time.
+    iGathered: list[bool] = [False] * len(notes)
     voicesRequired = False
 
     if not quarterLengthDivisors:
         quarterLengthDivisors = defaults.quantizationQuarterLengthDivisors
 
-    if len(notes) > 1:
-        # environLocal.printDebug(['\n', 'midiTrackToStream(): notes', notes])
-        while i < len(notes):
-            if i in iGathered:
-                i += 1
+    # let tolerance for chord subbing follow the quantization
+    if quantizePost:
+        divisor = max(quarterLengthDivisors)
+    # fallback: 1/16 of a quarter (64th)
+    else:
+        divisor = 16
+    chunkTolerance = ticksPerQuarter / divisor
+
+    for i, timedNoteEvent in enumerate(notes):
+        if iGathered[i]:
+            # this index has already been gathered into a chord
+            continue
+        # look at each note; get on time and event
+        tickStart = timedNoteEvent.onTime
+        tOff = timedNoteEvent.offTime
+
+        chordSub = [timedNoteEvent]
+
+        # environLocal.printDebug(['on, off', on, off, 'i', i, 'len(notes)', len(notes)])
+
+        # go through all following notes; if there is only 1 note, this will
+        # not execute;
+        # looking for other events that start within a certain small time
+        # window to make into a chord
+        # if we find a note with a different end time but the same start
+        # time, throw into a different voice
+        # this isn't really worth improving the speed on.
+        for j in range(i + 1, len(notes)):
+            # look at each on time event
+            tSub = notes[j].onTime
+            tOffSub = notes[j].offTime
+
+            # must be strictly less than the quantization unit
+            if abs(tSub - tickStart) >= chunkTolerance:
+                break
+
+            # isolate case where end time is not w/n tolerance
+            if abs(tOffSub - tOff) > chunkTolerance:
+                # need to store this as requiring movement to a different
+                # voice
+                voicesRequired = True
                 continue
-            # look at each note; get on time and event
-            on, off = notes[i]
-            timeNow, unused_e = on
-            tOff, unused_eOff = off
-            # environLocal.printDebug(['on, off', on, off, 'i', i, 'len(notes)', len(notes)])
-
-            # go through all following notes; if there is only 1 note, this will
-            # not execute;
-            # looking for other events that start within a certain small time
-            # window to make into a chord
-            # if we find a note with a different end time but same start
-            # time, throw into a different voice
-            for j in range(i + 1, len(notes)):
-                # look at each on time event
-                onSub, offSub = notes[j]
-                tSub, unused_eSub = onSub
-                tOffSub, unused_eOffSub = offSub
-
-                # let tolerance for chord subbing follow the quantization
-                if quantizePost:
-                    divisor = max(quarterLengthDivisors)
-                # fallback: 1/16 of a quarter (64th)
-                else:
-                    divisor = 16
-                chunkTolerance = ticksPerQuarter / divisor
-                # must be strictly less than the quantization unit
-                if abs(tSub - timeNow) < chunkTolerance:
-                    # isolate case where end time is not w/n tolerance
-                    if abs(tOffSub - tOff) > chunkTolerance:
-                        # need to store this as requiring movement to a diff
-                        # voice
-                        voicesRequired = True
-                        continue
-                    if not chordSub:  # start a new one
-                        chordSub = [notes[i]]
-                        iGathered.append(i)
-                    chordSub.append(notes[j])
-                    iGathered.append(j)
-                    continue  # keep looping through events to see
-                    # if we can add more elements to this chord group
-                else:  # no more matches; assuming chordSub tones are contiguous
-                    break
-            # this comparison must be outside the j loop, as the case where we
-            # have the last note in a list of notes and the j loop does not
-            # execute; chordSub will be None
-            if chordSub:
-                # composite.append(chordSub)
-                c = midiEventsToChord(chordSub, ticksPerQuarter)
-                tickStart = notes[i][0][0]
-                o = tickStart / ticksPerQuarter
-                c.editorial.midiTickStart = tickStart
-                lyric = lyricsDict.get(tickStart)
-                if (lyric is not None):
-                    c.lyric = lyric
-                s.coreInsert(o, c)
-                # iSkip = len(chordSub)  # amount of accumulated chords
-                chordSub = []
-            else:  # just append the note, chordSub is empty
-                # composite.append(notes[i])
-                n: note.NotRest = midiEventsToNote(notes[i], ticksPerQuarter)
-                # the time is the first value in the first pair
-                # need to round, as floating point error is likely
-                tickStart = notes[i][0][0]
-                o = tickStart / ticksPerQuarter
-                n.editorial.midiTickStart = tickStart
-                lyric = lyricsDict.get(tickStart)
-                if (lyric is not None):
-                    n.lyric = lyric
-
-                s.coreInsert(o, n)
-                # iSkip = 1
-            # break  # exit secondary loop
-            i += 1
+            chordSub.append(notes[j])
+            iGathered[j] = True
+            # keep looping through events to see
+            # if we can add more elements to this chord group
+
+        note_or_chord: note.NotRest
+        if len(chordSub) > 1:
+            # composite.append(chordSub)
+            note_or_chord = midiEventsToChord(chordSub, ticksPerQuarter)
+        else:
+            # just append the note, chordSub has only one element
+            # timedNoteEvent is chordSub[0]
+            note_or_chord = midiEventsToNote(timedNoteEvent, ticksPerQuarter)
 
-    elif len(notes) == 1:  # rare case of just one note
-        singleN: note.NotRest = midiEventsToNote(notes[0], ticksPerQuarter)
-        # the time is the first value in the first pair
-        # need to round, as floating point error is likely
-        tickStart = notes[i][0][0]
-        o = tickStart / ticksPerQuarter
-        singleN.editorial.midiTickStart = tickStart
-        lyric = lyricsDict.get(tickStart)
-        if (lyric is not None):
-            singleN.lyric = lyric
-        s.coreInsert(o, singleN)
+        o = opFrac(tickStart / ticksPerQuarter)
+        note_or_chord.editorial.midiTickStart = tickStart
+        if lyric := lyricsDict.get(tickStart):
+            note_or_chord.lyric = lyric
+        s.coreInsert(o, note_or_chord)
 
     s.sort(force=True)  # will also run coreElementsChanged()
     # quantize to nearest 16th
@@ -2216,8 +2234,6 @@ def prepareStreamForMidi(s) -> stream.Stream:
         {0.0} <music21.stream.Measure 1 offset=0.0>
             {0.0} <music21.note.Note C>
     '''
-    from music21 import volume
-
     if s[stream.Measure]:
         s = s.expandRepeats()  # makes a deep copy
     else:
@@ -2529,7 +2545,7 @@ def packetStorageFromSubstreamList(
                 instObj = Conductor()
 
         trackPackets = streamToPackets(subs, trackId=trackId, addStartDelay=addStartDelay)
-        # store packets in dictionary; keys are trackIds
+        # store packets in a dictionary; keys are trackIds
         packetStorage[trackId] = {
             'rawPackets': trackPackets,
             'initInstrument': instObj,
@@ -2560,6 +2576,8 @@ def updatePacketStorageWithChannelInfo(
         elif isinstance(instObj, instrument.Conductor):
             initCh = None
         else:  # keys are midi program
+            if t.TYPE_CHECKING:
+                assert isinstance(instObj, instrument.Instrument)
             initCh = channelByInstrument[instObj.midiProgram]
         bundle['initChannel'] = initCh  # set for bundle too
 
@@ -2662,6 +2680,8 @@ def midiTracksToStreams(
     ticksPerQuarter: int = defaults.ticksPerQuarter,
     quantizePost=True,
     inputM21: stream.Score|None = None,
+    *,
+    encoding: str = 'utf-8',
     **keywords
 ) -> stream.Score:
     '''
@@ -2697,6 +2717,7 @@ def midiTracksToStreams(
                           inputM21=streamPart,
                           conductorPart=conductorPart,
                           isFirst=(mt is firstTrackWithNotes),
+                          encoding=encoding,
                           **keywords)
 
     return s
@@ -2752,6 +2773,7 @@ def midiFilePathToStream(
     filePath,
     *,
     inputM21=None,
+    encoding: str = 'utf-8',
     **keywords,
 ):
     '''
@@ -2780,7 +2802,7 @@ def midiFilePathToStream(
     mf.open(filePath)
     mf.read()
     mf.close()
-    return midiFileToStream(mf, inputM21=inputM21, **keywords)
+    return midiFileToStream(mf, inputM21=inputM21, encoding=encoding, **keywords)
 
 
 def midiAsciiStringToBinaryString(
@@ -2903,6 +2925,7 @@ def midiFileToStream(
     *,
     inputM21=None,
     quantizePost=True,
+    encoding: str = 'utf-8',
     **keywords
 ):
     # noinspection PyShadowingNames
@@ -2952,6 +2975,7 @@ def midiFileToStream(
                         ticksPerQuarter=mf.ticksPerQuarterNote,
                         quantizePost=quantizePost,
                         inputM21=s,
+                        encoding=encoding,
                         **keywords)
     # s._setMidiTracks(mf.tracks, mf.ticksPerQuarterNote)
 
diff --git a/music21/text.py b/music21/text.py
index 953bd0ebb..063751967 100644
--- a/music21/text.py
+++ b/music21/text.py
@@ -589,12 +589,13 @@ class Trigram:
         thisLength = total ** 0.5
         self._length = thisLength
 
-    def similarity(self, other):
+    def similarity(self, other: Trigram) -> float:
         '''
         returns a number between 0 and 1 indicating similarity between
         two trigrams.
-        1 means an identical ratio of trigrams;
-        0 means no trigrams in common.
+
+        1.0 means an identical ratio of trigrams;
+        0.0 means no trigrams in common.
         '''
         if not isinstance(other, Trigram):
             raise TypeError("can't compare Trigram with non-Trigram")
@@ -612,14 +613,14 @@ class Trigram:
         # environLocal.warn([self.length, 'self'])
         # environLocal.warn([other.length, 'other'])
 
-        return float(total) / (self.length * other.length)
+        return float(total / (self.length * other.length))
 
-    def __sub__(self, other):
+    def __sub__(self, other: Trigram) -> float:
         '''
-        indicates difference between trigram sets; 1 is entirely
-        different, 0 is entirely the same.
+        indicates difference between trigram sets; 1.0 is entirely
+        different, 0.0 is entirely the same.
         '''
-        return 1 - self.similarity(other)
+        return 1.0 - self.similarity(other)
 
     def makeWords(self, count):
         '''


Test patch:
diff --git a/music21/midi/tests.py b/music21/midi/tests.py
index 718559263..7e1fe11f7 100644
--- a/music21/midi/tests.py
+++ b/music21/midi/tests.py
@@ -23,12 +23,20 @@ from music21.midi.base import (
     MidiFile,
 )
 from music21.midi.translate import (
+    TimedNoteEvent,
+    TranslateWarning,
+    channelInstrumentData,
+    conductorStream,
+    getMetaEvents,
     midiAsciiStringToBinaryString,
-    noteToMidiEvents,
+    midiEventsToInstrument,
     midiEventsToNote,
-    streamHierarchyToMidiTracks, midiFileToStream, conductorStream,
-    streamToMidiFile, prepareStreamForMidi, midiEventsToInstrument, getMetaEvents,
-    TranslateWarning, channelInstrumentData, packetStorageFromSubstreamList,
+    midiFileToStream,
+    noteToMidiEvents,
+    packetStorageFromSubstreamList,
+    prepareStreamForMidi,
+    streamHierarchyToMidiTracks,
+    streamToMidiFile,
     updatePacketStorageWithChannelInfo,
 )
 from music21.musicxml import testPrimitive
@@ -323,7 +331,7 @@ class Test(unittest.TestCase):
         # dealing with midi files that use running status compression
         s = converter.parse(fp)
         self.assertEqual(len(s.parts), 2)
-        self.assertEqual(len(s.parts[0].recurse().notes), 704)
+        self.assertEqual(len(s.parts[0].recurse().notes), 702)
         self.assertEqual(len(s.parts[1].recurse().notes), 856)
 
         # for n in s.parts[0].notes:
@@ -403,8 +411,7 @@ class Test(unittest.TestCase):
         self.assertIsInstance(eventList[3], MidiEvent)
 
         # translate eventList back to a note
-        n2 = midiEventsToNote(((eventList[0].time, eventList[1]),
-                               (eventList[2].time, eventList[2])))
+        n2 = midiEventsToNote(TimedNoteEvent(eventList[0].time, eventList[2].time, eventList[1]))
         self.assertEqual(n2.pitch.nameWithOctave, 'A4')
         self.assertEqual(n2.quarterLength, 2.0)
 
@@ -1538,23 +1545,24 @@ class Test(unittest.TestCase):
 
     def testMidiImportLyrics(self):
         lyricFactZh = ['明', '山', '涌', '水', '郁', '郁', '葱', '', '葱',
-                        '钟', '灵', '毓', '秀', '海', '天', '', '东',
-                        '济', '济', '多', '士', '四', '方', '所', '', '崇',
-                        '早', '', '育', '', '文', '明', '', '种']
+                       '钟', '灵', '毓', '秀', '海', '天', '', '东',
+                       '济', '济', '多', '士', '四', '方', '所', '', '崇',
+                       '早', '', '育', '', '文', '明', '', '种']
         lyricFactKo = ['빛', '날', '세', '라', '영', '웅', '열', '', '사',
-                        '만', '세', '불', '망', '하', '실', '', '이',
-                        '옛', '적', '이', '나', '지', '금', '이', '', '나',
-                        '항', '상', '앙', '모', '합', '니', '', '다']
+                       '만', '세', '불', '망', '하', '실', '', '이',
+                       '옛', '적', '이', '나', '지', '금', '이', '', '나',
+                       '항', '상', '앙', '모', '합', '니', '', '다']
         testCases = [
             ('test18.mid', 'utf-8', lyricFactZh),
             ('test19.mid', 'gbk', lyricFactZh),
             ('test20.mid', 'utf-8', lyricFactKo),
             ('test21.mid', 'euc-kr', lyricFactKo),
         ]
-        for (filename, encoding, lyricFact) in testCases:
+
+        for filename, encoding, lyricFact in testCases:
             fp = common.getSourceFilePath() / 'midi' / 'testPrimitive' / filename
-            s = converter.parse(fp, encoding_type=encoding)
-            for (n, l) in zip(s.flat.notes, lyricFact):
+            s = converter.parse(fp, encoding=encoding)
+            for (n, l) in zip(s.flatten().notes, lyricFact):
                 self.assertEqual(n.lyric, l)