From 9fd67a44179c547bd8ccd2f200729f7ec75baffe Mon Sep 17 00:00:00 2001 From: Emerson Date: Sun, 2 Aug 2026 14:42:05 -0400 Subject: [PATCH] Fix MetronomeMark objects silently dropped from Lilypond output The Lilypond translator's element dispatch never recognized MetronomeMark, so tempo markings were dropped entirely when writing to lily. Also fixes LyTempoEvent.stringOutput(), which ignored a paired stenoDuration when no tempoRange was given, and its docstring, which showed an invalid steno duration ('quarter' instead of '4'). Fixes #1852 This PR is AI-assisted (>10 lines). Co-Authored-By: Claude Sonnet 5 --- music21/lily/lilyObjects.py | 24 ++++++++++++--- music21/lily/translate.py | 59 +++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/music21/lily/lilyObjects.py b/music21/lily/lilyObjects.py index c9a4eeb24..77eadfa0a 100644 --- a/music21/lily/lilyObjects.py +++ b/music21/lily/lilyObjects.py @@ -867,23 +867,34 @@ class LyTempoEvent(LyObject): r''' tempo_event: "\tempo" steno_duration '=' tempo_range | "\tempo" scalar steno_duration '=' tempo_range + | "\tempo" steno_duration '=' scalar | "\tempo" scalar >>> lte = lily.lilyObjects.LyTempoEvent(scalar='40') >>> str(lte) '\\tempo 40' - More complex: + A steno_duration paired with a single bpm scalar (and no tempoRange) + is the common case for a music21 MetronomeMark, e.g. quarter = 87: - >>> steno = lily.lilyObjects.LyStenoDuration('quarter') + >>> steno = lily.lilyObjects.LyStenoDuration('4') + >>> lte = lily.lilyObjects.LyTempoEvent(stenoDuration=steno, scalar=87) + >>> str(lte) + '\\tempo 4 = 87' + + More complex, with a tempo range. Note that steno_duration takes a + Lilypond duration number such as '4' for a quarter note, not the + English name 'quarter': + + >>> steno = lily.lilyObjects.LyStenoDuration('4') >>> tempoRange = lily.lilyObjects.LyTempoRange(70, 100) >>> lte = lily.lilyObjects.LyTempoEvent(tempoRange=tempoRange, stenoDuration=steno) >>> str(lte) - '\\tempo quarter = 70~100 ' + '\\tempo 4 = 70~100 ' >>> lte.scalar = 85 >>> str(lte) - '\\tempo 85 quarter = 70~100 ' + '\\tempo 85 4 = 70~100 ' ''' def __init__(self, tempoRange=None, stenoDuration=None, scalar=None): @@ -906,6 +917,11 @@ def stringOutput(self): else: return ' '.join([base, self.stenoDuration.stringOutput(), '=', self.tempoRange.stringOutput()]) + elif self.stenoDuration is not None: + if self.scalar is None: # pragma: no cover + raise LilyObjectsException( + 'If tempoRange is not defined but stenoDuration is, need a scalar') + return ' '.join([base, self.stenoDuration.stringOutput(), '=', str(self.scalar)]) elif self.scalar is None: # pragma: no cover raise LilyObjectsException('If tempoRange is not defined then need scalar') diff --git a/music21/lily/translate.py b/music21/lily/translate.py index 2975dcb20..9f307aaa4 100644 --- a/music21/lily/translate.py +++ b/music21/lily/translate.py @@ -33,6 +33,7 @@ from music21 import key from music21 import note from music21 import stream +from music21 import tempo from music21 import variant from music21.lily import lilyObjects as lyo @@ -1096,6 +1097,11 @@ def appendM21ObjectToContext(self, thisObject): lyObject = self.lyEmbeddedScmFromTimeSignature(thisObject) currentMusicList.append(lyObject) lyObject.setParent(contextObject) + elif 'MetronomeMark' in c: + lyObject = self.lyEmbeddedScmFromMetronomeMark(thisObject) + if lyObject is not None: + currentMusicList.append(lyObject) + lyObject.setParent(contextObject) elif 'Variant' in c: self.appendContextFromVariant(thisObject, coloredVariants=self.coloredVariants) elif 'SystemLayout' in c: @@ -1680,6 +1686,38 @@ def lyEmbeddedScmFromTimeSignature(self, ts): lpEmbeddedScm.content = keyScheme return lpEmbeddedScm + def lyEmbeddedScmFromMetronomeMark(self, mm: tempo.MetronomeMark) -> lyo.LyEmbeddedScm|None: + # noinspection PyShadowingNames + r''' + convert a :class:`~music21.tempo.MetronomeMark` object + to a lilyObjects.LyEmbeddedScm object + + * New in v11: MetronomeMark objects are now written out when + converting a Stream to Lilypond; previously they were silently dropped. + + >>> mm = tempo.MetronomeMark(number=87, referent=note.Note(type='quarter')) + >>> conv = lily.translate.LilypondConverter() + >>> print(conv.lyEmbeddedScmFromMetronomeMark(mm)) + \tempo 4 = 87 + + A MetronomeMark without a number produces no output: + + >>> mm = tempo.MetronomeMark() + >>> mm.number is None + True + >>> conv.lyEmbeddedScmFromMetronomeMark(mm) is None + True + ''' + if mm.number is None: + return None + + multipliedDuration = self.lyMultipliedDurationFromDuration(mm.referent) + tempoEvent = lyo.LyTempoEvent(stenoDuration=multipliedDuration.stenoDur, scalar=mm.number) + + lpEmbeddedScm = lyo.LyEmbeddedScm() + lpEmbeddedScm.content = tempoEvent.stringOutput() + lpEmbeddedScm.newlineIndent + return lpEmbeddedScm + def setContextForTupletStart(self, inObj): r''' if the inObj has tuplets then we set a new context @@ -2596,6 +2634,27 @@ def testColors(self): "c' 4 " ) + def testMetronomeMark(self): + mm = tempo.MetronomeMark(number=87, referent=note.Note(type='quarter')) + lpEmbeddedScm = LilypondConverter().lyEmbeddedScmFromMetronomeMark(mm) + self.assertEqual(str(lpEmbeddedScm).strip(), r'\tempo 4 = 87') + + # a MetronomeMark with no number produces no output + textOnly = tempo.MetronomeMark() + self.assertIsNone(LilypondConverter().lyEmbeddedScmFromMetronomeMark(textOnly)) + + def testMetronomeMarkWrittenInStream(self): + # https://github.com/cuthbertLab/music21/issues/1852 + from music21 import key + from music21 import meter + keysig = key.Key('a-') + mm = tempo.MetronomeMark(number=87, referent=note.Note(type='quarter')) + timesig = meter.TimeSignature('3/4') + s = stream.Stream([keysig, mm, timesig, note.Note()]) + lpc = LilypondConverter() + lpc.loadObjectFromScore(s, makeNotation=False) + self.assertIn(r'\tempo 4 = 87', str(lpc.topLevelObject)) + class TestExternal(unittest.TestCase): show = True