Repository: cuthbertLab/music21
Pull request #1779 (2025-05-18 08:18:57)

Support writing lyrics to midi
Midi files can contain lyrics. After this PR, the midi file exported from music21 will contain lyrics, which will show if you open it with musescore.

I've implemented reading lyrics from midi in #1769

Users can select the encoding which encoding to use when exporting by using `stream.write('midi', fp=filename, encoding=encoding)`. The default encoding is UTF-8.


Patch:
diff --git a/music21/converter/subConverters.py b/music21/converter/subConverters.py
index 7d93b8dd4..e2f33f37e 100644
--- a/music21/converter/subConverters.py
+++ b/music21/converter/subConverters.py
@@ -1024,9 +1024,12 @@ class ConverterMidi(SubConverter):
     registerInputExtensions = ('mid', 'midi')
     registerOutputExtensions = ('mid',)
 
-    def __init__(self, *, encoding='utf-8', **keywords):
-        self.encoding = encoding
-        super().__init__(**keywords)
+    @property
+    def encoding(self) -> str:
+        if 'encoding' in self.keywords and self.keywords['encoding']:
+            # if the encoding is set to None or '', it will be ignored
+            return self.keywords['encoding']
+        return 'utf-8'
 
     def parseData(self, strData, number=None, *, encoding: str = ''):
         '''
@@ -1043,10 +1046,12 @@ class ConverterMidi(SubConverter):
         (default "utf-8")
         '''
         from music21.midi import translate as midiTranslate
