Repository: cuthbertLab/music21
Pull request #1796 (2025-06-19 20:35:03)

Also merge spanners when calling stream.Score.partsToVoices
## What does this PR do?

When calling `stream.Score.partsToVoices`, only `notesAndRests` are merged into the new part. We can also merge the spanners from those parts into the new one.



Patch:
diff --git a/music21/configure.py b/music21/configure.py
index 7f3201192..773098744 100644
--- a/music21/configure.py
+++ b/music21/configure.py
@@ -42,8 +42,6 @@ reSibeliusExe = re.compile(r'Sibelius\.exe', IGNORECASE)
 reFinaleReaderApp = re.compile(r'Finale Reader\.app', IGNORECASE)
 reMuseScoreApp = re.compile(r'MuseScore.*\.app', IGNORECASE)
 reMuseScoreExe = re.compile(r'Musescore.*\\bin\\MuseScore.*\.exe', IGNORECASE)
-reDoricoApp = re.compile(r'Dorico.*\.app', IGNORECASE)
-reDoricoExe = re.compile(r'Dorico.*\.exe', IGNORECASE)
 
 urlMusic21 = 'https://www.music21.org/music21docs/'
 urlMuseScore = 'https://www.musescore.org'
@@ -1180,15 +1178,11 @@ class SelectMusicXMLReader(SelectFilePath):
         def comparisonSibelius(x):
             return reSibeliusApp.search(x) is not None
 
-        def comparisonDorico(x):
-            return reDoricoApp.search(x) is not None
-
         # order here results in ranks
         results = self._getDarwinApp(comparisonMuseScore)
         results += self._getDarwinApp(comparisonFinale)
         results += self._getDarwinApp(comparisonFinaleReader)
         results += self._getDarwinApp(comparisonSibelius)
-        results += self._getDarwinApp(comparisonDorico)
 
         # de-duplicate
         res = []
@@ -1211,14 +1205,10 @@ class SelectMusicXMLReader(SelectFilePath):
         def comparisonSibelius(x):
             return reSibeliusExe.search(x) is not None
 
-        def comparisonDorico(x):
-            return reDoricoExe.search(x) is not None
-
         # order here results in ranks
         results = self._getWinApp(comparisonMuseScore)
         results += self._getWinApp(comparisonFinale)
         results += self._getWinApp(comparisonSibelius)
-        results += self._getWinApp(comparisonDorico)
 
         # de-duplicate (Windows especially can put the same environment var twice)
         res = []
diff --git a/music21/lily/translate.py b/music21/lily/translate.py
index 46dfb350f..b65b99801 100644
--- a/music21/lily/translate.py
+++ b/music21/lily/translate.py
@@ -201,11 +201,8 @@ class LilypondConverter:
     def setupTools(self):
         LILYEXEC = self.findLilyExec()
         command = [LILYEXEC, '--version']
-        platform = common.getPlatform()
-        creation_flags = subprocess.CREATE_NO_WINDOW if platform == 'win' else 0
         try:
-            with subprocess.Popen(command, stdout=subprocess.PIPE,
-                                  creationflags=creation_flags) as proc:
+            with subprocess.Popen(command, stdout=subprocess.PIPE) as proc:
                 stdout, unused = proc.communicate()
                 stdout = stdout.decode(encoding='utf-8')
                 versionString = stdout.split()[2]
diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py
index 239a2caac..b8a21c1f3 100644
--- a/music21/musicxml/m21ToXml.py
+++ b/music21/musicxml/m21ToXml.py
@@ -5885,12 +5885,10 @@ class MeasureExporter(XMLExporterBase):
         self.xmlRoot.append(mxHarmony)
         return mxHarmony
 
-    def chordSymbolToXml(
-        self,
-        cs: harmony.ChordSymbol,
-        *,
-        append: bool = True
-    ) -> Element|list[Element]:
+    def chordSymbolToXml(self,
+                         cs: harmony.ChordSymbol,
+                         *,
+                         append: bool = True) -> Element|list[Element]:
         # noinspection PyShadowingNames
         '''
         Convert a ChordSymbol object to either a chord (if .writeAsChord is True)
@@ -5923,6 +5921,7 @@ class MeasureExporter(XMLExporterBase):
           </bass>
         </harmony>
 
+
         Now give function:
 
         >>> cs.romanNumeral = 'I64'
diff --git a/music21/search/lyrics.py b/music21/search/lyrics.py
index 32d7c673d..f6e3cfa9d 100644
--- a/music21/search/lyrics.py
+++ b/music21/search/lyrics.py
@@ -135,14 +135,13 @@ class LyricSearcher:
         found if a work contains multiple voices.
     '''
 
