diff --git a/documentation/source/usersGuide/usersGuide_14_timeSignatures.ipynb b/documentation/source/usersGuide/usersGuide_14_timeSignatures.ipynb index d5085a5390..a259e54f63 100644 --- a/documentation/source/usersGuide/usersGuide_14_timeSignatures.ipynb +++ b/documentation/source/usersGuide/usersGuide_14_timeSignatures.ipynb @@ -982,7 +982,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Whoops! The last F of measure 2 is actually too long, and the next measure's first F begins half a beat later. Let's run the powerful command :meth:`~music21.stream.Stream.makeNotation` first before showing:" + "Whoops! The last F of measure 2 is actually too long, and the next measure's first F begins half a beat later. Let's run the powerful command :meth:`~music21.stream.base.Stream.makeNotation` first before showing:" ] }, { diff --git a/documentation/source/usersGuide/usersGuide_31_clefs.ipynb b/documentation/source/usersGuide/usersGuide_31_clefs.ipynb index a4322ca8e0..8526752b74 100644 --- a/documentation/source/usersGuide/usersGuide_31_clefs.ipynb +++ b/documentation/source/usersGuide/usersGuide_31_clefs.ipynb @@ -2226,7 +2226,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "This is accomplished because before showing the Stream, `music21` runs the powerful method :meth:`~music21.stream.Stream.makeNotation` on the stream. This calls a function in :ref:`moduleStreamMakeNotation` module called :func:`~music21.stream.makeNotation.makeBeams` that does the real work. That function checks the stream to see if any beams exist on it:" + "This is accomplished because before showing the Stream, `music21` runs the powerful method :meth:`~music21.stream.base.Stream.makeNotation` on the stream. This calls a function in :ref:`moduleStreamMakeNotation` module called :func:`~music21.stream.makeNotation.makeBeams` that does the real work. That function checks the stream to see if any beams exist on it:" ] }, { diff --git a/music21/audioSearch/__init__.py b/music21/audioSearch/__init__.py index 1cc74a8c31..9de265b936 100644 --- a/music21/audioSearch/__init__.py +++ b/music21/audioSearch/__init__.py @@ -820,7 +820,7 @@ def notesAndDurationsToStream( returns a :class:`~music21.stream.Score` object, containing a metadata object and a single :class:`~music21.stream.Part` object, which in turn - contains the notes, etc. Does not run :meth:`~music21.stream.Stream.makeNotation` + contains the notes, etc. Does not run :meth:`~music21.stream.base.Stream.makeNotation` on the Score. diff --git a/music21/base.py b/music21/base.py index 339869e781..1c06dc336c 100644 --- a/music21/base.py +++ b/music21/base.py @@ -2558,6 +2558,11 @@ def write(self, fmt=None, fp=None, **keywords): # pragma: no cover be used. For most people that is musicxml. Returns the full path to the file. + + Some formats, including .musicxml, create a copy of the stream, pack it into a well-formed + score if necessary, and run :meth:`~music21.stream.Score.makeNotation`. To + avoid this when writing .musicxml, use `makeNotation=False`, an advanced option + that prioritizes speed but may not guarantee satisfactory notation. ''' if fmt is None: # get setting in environment fmt = environLocal['writeFormat'] @@ -2605,6 +2610,7 @@ def show(self, fmt=None, app=None, **keywords): # pragma: no cover fmt argument or, if not provided, the format set in the user's Environment Valid formats include (but are not limited to):: + musicxml text midi @@ -2618,6 +2624,11 @@ def show(self, fmt=None, app=None, **keywords): # pragma: no cover N.B. score.write('lily') returns a bare lilypond file, score.show('lily') runs it through lilypond and displays it as a png. + + Some formats, including .musicxml, create a copy of the stream, pack it into a well-formed + score if necessary, and run :meth:`~music21.stream.Score.makeNotation`. To + avoid this when showing .musicxml, use `makeNotation=False`, an advanced option + that prioritizes speed but may not guarantee satisfactory notation. ''' # note that all formats here must be defined in # common.VALID_SHOW_FORMATS diff --git a/music21/braille/translate.py b/music21/braille/translate.py index 906686e686..124683fc19 100644 --- a/music21/braille/translate.py +++ b/music21/braille/translate.py @@ -26,12 +26,12 @@ Keywords: -* **inPlace** (False): If False, then :meth:`~music21.stream.Stream.makeNotation` is called +* **inPlace** (False): If False, then :meth:`~music21.stream.base.Stream.makeNotation` is called on all :class:`~music21.stream.Measure`, :class:`~music21.stream.Part`, and :class:`~music21.stream.PartStaff` instances. Copies of those objects are then used to transcribe the music. If True, the transcription is done "as is." This is useful for strict transcription because - sometimes :meth:`~music21.stream.Stream.makeNotation` + sometimes :meth:`~music21.stream.base.Stream.makeNotation` introduces some unwanted artifacts in the music. However, the music needs to be organized into measures for transcription to work. * **debug** (False): If True, a braille-english representation of the music is returned. Useful diff --git a/music21/converter/subConverters.py b/music21/converter/subConverters.py index a7546f370e..dcf6c1a1b5 100644 --- a/music21/converter/subConverters.py +++ b/music21/converter/subConverters.py @@ -786,7 +786,6 @@ def parseFile(self, fp, number=None): ''' Open Noteworthy data (as nwctxt) from a file path. - >>> import os #_DOCS_HIDE >>> nwcTranslatePath = common.getSourceFilePath() / 'noteworthy' #_DOCS_HIDE >>> filePath = nwcTranslatePath / 'Part_OWeisheit.nwctxt' #_DOCS_HIDE >>> #_DOCS_SHOW paertPath = converter.parse('d:/desktop/arvo_part_o_weisheit.nwctxt') @@ -1002,10 +1001,24 @@ def writeDataStream(self, fp, dataBytes: bytes) -> pathlib.Path: # pragma: no c return fp - def write(self, obj, fmt, fp=None, subformats=None, - compress=False, **keywords): # pragma: no cover + def write(self, + obj, + fmt, + *, + fp=None, + subformats=None, + makeNotation=True, + compress=False, + **keywords): # pragma: no cover ''' Write to a .musicxml file. + + Set `makeNotation=False` to prevent fixing up the notation, and where possible, + to prevent making additional deepcopies. (This option cannot be used if `obj` is not a + :class:`~music21.stream.Score`.) `makeNotation=True` generally solves common notation + issues, whereas `makeNotation=False` is intended for advanced users facing + special cases where speed is a priority or making notation reverses user choices. + Set `compress=True` to immediately compress the output to a .mxl file. ''' from music21.musicxml import archiveTools, m21ToXml @@ -1019,8 +1032,10 @@ def write(self, obj, fmt, fp=None, subformats=None, defaults.title = '' defaults.author = '' + dataBytes: bytes = b'' generalExporter = m21ToXml.GeneralObjectExporter(obj) - dataBytes: bytes = generalExporter.parse() + generalExporter.makeNotation = makeNotation + dataBytes = generalExporter.parse() writeDataStreamFp = fp if fp is not None and subformats: # could be empty list @@ -1494,6 +1509,44 @@ def testWriteMXL(self): self.assertTrue(str(mxlPath).endswith('.mxl')) os.remove(mxlPath) + def testWriteMusicXMLMakeNotation(self): + from music21 import converter + from music21 import note + from music21.musicxml.xmlObjects import MusicXMLExportException + + m1 = stream.Measure(note.Note(quarterLength=5.0)) + m2 = stream.Measure() + p = stream.Part([m1, m2]) + s = stream.Score(p) + + self.assertEqual(len(m1.notes), 1) + self.assertEqual(len(m2.notes), 0) + + out1 = s.write(makeNotation=True) + # 4/4 will be assumed; quarter note will be moved to measure 2 + roundtrip_back = converter.parse(out1) + self.assertEqual( + len(roundtrip_back.parts.first().getElementsByClass(stream.Measure)[0].notes), 1) + self.assertEqual( + len(roundtrip_back.parts.first().getElementsByClass(stream.Measure)[1].notes), 1) + + out2 = s.write(makeNotation=False) + roundtrip_back = converter.parse(out2) + # 4/4 will not be assumed; quarter note will still be split out from 5.0QL + # but it will remain in measure 1 + # and there will be no rests in measure 2 + self.assertEqual( + len(roundtrip_back.parts.first().getElementsByClass(stream.Measure)[0].notes), 2) + self.assertEqual( + len(roundtrip_back.parts.first().getElementsByClass(stream.Measure)[1].notes), 0) + + # makeNotation = False cannot be used on non-scores + with self.assertRaises(MusicXMLExportException): + p.write(makeNotation=False) + + for out in (out1, out2): + os.remove(out) + class TestExternal(unittest.TestCase): # pragma: no cover diff --git a/music21/musicxml/m21ToXml.py b/music21/musicxml/m21ToXml.py index 57f309b56c..53a036381f 100644 --- a/music21/musicxml/m21ToXml.py +++ b/music21/musicxml/m21ToXml.py @@ -19,6 +19,7 @@ import io import math import unittest +import warnings import xml.etree.ElementTree as ET from xml.etree.ElementTree import Element, SubElement, ElementTree from typing import List, Optional, Union @@ -50,6 +51,7 @@ from music21.musicxml.partStaffExporter import PartStaffExporterMixin from music21.musicxml import xmlObjects from music21.musicxml.xmlObjects import MusicXMLExportException +from music21.musicxml.xmlObjects import MusicXMLWarning from music21 import environment _MOD = "musicxml.m21ToXml" @@ -300,6 +302,10 @@ def _synchronizeIds(element: Element, m21Object: Optional[base.Music21Object]) - class GeneralObjectExporter: + ''' + Packs any `Music21Object` into a well-formed score and exports + a bytes object MusicXML representation. + ''' classMapping = OrderedDict([ ('Score', 'fromScore'), ('Part', 'fromPart'), @@ -318,12 +324,18 @@ class GeneralObjectExporter: def __init__(self, obj=None): self.generalObj = obj + self.makeNotation: bool = True def parse(self, obj=None): r''' - return a bytes object representation of anything from a + Return a bytes object representation of anything from a Score to a single pitch. + When :attr:`makeNotation` is True (default), wraps `obj` in a well-formed + `Score`, makes a copy, and runs :meth:`~music21.stream.base.Stream.makeNotation` + on each of the parts. To skip copying and making notation, set `.makeNotation` + on this instance to False. + >>> p = pitch.Pitch('D#4') >>> GEX = musicxml.m21ToXml.GeneralObjectExporter(p) >>> out = GEX.parse() # out is bytes @@ -384,16 +396,24 @@ def parse(self, obj=None): ''' if obj is None: obj = self.generalObj - outObj = self.fromGeneralObject(obj) - # TODO: set whether to do an additional score copy in submethods. - return self.parseWellformedObject(outObj) + if self.makeNotation: + outObj = self.fromGeneralObject(obj) + return self.parseWellformedObject(outObj) + else: + if 'Score' not in obj.classes: + raise MusicXMLExportException('Can only export Scores with makeNotation=False') + return self.parseWellformedObject(obj) - def parseWellformedObject(self, sc): + def parseWellformedObject(self, sc) -> bytes: ''' - parse an object that has already gone through the - `.fromGeneralObject` conversion. Returns bytes. + Parse an object that has already gone through the + `.fromGeneralObject` conversion, which has produced a copy with + :meth:`~music21.stream.Score.makeNotation` run on it + (unless :attr:`makeNotation` is False). + + Returns bytes. ''' - scoreExporter = ScoreExporter(sc) + scoreExporter = ScoreExporter(sc, makeNotation=self.makeNotation) scoreExporter.parse() return scoreExporter.asBytes() @@ -431,15 +451,18 @@ def fromGeneralObject(self, obj): def fromScore(self, sc): ''' - the best one of all -- a perfectly made Score (or something like that) + Copies the input score and runs :meth:`~music21.stream.Score.makeNotation` on the copy. ''' scOut = sc.makeNotation(inPlace=False) + if not scOut.isWellFormedNotation(): + warnings.warn(f'{scOut} is not well-formed; see isWellFormedNotation()', + category=MusicXMLWarning) # scOut.makeImmutable() return scOut def fromPart(self, p): ''' - from a part, put it in a score... + From a part, put it in a new score. ''' if p.isFlat: p = p.makeMeasures() @@ -1339,7 +1362,7 @@ class ScoreExporter(XMLExporterBase, PartStaffExporterMixin): a musicxml Element. ''' - def __init__(self, score: Optional[stream.Score] = None): + def __init__(self, score: Optional[stream.Score] = None, makeNotation: bool = True): super().__init__() if score is None: # should not be done this way. @@ -1370,6 +1393,8 @@ def __init__(self, score: Optional[stream.Score] = None): self.parts = [] + self.makeNotation: bool = makeNotation + def parse(self): ''' the main function to call. @@ -1564,10 +1589,12 @@ def setScoreLayouts(self): def parsePartlikeScore(self): ''' - called by .parse() if the score has individual parts. + Called by .parse() if the score has individual parts. - Calls makeRests() for the part, then creates a PartExporter for each part, - and runs .parse() on that part. appends the PartExporter to self. + Calls makeRests() for the part (if `ScoreExporter.makeNotation` is True), + then creates a `PartExporter` for each part, and runs .parse() on that part. + Appends the PartExporter to `self.partExporterList` + and runs .parse() on that part. Appends the PartExporter to self. Hide rests created at this late stage. @@ -1579,14 +1606,15 @@ def parsePartlikeScore(self): >>> '' in outStr True ''' - # self.parts is a stream of parts - # hide any rests created at this late stage, because we are - # merely trying to fill up MusicXML display, not impose things on users - self.parts.makeRests(refStreamOrTimeRange=self.refStreamOrTimeRange, - inPlace=True, - hideRests=True, - timeRangeFromBarDuration=True, - ) + if self.makeNotation: + # self.parts is a stream of parts + # hide any rests created at this late stage, because we are + # merely trying to fill up MusicXML display, not impose things on users + self.parts.makeRests(refStreamOrTimeRange=self.refStreamOrTimeRange, + inPlace=True, + hideRests=True, + timeRangeFromBarDuration=True, + ) count = 0 sp = list(self.parts) @@ -2384,10 +2412,12 @@ def __init__(self, partObj: Union[stream.Part, stream.Score, None] = None, paren self.meterStream = stream.Stream() self.refStreamOrTimeRange = [0.0, 0.0] self.midiChannelList = [] + self.makeNotation = True else: self.meterStream = parent.meterStream self.refStreamOrTimeRange = parent.refStreamOrTimeRange self.midiChannelList = parent.midiChannelList # shared list + self.makeNotation = parent.makeNotation self.instrumentStream = None self.firstInstrumentObject = None @@ -2401,11 +2431,27 @@ def __init__(self, partObj: Union[stream.Part, stream.Score, None] = None, paren def parse(self): ''' Set up instruments, create a partId (if no good one exists) and sets it on - , fixes up the notation (`fixupNotationFlat()` or `fixupNotationMeasured()`) + , fixes up the notation (:meth:`fixupNotationFlat` or :meth:`fixupNotationMeasured`, + if :attr:`makeNotation` is True), setsIdLocals on spanner bundle. runs parse() on each measure's MeasureExporter and appends the output to the object. In other words, one-stop shopping. + + :attr:`makeNotation` when False, will avoid running + :meth:`~music21.stream.base.Stream.makeNotation` + on the Part. Generally this attribute is set on `GeneralObjectExporter` + or `ScoreExporter` and read from there. Running with `makeNotation` + as False will raise `MusicXMLExportException` if no measures are present. + + >>> from music21.musicxml.m21ToXml import PartExporter + >>> noMeasures = stream.Part(note.Note()) + >>> pex = PartExporter(noMeasures) + >>> pex.makeNotation = False + >>> pex.parse() + Traceback (most recent call last): + music21.musicxml.xmlObjects.MusicXMLExportException: + Cannot export with makeNotation=False if there are no measures ''' self.instrumentSetup() @@ -2415,10 +2461,13 @@ def parse(self): self.stream = self.stream.splitAtDurations(recurse=True)[0] # Suppose that everything below this is a measure - if not self.stream[stream.Measure]: + if self.makeNotation and not self.stream.getElementsByClass(stream.Measure): self.fixupNotationFlat() - else: + elif self.makeNotation: self.fixupNotationMeasured() + elif not self.stream.getElementsByClass(stream.Measure): + raise MusicXMLExportException( + 'Cannot export with makeNotation=False if there are no measures') # make sure that all instances of the same class have unique ids self.spannerBundle.setIdLocals() for m in self.stream.getElementsByClass(stream.Measure): @@ -6432,12 +6481,23 @@ def testSetPartsAndRefStreamMeasure(self): self.assertSequenceEqual(measuresAtOffsetZero, p.elements[:1]) def testFromScoreNoParts(self): + ''' + Badly nested streams should warn but output no gaps. + ''' s = stream.Score() s.append(meter.TimeSignature('1/4')) s.append(note.Note()) s.append(note.Note()) gex = GeneralObjectExporter(s) - tree = ET.fromstring(gex.parse().decode('utf-8')) + + with self.assertWarns(MusicXMLWarning) as cm: + tree = ET.fromstring(gex.parse().decode('utf-8')) + self.assertIn(repr(s).split(' 0x')[0], str(cm.warning)) + self.assertIn(' is not well-formed; see isWellFormedNotation()', str(cm.warning)) + # The original score with its original address should not + # be found in the message because makeNotation=True makes a copy + self.assertNotIn(repr(s), str(cm.warning)) + # Assert no gaps in stream self.assertSequenceEqual(tree.findall('.//forward'), []) diff --git a/music21/musicxml/xmlObjects.py b/music21/musicxml/xmlObjects.py index 0d01c95e93..848d9d033f 100644 --- a/music21/musicxml/xmlObjects.py +++ b/music21/musicxml/xmlObjects.py @@ -123,6 +123,10 @@ class MusicXMLImportException(MusicXMLException): pass +class MusicXMLWarning(UserWarning): + pass + + # ------------------------------------------------------------------------------ # helpers STYLE_ATTRIBUTES_YES_NO_TO_BOOL = ('hideObjectOnPrint', ) diff --git a/music21/note.py b/music21/note.py index 628a2c82b7..5b99a62d4c 100644 --- a/music21/note.py +++ b/music21/note.py @@ -878,7 +878,7 @@ class NotRest(GeneralNote): ''' Parent class for Note-like objects that are not rests; that is to say they have a stem, can be tied, and volume is important. - Basically, that's a `Note` or + Basically, that's a `Note` or `Chord` or `Unpitched` object for now. ''' # unspecified means that there may be a stem, but its orientation @@ -1130,7 +1130,7 @@ def hasVolumeInformation(self) -> bool: def pitches(self) -> Tuple[pitch.Pitch]: ''' Returns an empty tuple. (Useful for iterating over NotRests since they - include Notes and Chords + include Notes and Chords.) ''' return () diff --git a/music21/stream/base.py b/music21/stream/base.py index 314b2f0999..86dc47a9ee 100644 --- a/music21/stream/base.py +++ b/music21/stream/base.py @@ -9424,7 +9424,7 @@ def isTwelveTone(self): return False return True - def isWellFormedNotation(self): + def isWellFormedNotation(self) -> bool: # noinspection PyShadowingNames ''' Return True if, given the context of this Stream or Stream subclass, diff --git a/music21/stream/tests.py b/music21/stream/tests.py index 9045b9334b..995e83b9cc 100644 --- a/music21/stream/tests.py +++ b/music21/stream/tests.py @@ -8245,10 +8245,15 @@ def testWrite(self): def testOpusWrite(self): import os + from music21 import converter o = Opus() s1 = Score() s2 = Score() + p1 = converter.parse('tinyNotation: 4/4 e1') + p2 = converter.parse('tinyNotation: 4/4 f1') + s1.append(p1) + s2.append(p2) o.append([s1, s2]) out = o.write()