+        keywords = {k: v for k, v in self.keywords.items()
+                    if k not in ('encoding',)}
         self.stream = midiTranslate.midiStringToStream(
             strData,
             encoding=encoding or self.encoding,
-            **self.keywords
+            **keywords
         )
 
     def parseFile(self,
@@ -1069,6 +1074,8 @@ class ConverterMidi(SubConverter):
         (default "utf-8")
         '''
         from music21.midi import translate as midiTranslate
+        keywords = {k: v for k, v in self.keywords.items()
+                    if k not in ('encoding',)}
         midiTranslate.midiFilePathToStream(
             filePath,
             inputM21=self.stream,
@@ -1083,12 +1090,14 @@ class ConverterMidi(SubConverter):
               subformats=(),
               *,
               addStartDelay: bool = False,
+              encoding: str = '',
               **keywords):  # pragma: no cover
         from music21.midi import translate as midiTranslate
         if fp is None:
             fp = self.getTemporaryFile()
 
-        mf = midiTranslate.music21ObjectToMidiFile(obj, addStartDelay=addStartDelay)
+        mf = midiTranslate.music21ObjectToMidiFile(obj, addStartDelay=addStartDelay,
+                                                   encoding=encoding or self.encoding)
         mf.open(fp, 'wb')  # write binary
         mf.write()
         mf.close()
diff --git a/music21/midi/translate.py b/music21/midi/translate.py
index 898fca092..c6ce45433 100644
--- a/music21/midi/translate.py
+++ b/music21/midi/translate.py
@@ -295,6 +295,7 @@ def music21ObjectToMidiFile(
     music21Object: base.Music21Object,
     *,
     addStartDelay=False,
+    encoding: str = 'utf-8',
 ) -> MidiFile:
     '''
     Either calls streamToMidiFile on the music21Object or
@@ -307,12 +308,14 @@ def music21ObjectToMidiFile(
             music21Object = music21Object.toSoundingPitch()
 
         return streamToMidiFile(t.cast(stream.Stream, music21Object),
-                                addStartDelay=addStartDelay)
+                                addStartDelay=addStartDelay,
+                                encoding=encoding)
     else:
         m21ObjectCopy = copy.deepcopy(music21Object)
         s: stream.Stream = stream.Stream()
         s.insert(0, m21ObjectCopy)
-        return streamToMidiFile(s, addStartDelay=addStartDelay)
+        return streamToMidiFile(s, addStartDelay=addStartDelay,
+                                encoding=encoding)
 
 
 # ------------------------------------------------------------------------------
@@ -465,6 +468,7 @@ def noteToMidiEvents(
     *,
     includeDeltaTime: bool = True,
     channel: int = 1,
+    encoding: str = 'utf-8',
 ) -> list[DeltaTime|MidiEvent]:
     # noinspection PyShadowingNames
     '''
@@ -511,9 +515,17 @@ def noteToMidiEvents(
     mt = None  # use a midi track set to None
     eventList: list[DeltaTime|MidiEvent] = []
 
+    if (inputM21.lyric is not None and inputM21.lyric != ''):
+        if includeDeltaTime:
+            dt = DeltaTime(mt, channel=channel)
+            eventList.append(dt)
+        me = MidiEvent(track=mt)
+        me.type = MetaEvents.LYRIC
+        me.data = inputM21.lyric.encode(encoding, 'ignore')
+        eventList.append(me)
+
     if includeDeltaTime:
-        dt = DeltaTime(mt, channel=channel)
-        # add to track events
+        dt = DeltaTime(track=mt)
         eventList.append(dt)
 
     me1 = MidiEvent(track=mt)
@@ -687,6 +699,7 @@ def chordToMidiEvents(
     *,
     includeDeltaTime: bool = True,
     channel: int = 1,
+    encoding: str = 'utf-8',
 ) -> list[DeltaTime|MidiEvent]:
     # noinspection PyShadowingNames
     '''
@@ -729,6 +742,15 @@ def chordToMidiEvents(
     chordVolume = c.volume  # use if component volume are not defined
     hasComponentVolumes = c.hasComponentVolumes()
 
+    if (inputM21.lyric is not None and inputM21.lyric != ''):
+        if includeDeltaTime:
+            dt = DeltaTime(track=mt)
+            eventList.append(dt)
+        me = MidiEvent(track=mt)
+        me.type = MetaEvents.LYRIC
+        me.data = inputM21.lyric.encode(encoding, 'ignore')
+        eventList.append(me)
+
     for i, chordComponent in enumerate(c):
         # pitchObj = c.pitches[i]
         # noteObj = chordComponent
@@ -1320,7 +1342,8 @@ def getPacketFromMidiEvent(
 
 
 def elementToMidiEventList(
-    el: base.Music21Object
+    el: base.Music21Object,
+    encoding: str = 'utf-8',
 ) -> list[MidiEvent|DeltaTime]|None:
     '''
     Return a list of MidiEvents (or None) from a Music21Object,
@@ -1345,14 +1368,14 @@ def elementToMidiEventList(
             # get a list of midi events
             # using this property here is easier than using the above conversion
             # methods, as we do not need to know what the object is
-            return noteToMidiEvents(el, includeDeltaTime=False)
+            return noteToMidiEvents(el, includeDeltaTime=False, encoding=encoding)
         case note.Unpitched():
-            return noteToMidiEvents(el, includeDeltaTime=False, channel=10)
+            return noteToMidiEvents(el, includeDeltaTime=False, channel=10, encoding=encoding)
         case chord.Chord():
             # TODO: skip Harmony unless showAsChord
-            return chordToMidiEvents(el, includeDeltaTime=False)
+            return chordToMidiEvents(el, includeDeltaTime=False, encoding=encoding)
         case percussion.PercussionChord():
-            return chordToMidiEvents(el, includeDeltaTime=False, channel=10)
+            return chordToMidiEvents(el, includeDeltaTime=False, channel=10, encoding=encoding)
         case dynamics.Dynamic():
             return None  # dynamics have already been applied to notes
         case meter.TimeSignature():
@@ -1375,6 +1398,7 @@ def streamToPackets(
     s: stream.Stream,
     trackId: int = 1,
     addStartDelay: bool = False,
+    encoding: str = 'utf-8',
 ) -> list[dict[str, t.Any]]:
     '''
     Convert a flattened, sorted Stream to MIDI Packets.
@@ -1394,7 +1418,7 @@ def streamToPackets(
 
     # s should already be flat and sorted
     for el in s:
-        midiEventList = elementToMidiEventList(el)
+        midiEventList = elementToMidiEventList(el, encoding=encoding)
         if isinstance(el, instrument.Instrument):
             lastInstrument = el  # store last instrument
 
@@ -1917,7 +1941,7 @@ def lyricTimingsFromEvents(
                 lyrics[time] = e.data.decode(encoding)
             except UnicodeDecodeError:
                 warnings.warn(
-                    f'Unable to decode lyrics from {e}',
+                    f'Unable to decode lyrics from {e} as {encoding}',
                     TranslateWarning)
     return lyrics
 
@@ -2457,6 +2481,7 @@ def packetStorageFromSubstreamList(
     substreamList: list[stream.Part],
     *,
     addStartDelay=False,
+    encoding: str = 'utf-8',
 ) -> dict[int, dict[str, t.Any]]:
     # noinspection PyShadowingNames
     r'''
@@ -2544,7 +2569,8 @@ def packetStorageFromSubstreamList(
                 # maybe prepareStreamForMidi() wasn't run; create Conductor instance
                 instObj = Conductor()
 
-        trackPackets = streamToPackets(subs, trackId=trackId, addStartDelay=addStartDelay)
+        trackPackets = streamToPackets(subs, trackId=trackId, addStartDelay=addStartDelay,
+                                       encoding=encoding)
         # store packets in a dictionary; keys are trackIds
         packetStorage[trackId] = {
             'rawPackets': trackPackets,
@@ -2590,6 +2616,7 @@ def streamHierarchyToMidiTracks(
     *,
     acceptableChannelList=None,
     addStartDelay=False,
+    encoding='utf-8',
 ):
     '''
     Given a Stream, Score, Part, etc., that may have substreams (i.e.,
@@ -2640,7 +2667,8 @@ def streamHierarchyToMidiTracks(
     for subs in substreamList:
         subs.stripTies(inPlace=True, matchByPitch=True)
 
-    packetStorage = packetStorageFromSubstreamList(substreamList, addStartDelay=addStartDelay)
+    packetStorage = packetStorageFromSubstreamList(substreamList, addStartDelay=addStartDelay,
+                                                   encoding=encoding)
     updatePacketStorageWithChannelInfo(packetStorage, channelByInstrument)
 
     initTrackIdToChannelMap = {}
@@ -2728,6 +2756,7 @@ def streamToMidiFile(
     *,
     addStartDelay: bool = False,
     acceptableChannelList: list[int]|None = None,
+    encoding: str = 'utf-8',
 ) -> MidiFile:
     # noinspection PyShadowingNames
     '''
@@ -2759,6 +2788,7 @@ def streamToMidiFile(
     midiTracks = streamHierarchyToMidiTracks(s,
                                              addStartDelay=addStartDelay,
                                              acceptableChannelList=acceptableChannelList,
+                                             encoding=encoding,
                                              )
 
     # may need to update channel information


Test patch:
diff --git a/music21/midi/tests.py b/music21/midi/tests.py
index 7e1fe11f7..5cf590eca 100644
--- a/music21/midi/tests.py
+++ b/music21/midi/tests.py
@@ -654,10 +654,12 @@ class Test(unittest.TestCase):
         <music21.midi.DeltaTime (empty) track=1>,
         <music21.midi.MidiEvent PROGRAM_CHANGE, track=1, channel=1, data=0>,
         <music21.midi.DeltaTime (empty) track=1>,
+        <music21.midi.MidiEvent LYRIC, track=1, data=b'1. Was\\nzu\\n2. Ich\\nwas'>,
+        <music21.midi.DeltaTime (empty) track=1>,
         <music21.midi.MidiEvent NOTE_ON, track=1, channel=1, pitch=66, velocity=90>,
         <music21.midi.DeltaTime t=5040, track=1>,
         <music21.midi.MidiEvent NOTE_OFF, track=1, channel=1, pitch=66, velocity=0>]'''
-        found = str(mtList[1].events[:10])
+        found = str(mtList[1].events[:12])
         self.assertTrue(common.whitespaceEqual(found, match), found)
 
     def testMidiProgramChangeA(self):
@@ -1565,6 +1567,44 @@ class Test(unittest.TestCase):
             for (n, l) in zip(s.flatten().notes, lyricFact):
                 self.assertEqual(n.lyric, l)
 
+    def testMidiExportLyrics(self):
+        lyricEn = 'cat'  # ascii characters should be supported by every encoding
+        lyricZh = '明'
+        lyricKo = '빛'
+
+        testCases = [
+            ('utf-8', lyricEn),
+            ('gbk', lyricEn),
+            ('euc-kr', lyricEn),
+            ('utf-8', lyricZh),
+            ('gbk', lyricZh),
+            ('utf-8', lyricKo),
+            ('euc-kr', lyricKo),
+        ]
+        for encoding, lyric in testCases:
+            with self.subTest(encoding=encoding, lyric=lyric):
+                s = stream.Score()
+                p1 = stream.Part()
+                p2 = stream.Part()
+
+                n1 = note.Note('c4')
+                n1.lyric = lyric
+                n2 = note.Note('g4')
+                n2.lyric = lyric
+
+                p1.append(n1)
+                p2.append(n2)
+
+                s.append(p1)
+                s.append(p2)
+
+                b = converter.toData(s, fmt='midi', encoding=encoding)
+                self.assertIsInstance(b, bytes)
+
+                m = converter.parseData(b, fmt='midi', encoding=encoding)
+                for n in m.flatten().notes:
+                    self.assertEqual(n.lyric, lyric)
+
 
 # ------------------------------------------------------------------------------
 if __name__ == '__main__':