-    def __init__(self, s: StreamType|None = None, *, wordSeparator: str = ' '):
+    def __init__(self, s: StreamType|None = None):
         self.stream: StreamType|None = s
         self.includeIntermediateElements = False  # currently does nothing
         self.includeTrailingMelisma = False  # currently does nothing
 
         self._indexText: str|None = None
         self._indexTuples: list[IndexedLyric] = []
-        self.wordSeparator = wordSeparator
 
     @property
     def indexText(self) -> str:
@@ -229,8 +228,8 @@ class LyricSearcher:
                 if lastSyllabic in ('begin', 'middle', None):
                     iText += txt
                 else:
-                    iText += self.wordSeparator + txt
-                    posStart += len(self.wordSeparator)
+                    iText += ' ' + txt
+                    posStart += 1
 
                 iTextByIdentifier[lyIdentifier] = iText
                 il = IndexedLyric(n, posStart, posStart + len(txt), mNum, ly, txt,
@@ -350,7 +349,7 @@ class LyricSearcher:
         '''
         indices = []
         for i in self._indexTuples:
-            if i.absoluteEnd > posStart and i.absoluteStart <= posEnd:
+            if i.absoluteEnd >= posStart and i.absoluteStart <= posEnd:
                 indices.append(i)
         if not indices:
             raise LyricSearcherException(f'Could not find position {posStart} in text')
@@ -569,190 +568,6 @@ class Test(unittest.TestCase):
         self.assertEqual(match[0].mEnd, 2)
         self.assertEqual(match[0].identifier, 1)
 
-    def testCustomSeparator(self):
-        from music21 import converter
-        from music21 import search
-        import more_itertools
-
-        partXML = '''<score-partwise version="4.0">
-  <identification>
-    <encoding>
-      <software>MuseScore 4.5.2</software>
-      <encoding-date>2025-06-22</encoding-date>
-      <supports element="accidental" type="yes"/>
-      <supports element="beam" type="yes"/>
-      <supports element="print" attribute="new-page" type="no"/>
-      <supports element="print" attribute="new-system" type="no"/>
-      <supports element="stem" type="yes"/>
-      </encoding>
-    </identification>
-  <part-list>
-    <score-part id="P1">
-      <part-name>钢琴, Track1</part-name>
-      <part-abbreviation>Pno.</part-abbreviation>
-      <score-instrument id="P1-I1">
-        <instrument-name>钢琴</instrument-name>
-        <instrument-sound>keyboard.piano</instrument-sound>
-        </score-instrument>
-      <midi-device id="P1-I1" port="1"></midi-device>
-      <midi-instrument id="P1-I1">
-        <midi-channel>1</midi-channel>
-        <midi-program>1</midi-program>
-        <volume>78.7402</volume>
-        <pan>0</pan>
-        </midi-instrument>
-      </score-part>
-    </part-list>
-  <part id="P1">
-    <measure number="1">
-      <attributes>
-        <divisions>2</divisions>
-        <key>
-          <fifths>0</fifths>
-          </key>
-        <time>
-          <beats>4</beats>
-          <beat-type>4</beat-type>
-          </time>
-        <clef>
-          <sign>F</sign>
-          <line>4</line>
-          </clef>
-        </attributes>
-      <note dynamics="50">
-        <pitch>
-          <step>G</step>
-          <octave>3</octave>
-          </pitch>
-        <duration>2</duration>
-        <voice>1</voice>
-        <type>quarter</type>
-        <stem>down</stem>
-        <lyric number="1">
-          <syllabic>single</syllabic>
-          <text>长</text>
-          </lyric>
-        </note>
-      <note dynamics="50">
-        <pitch>
-          <step>E</step>
-          <octave>3</octave>
-          </pitch>
-        <duration>1</duration>
-        <voice>1</voice>
-        <type>eighth</type>
-        <stem>down</stem>
-        <beam number="1">begin</beam>
-        <notations>
-          <slur type="start" number="1"/>
-          </notations>
-        <lyric number="1">
-          <syllabic>single</syllabic>
-          <text>亭</text>
-          </lyric>
-        </note>
-      <note dynamics="50">
-        <pitch>
-          <step>G</step>
-          <octave>3</octave>
-          </pitch>
-        <duration>1</duration>
-        <voice>1</voice>
-        <type>eighth</type>
-        <stem>down</stem>
-        <beam number="1">end</beam>
-        <notations>
-          <slur type="stop" number="1"/>
-          </notations>
-        </note>
-      <note dynamics="50">
-        <pitch>
-          <step>C</step>
-          <octave>4</octave>
-          </pitch>
-        <duration>2</duration>
-        <voice>1</voice>
-        <type>quarter</type>
-        <stem>down</stem>
-        <lyric number="1">
-          <syllabic>single</syllabic>
-          <text>外</text>
-          </lyric>
-        </note>
-      <note>
-        <rest/>
-        <duration>2</duration>
-        <voice>1</voice>
-        <type>quarter</type>
-        </note>
-      </measure>
-    <measure number="2">
-      <note dynamics="50">
-        <pitch>
-          <step>A</step>
-          <octave>3</octave>
-          </pitch>
-        <duration>2</duration>
-        <voice>1</voice>
-        <type>quarter</type>
-        <stem>down</stem>
-        <lyric number="1">
-          <syllabic>single</syllabic>
-          <text>古</text>
-          </lyric>
-        </note>
-      <note dynamics="50">
-        <pitch>
-          <step>C</step>
-          <octave>4</octave>
-          </pitch>
-        <duration>2</duration>
-        <voice>1</voice>
-        <type>quarter</type>
-        <stem>down</stem>
-        <lyric number="1">
-          <syllabic>single</syllabic>
-          <text>道</text>
-          </lyric>
-        </note>
-      <note dynamics="50">
-        <pitch>
-          <step>G</step>
-          <octave>3</octave>
-          </pitch>
-        <duration>2</duration>
-        <voice>1</voice>
-        <type>quarter</type>
-        <stem>down</stem>
-        <lyric number="1">
-          <syllabic>single</syllabic>
-          <text>边</text>
-          </lyric>
-        </note>
-      <note>
-        <rest/>
-        <duration>2</duration>
-        <voice>1</voice>
-        <type>quarter</type>
-        </note>
-      <barline location="right">
-        <bar-style>light-heavy</bar-style>
-        </barline>
-      </measure>
-    </part>
-  </score-partwise>'''
-        s = converter.parse(partXML, format='MusicXML')
-        for lenWordSep in range(5):
-            wordSep = ' ' * lenWordSep
-            ls = search.lyrics.LyricSearcher(s, wordSeparator=wordSep)
-            for pair in more_itertools.pairwise('长亭外古道边'):
-                keyword = pair[0] + wordSep + pair[1]
-                match = ls.search(keyword)
-                self.assertEqual(len(match), 1)
-                self.assertEqual(len(match[0].els), 2)
-                self.assertEqual(match[0].els[0].lyric, pair[0])
-                self.assertEqual(match[0].els[1].lyric, pair[1])
-
 
 # ------------------------------------------------------------------------------
 # define presented order in documentation
diff --git a/music21/stream/base.py b/music21/stream/base.py
index 6a840b25c..8e74db597 100644
--- a/music21/stream/base.py
+++ b/music21/stream/base.py
@@ -14159,6 +14159,8 @@ class Score(Stream):
         165
 
         '''
+        from music21 import spanner
+
         sub: list[Part] = []
         bundle = []
         if isinstance(voiceAllocation, int):
@@ -14238,7 +14240,7 @@ class Score(Stream):
                     v = Voice()
                     v.id = pIndex
                     # for now, just take notes, including rests
-                    for e in m.notesAndRests:  # m.getElementsByClass():
+                    for e in m.getElementsByClass([note.GeneralNote, spanner.Spanner]):
                         if setStems and isinstance(e, note.Note):
                             e.stemDirection = 'up' if pIndex % 2 == 0 else 'down'
                         v.insert(e.getOffsetBySite(m), e)
@@ -14250,6 +14252,10 @@ class Score(Stream):
                     if not hasMeasures:
                         pActive.insert(m.getOffsetBySite(p), mActive)
 
+                # merge spanners from current part into active part
+                for sp in p.spanners:
+                    pActive.insert(sp.getOffsetBySite(p), sp)
+
             s.insert(0, pActive)
             pActive = None
         return s
diff --git a/music21/text.py b/music21/text.py
index a2e866c42..063751967 100644
--- a/music21/text.py
+++ b/music21/text.py
@@ -54,7 +54,7 @@ articleReference = {
 
 
 # ------------------------------------------------------------------------------
-def assembleLyrics(streamIn, lineNumber=1, *, wordSeparator=' '):
+def assembleLyrics(streamIn, lineNumber=1):
     '''
     Concatenate text from a stream. The Stream is automatically flattened.
 
@@ -124,10 +124,10 @@ def assembleLyrics(streamIn, lineNumber=1, *, wordSeparator=' '):
                     word = []
             else:
                 raise ValueError(f'no known Text syllabic setting: {lyricObj.syllabic}')
-    return wordSeparator.join(words)
+    return ' '.join(words)
 
 
-def assembleAllLyrics(streamIn, maxLyrics=10, lyricSeparation='\n', *, wordSeparator=' '):
+def assembleAllLyrics(streamIn, maxLyrics=10, lyricSeparation='\n'):
     r'''
     Concatenate all Lyrics text from a stream separated by lyricSeparation.
     The Stream is automatically recursed.
@@ -152,7 +152,7 @@ def assembleAllLyrics(streamIn, maxLyrics=10, lyricSeparation='\n', *, wordSepar
     '''
     lyrics = ''
     for i in range(1, maxLyrics):
-        lyr = assembleLyrics(streamIn, i, wordSeparator=wordSeparator)
+        lyr = assembleLyrics(streamIn, i)
         if lyr != '':
             if i > 1:
                 lyrics += lyricSeparation
@@ -688,15 +688,6 @@ class Test(unittest.TestCase):
         # noinspection SpellCheckingInspection
         self.assertEqual(post, 'aristocats are great')
 
-        s = stream.Stream()
-        for syl in ['长', '亭', '外', '古', '道', '边']:
-            n = note.Note()
-            n.lyric = syl
-            s.append(n)
-        post = assembleLyrics(s, wordSeparator='')
-        # custom word separator
-        self.assertEqual(post, '长亭外古道边')
-
     # noinspection SpellCheckingInspection
     def testLanguageDetector(self):
         ld = LanguageDetector()


Test patch:
diff --git a/music21/stream/tests.py b/music21/stream/tests.py
index 8c336783d..d0895b666 100644
--- a/music21/stream/tests.py
+++ b/music21/stream/tests.py
@@ -5989,6 +5989,64 @@ class Test(unittest.TestCase):
         # self.assertEqual(s2.parts[2].getElementsByClass(
         #     'Measure')[0].hasVoices(), False)
 
+    def testPartsToVoicesSpanner(self):
+        # NOTE: this test spanners merged from two parts into one part
+        clef1 = clef.TrebleClef()
+        clef2 = clef.TrebleClef()
+        ts1 = meter.TimeSignature('12/8')
+        ts2 = meter.TimeSignature('12/8')
+
+        m1 = Measure()
+        m2 = Measure()
+        n1 = note.Note('C#5', type='eighth')
+        n2 = note.Note('D5', type='eighth')
+        n3 = note.Note('A5', type='eighth')
+        n4 = note.Note('B-5', type='eighth')
+        n5 = note.Note('D5', type='eighth')
+        n6 = note.Note('C#5', type='eighth')
+        n7 = note.Note('C6', type='eighth')
+        n8 = note.Note('B-5', type='eighth')
+
+        m1.append((
+            clef1, ts1, note.Rest(type='eighth'), n1, n2,
+            note.Rest(type='eighth'), n3, n4,
+            spanner.Slur(n3, n4),
+            chord.Chord(['E4', 'G4']), note.Rest(type='eighth'),
+            chord.Chord(['G4', 'C#5']), note.Rest(type='eighth'),
+        ))
+        m1.rightBarline = bar.Barline('regular')
+        m2.append((
+            clef2, ts2, chord.Chord(['D4', 'F4']), note.Rest(type='eighth'),
+            chord.Chord(['F4', 'A4']), note.Rest(type='eighth'),
+            note.Rest(type='eighth'), n5, n6,
+            note.Rest(type='eighth'), n7, n8,
+        ))
+        m2.rightBarline = bar.Barline('regular')
+
+        p1 = Part()
+        p1.append(m1)
+        p1.append(spanner.Slur(n1, n2))
+        p2 = Part()
+        p2.append(spanner.Slur(n5, n6))
+        p2.append(spanner.Slur(n7, n8))
+        p2.append(m2)
+        s = Score()
+        s.insert(0, p1)
+        s.insert(0, p2)
+
+        s_ = s.partsToVoices(2, permitOneVoicePerPart=True)
+        self.assertEqual(len(s_), 1)
+        self.assertEqual(len(s_[0].getElementsByClass('Slur')), 3)
+        self.assertEqual(len(s_[0]['Slur']), 4)
+        self.assertEqual(len(s_[0]), 4)  # 1 measure + 3 slurs
+        self.assertIsInstance(s_[0][0], Measure)
+        self.assertEqual(s_[0][0][0], clef1)
+        self.assertEqual(s_[0][0][1], ts1)
+        self.assertEqual(len(s_[0][0].voices), 2)  # 2 voices
+        self.assertEqual(len(s_[0][0][2]['Slur']), 1)  # 1 slur inside the first voice
+        self.assertEqual(s_[0][0][2]['Slur'][0][0], n3)
+        self.assertEqual(s_[0][0][2]['Slur'][0][1], n4)
+
     def testVoicesToPartsA(self):
 
         s0 = corpus.parse('bwv66.6')