From 9c67043c7c27ef020c1b07b7790fb4a74d6bd374 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Sun, 7 May 2023 10:37:55 -0400 Subject: [PATCH 1/5] Improve measure numbers on ClercqTemperley de Clercq / Temperley files were being exported with all measure numbers set to 0. Now they are properly numbered. Deprecate `toScore` in CT parsing, add `toPart` since the format currently returns a Part object. (this should be changed to a Score object instead to fit w/ RomanText parsing, but not done now). Typing improvements for the converter. --- music21/converter/subConverters.py | 4 +- music21/romanText/clercqTemperley.py | 105 ++++++++++++++++----------- 2 files changed, 65 insertions(+), 44 deletions(-) diff --git a/music21/converter/subConverters.py b/music21/converter/subConverters.py index 5f9f181376..c0eac7a970 100644 --- a/music21/converter/subConverters.py +++ b/music21/converter/subConverters.py @@ -1400,10 +1400,10 @@ class ConverterClercqTemperley(SubConverter): registerFormats = ('cttxt', 'har') registerInputExtensions = ('cttxt', 'har') - def parseData(self, strData, number=None): + def parseData(self, strData: str | pathlib.Path, number=None): from music21.romanText import clercqTemperley ctSong = clercqTemperley.CTSong(strData) - self.stream = ctSong.toScore() + self.stream = ctSong.toPart() def parseFile(self, filePath: pathlib.Path | str, diff --git a/music21/romanText/clercqTemperley.py b/music21/romanText/clercqTemperley.py index b83d34f6b1..bec2535b26 100644 --- a/music21/romanText/clercqTemperley.py +++ b/music21/romanText/clercqTemperley.py @@ -306,7 +306,7 @@ def __init__(self, textFile: str | pathlib.Path = '', **keywords): # same for time signatures self.tsList: list[meter.TimeSignature] = [] - self._scoreObj = None + self._partObj = None self.year = None self._homeTimeSig = None @@ -511,34 +511,42 @@ def homeKeySig(self): pass return self._homeKeySig - def toScore(self, labelRomanNumerals=True, labelSubsectionsOnScore=True): + def toPart(self, labelRomanNumerals=True, labelSubsectionsOnScore=True) -> stream.Part: # noinspection PyShadowingNames ''' - creates Score object out of a from CTSong...also creates CTRule objects in the process, + creates a Part object out of a from CTSong...also creates CTRule objects in the process, filling their .streamFromCTSong attribute with the corresponding smaller inner stream. Individual attributes of a rule are defined by the entire CTSong, such as meter and time signature, so creation of CTRule objects typically occurs only from this method and directly from the clercqTemperley text. >>> s = romanText.clercqTemperley.CTSong(romanText.clercqTemperley.BlitzkriegBopCT) - >>> scoreObj = s.toScore() - >>> scoreObj.highestOffset + >>> partObj = s.toPart() + >>> partObj.highestOffset 380.0 ''' self.labelRomanNumerals = labelRomanNumerals self.labelSubsectionsOnScore = labelSubsectionsOnScore - if self._scoreObj is not None: - return self._scoreObj - scoreObj = stream.Part() + if self._partObj is not None: + return self._partObj + partObj = stream.Part() measures = self.rules['S'].expand() - scoreObj.append(measures) + for i, m in enumerate(measures): + m.number = i + 1 + partObj.append(measures) - scoreObj.insert(0, metadata.Metadata()) - scoreObj.metadata.title = self.title + partObj.insert(0, metadata.Metadata()) + partObj.metadata.title = self.title - self._scoreObj = scoreObj - return scoreObj + self._partObj = partObj + return partObj + def toScore(self, labelRomanNumerals=True, labelSubsectionsOnScore=True) -> stream.Part: + ''' + DEPRECATED: use .toPart() instead. This method will be removed in v.10 + ''' + return self.toPart(labelRomanNumerals=labelRomanNumerals, + labelSubsectionsOnScore=labelSubsectionsOnScore) class CTRuleException(exceptions21.Music21Exception): pass @@ -548,7 +556,7 @@ class CTRule(prebase.ProtoM21Object): ''' CTRule objects correspond to the individual lines defined in a :class:`~music21.romanText.clercqTemperley.CTSong` object. They are typically - created by the parser after a CTSong object has been created and the .toScore() method + created by the parser after a CTSong object has been created and the .toPart() method has been called on that object. The usefulness of each CTRule object is that each has a :meth:`~music21.romanText.clercqTemperley.CTRUle.streamFromCTSong` attribute, which is the stream from the entire score that the rule corresponds to. @@ -586,7 +594,11 @@ def _setParent(self, parent): ''') # -------------------------------------------------------------------------- - def expand(self, ts=None, ks=None): + def expand( + self, + ts: meter.TimeSignature | None = None, + ks: key.KeySignature = None + ) -> list[stream.Measure]: ''' The meat of it all -- expand one rule completely and return a list of Measure objects. ''' @@ -594,10 +606,10 @@ def expand(self, ts=None, ks=None): ts = meter.TimeSignature('4/4') if ks is None: ks = key.Key('C') - measures = [] + measures: list[stream.Measure] = [] - lastRegularAtom = None - lastChord = None + lastRegularAtom: str = '' + lastChord: chord.Chord | None = None for content, sep, numReps in self._measureGroups(): lastChordIsInSameMeasure = False @@ -634,10 +646,10 @@ def expand(self, ts=None, ks=None): ks = key.Key(key.convertKeyStringToMusic21KeyString(atomContent)) elif atom == '.': - if lastRegularAtom is None: + if not lastRegularAtom: raise CTRuleException(f' . w/o previous atom: {self}') regularAtoms.append(lastRegularAtom) - elif atom in ('', None): + elif not atom: pass else: regularAtoms.append(atom) @@ -688,9 +700,11 @@ def expand(self, ts=None, ks=None): return measures - def _measureGroups(self): + def _measureGroups(self) -> list[tuple[str, str, int]]: ''' - Returns content, "|" (normal) or "$" (expansion), and number of repetitions. + Returns a list of 3-tuples where each tuple consists of the + str content, either "|" (a normal measure ) or "$" (an expansion), + and the number of repetitions. Comments are stripped. >>> rs = ('In: [A] [4/4] $Vr $BP*3 I IV | I | ' + ... '$BP*3 I IV | I | | R |*4 I |*4 % This is a comment') @@ -701,7 +715,6 @@ def _measureGroups(self): ('BP', '$', 3), ('I IV', '|', 1), ('I', '|', 1), ('.', '|', 1), ('R', '|', 4), ('I', '|', 4)] - >>> r = romanText.clercqTemperley.CTRule('In: $IP*3 I | | | $BP*2') >>> r._measureGroups() [('IP', '$', 3), ('I', '|', 1), ('.', '|', 1), ('.', '|', 1), ('BP', '$', 2)] @@ -730,10 +743,10 @@ def _measureGroups(self): >>> measures[3][-1].quarterLength 2.0 ''' - measureGroups1 = [] - measureGroups2 = [] - measureGroups3 = [] - measureGroupTemp = self.SPLITMEASURES.split(self.musicText) + measureGroups1: list[tuple[str, str]] = [] + measureGroups2: list[tuple[str, str, int]] = [] + measureGroups3: list[tuple[str, str, int]] = [] + measureGroupTemp: list[str] = self.SPLITMEASURES.split(self.musicText) # first pass -- separate by | or |*3, etc. for i in range(0, len(measureGroupTemp), 2): content = measureGroupTemp[i].strip() @@ -790,7 +803,11 @@ def _measureGroups(self): return measureGroups3 # -------------------------------------------------------------------------- - def isSame(self, rn, lastChord): + def isSame(self, rn: roman.RomanNumeral, lastChord: chord.Chord | None) -> bool: + ''' + Returns True if the pitches of the RomanNumeral are the same as the pitches + of lastChord. Returns False if lastChord is None. + ''' if lastChord is None: same = False else: @@ -802,7 +819,11 @@ def isSame(self, rn, lastChord): same = False return same - def addOptionalTieAndLyrics(self, rn, lastChord): + def addOptionalTieAndLyrics( + self, + rn: roman.RomanNumeral, + lastChord: chord.Chord | None + ) -> None: ''' Adds ties to chords that are the same. Adds lyrics to chords that change. ''' @@ -822,7 +843,7 @@ def addOptionalTieAndLyrics(self, rn, lastChord): def insertKsTs(self, m: stream.Measure, ts: meter.TimeSignature, - ks: key.KeySignature): + ks: key.KeySignature) -> None: ''' Insert a new time signature or key signature into measure m, if it's not already in the stream somewhere. @@ -839,7 +860,7 @@ def insertKsTs(self, m.keySignature = ks self.parent.tsList.append(ks) - def fixupChordAtom(self, atom): + def fixupChordAtom(self, atom: str) -> str: ''' changes some CT values into music21 values @@ -861,7 +882,7 @@ def fixupChordAtom(self, atom): return atom # -------------------------------------------------------------------------- - def _setMusicText(self, value): + def _setMusicText(self, value: str) -> None: self._musicText = str(value) def _getMusicText(self): @@ -891,7 +912,7 @@ def _getMusicText(self): ''') @property - def comment(self): + def comment(self) -> str | None: ''' Get the comment of a CTRule object. @@ -905,10 +926,7 @@ def comment(self): else: return None - def _setLHS(self, value): - self._LHS = str(value) - - def _getLHS(self): + def _getLHS(self) -> str: if self._LHS not in (None, ''): return self._LHS @@ -922,6 +940,9 @@ def _getLHS(self): else: return '' + def _setLHS(self, value: str) -> None: + self._LHS = str(value) + LHS = property(_getLHS, _setLHS, doc=''' Get the LHS (Left Hand Side) of the CTRule. If not specified explicitly but CTtext present, searches @@ -984,9 +1005,9 @@ class TestExternal(unittest.TestCase): def testB(self): from music21.romanText import clercqTemperley s = clercqTemperley.CTSong(BlitzkriegBopCT) - scoreObj = s.toScore() + partObj = s.toPart() if self.show: - scoreObj.show() + partObj.show() def x_testA(self): pass @@ -1001,7 +1022,7 @@ def x_testA(self): # txt = f.read() # # s = clercqTemperley.CTSong(txt) - # for chord in s.toScore().flatten().getElementsByClass(chord.Chord): + # for chord in s.toPart().flatten().getElementsByClass(chord.Chord): # try: # x = chord.pitches # except: @@ -1012,12 +1033,12 @@ def x_testA(self): # try: # fileName = 'C:\\dt\\' + num + '.txt' # s = clercqTemperley.CTSong(fileName) - # print(s.toScore().highestOffset, 'Success', num) + # print(s.toPart().highestOffset, 'Success', num) # except: # print('ERROR', num) # s = clercqTemperley.CTSong(exampleClercqTemperley) - # sc = s.toScore() + # sc = s.toPart() # print(sc.highestOffset) # sc.show() From 0d3386b7569320537ad5f0642d813b2b1045814f Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Sun, 7 May 2023 11:05:05 -0400 Subject: [PATCH 2/5] lint, docs improve, formalDivision editorial --- music21/converter/subConverters.py | 4 +- music21/romanText/clercqTemperley.py | 74 ++++++++++++++++++++++------ 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/music21/converter/subConverters.py b/music21/converter/subConverters.py index c0eac7a970..19b502e446 100644 --- a/music21/converter/subConverters.py +++ b/music21/converter/subConverters.py @@ -1397,8 +1397,8 @@ class ConverterClercqTemperley(SubConverter): Wrapper for parsing harmonic definitions in Trevor de Clercq and David Temperley's format. ''' - registerFormats = ('cttxt', 'har') - registerInputExtensions = ('cttxt', 'har') + registerFormats = ('cttxt', 'har', 'clercqTemperley') + registerInputExtensions = ('cttxt', 'har', 'clercqTemperley') def parseData(self, strData: str | pathlib.Path, number=None): from music21.romanText import clercqTemperley diff --git a/music21/romanText/clercqTemperley.py b/music21/romanText/clercqTemperley.py index bec2535b26..804c4a9b79 100644 --- a/music21/romanText/clercqTemperley.py +++ b/music21/romanText/clercqTemperley.py @@ -21,6 +21,7 @@ import io import pathlib import re +import typing as t import unittest from collections import OrderedDict @@ -37,6 +38,9 @@ from music21 import stream from music21 import tie +if t.TYPE_CHECKING: + from music21 import chord + environLocal = environment.Environment('romanText.clercqTemperley') # clercqTemperley test files used as tests throughout this module @@ -108,10 +112,24 @@ class CTSong(prebase.ProtoM21Object): # noinspection PyShadowingNames r""" This parser is an object-oriented approach to parsing clercqTemperley text files into music. + It is an advanced method. Most people should just run: + + >>> #_DOCS_SHOW p = converter.parse('clercqTemperley/dt/BrownEyedGirl.cttxt') + + or if the file ends in .txt then give the format explicitly as either 'clerqTemperley' + or 'cttxt': + + >>> #_DOCS_SHOW p = converter.parse('BrownEyedGirl.txt', format='clercqTemperley') + + Advanced: if you want access to a CTSong object itself (for manipulating the input before + converting to a string, etc. then create a CTSong object with one of the following inputs: - Create a CTSong object one of two ways: - 1) by passing in the string, with newline characters (\\n) at the end of each line - 2) by passing in the text file as a string, and have python open the file and read the text + 1. by passing in the string, with newline characters (\\n) at the end of each line + + 2. by passing in the filename as a string or path, and have Python + open the file and read the text + + Given this file, you could create a CTSong object with: >>> exampleClercqTemperley = ''' ... % Brown-Eyed Girl @@ -123,27 +141,52 @@ class CTSong(prebase.ProtoM21Object): ... S: [G] $In $Vr $Vr $Ch $VP $Vr $Ch2 ... ''' - >>> exCT = romanText.clercqTemperley.exampleClercqTemperley + >>> exCT = romanText.clercqTemperley.exampleClercqTemperley #_DOCS_HIDE >>> s = romanText.clercqTemperley.CTSong(exCT) #_DOCS_HIDE + + Or: + >>> #_DOCS_SHOW s = romanText.clercqTemperley.CTSong('C:/Brown-Eyed_Girl.txt') - When you call the .toScore() method on the newly created CTSong object, + When you call the .toPart() method on the newly created CTSong object, the code extracts meaningful properties (such as title, text, comments, year, rules, home time Signature, and home Key Signature) from the text file - and makes these accessible as below. + and returns a new Part object. It also makes these properties available on the + CTSong object. - The toScore() method has two optional labeling parameters, labelRomanNumerals and + The toPart() method has two optional labeling parameters, labelRomanNumerals and labelSubsectionsOnScore. Both are set to True by default. Thus, the created score will have labels (on the chord's lyric) for each roman numeral as well as for each section in the song (LHS). In case of a recursive definition (a rule contains a reference to another rule), both labels are printed, with the deepest reference on the smallest lyric line. - >>> #_DOCS_SHOW s.toScore().show() + >>> p = s.toPart() + >>> #_DOCS_SHOW p.show() .. image:: images/ClercqTemperleyExbrown-eyed_girl.png :width: 500 + >>> firstRN = p[roman.RomanNumeral][0] + >>> firstRN.lyric + 'I\nVP\nIn' + + All roman numerals mark which formal division they are in: + + >>> 'formalDivision' in firstRN.editorial + True + >>> firstRN.editorial.formalDivision + ['VP', 'In'] + + The second RomanNumeral is at the start of no formal divisions + + >>> secondRN = p[roman.RomanNumeral][1] + >>> secondRN.lyric + 'IV' + >>> secondRN.editorial.formalDivision + [] + + >>> s.title 'Brown-Eyed Girl' @@ -163,7 +206,7 @@ class CTSong(prebase.ProtoM21Object): >>> s.year 1967 - Upon calling toScore(), CTRule objects are also created. CTRule objects are + Upon calling toPart(), CTRule objects are also created. CTRule objects are the individual rules that make up the song object. For example, >>> s.rules @@ -214,8 +257,6 @@ class CTSong(prebase.ProtoM21Object): ... S: [A] $In $Vr $Vr $Br $Vr $Vr $Br $Vr $Vr $Co ... ''' - OMIT_FROM_DOCS - Another example using a different Clercq-Temperley file RockClockCT = @@ -227,8 +268,8 @@ class CTSong(prebase.ProtoM21Object): S: [A] $In $Vr $Vr $Vr $Vr $Vr $Vr $Vrf % 3rd and 6th verses are instrumental >>> s = romanText.clercqTemperley.CTSong(romanText.clercqTemperley.RockClockCT) - >>> score = s.toScore() - >>> score.highestTime + >>> part = s.toPart() + >>> part.highestTime 376.0 >>> s.title @@ -268,6 +309,8 @@ class CTSong(prebase.ProtoM21Object): >>> rule.sectionName 'Introduction' + OMIT_FROM_DOCS + one more example...the bane of this parser's existence...:: % Ring Of Fire @@ -287,7 +330,7 @@ class CTSong(prebase.ProtoM21Object): """ - _DOC_ORDER = ['text', 'toScore', 'title', 'homeTimeSig', 'homeKeySig', 'comments', 'rules'] + _DOC_ORDER = ['text', 'toPart', 'title', 'homeTimeSig', 'homeKeySig', 'comments', 'rules'] _DOC_ATTR: dict[str, str] = { 'year': ''' The year of the CTSong; not formally defined @@ -664,12 +707,14 @@ def expand( for atom in regularAtoms: if atom == 'R': rest = note.Rest(quarterLength=atomLength) + rest.editorial.formalDivision = [] lastChord = None lastChordIsInSameMeasure = False m.append(rest) else: atom = self.fixupChordAtom(atom) rn = roman.RomanNumeral(atom, ks) + rn.editorial.formalDivision = [] if self.isSame(rn, lastChord) and lastChordIsInSameMeasure: lastChord.duration.quarterLength += atomLength m.coreElementsChanged() @@ -696,6 +741,7 @@ def expand( rn = noteIter[0] lyricNum = len(rn.lyrics) + 1 rn.lyrics.append(note.Lyric(self.LHS, number=lyricNum)) + rn.editorial.formalDivision.append(self.LHS) break return measures From 6973483b1a3971afee93a4967ddc9ed8dd6e584e Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Sun, 7 May 2023 11:11:40 -0400 Subject: [PATCH 3/5] mypy --- music21/romanText/clercqTemperley.py | 52 ++++++++++++++++------------ 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/music21/romanText/clercqTemperley.py b/music21/romanText/clercqTemperley.py index 804c4a9b79..4d6cad2963 100644 --- a/music21/romanText/clercqTemperley.py +++ b/music21/romanText/clercqTemperley.py @@ -193,7 +193,7 @@ class CTSong(prebase.ProtoM21Object): >>> s.homeTimeSig - >>> s.homeKeySig + >>> s.homeKey >>> s.comments @@ -278,7 +278,7 @@ class CTSong(prebase.ProtoM21Object): >>> s.homeTimeSig - >>> s.homeKeySig + >>> s.homeKey >>> s.comments @@ -330,7 +330,7 @@ class CTSong(prebase.ProtoM21Object): """ - _DOC_ORDER = ['text', 'toPart', 'title', 'homeTimeSig', 'homeKeySig', 'comments', 'rules'] + _DOC_ORDER = ['text', 'toPart', 'title', 'homeTimeSig', 'homeKey', 'comments', 'rules'] _DOC_ATTR: dict[str, str] = { 'year': ''' The year of the CTSong; not formally defined @@ -344,16 +344,16 @@ def __init__(self, textFile: str | pathlib.Path = '', **keywords): self.lines: list[str] = [] # Dictionary of all component rules of the type CTRule self._rules: dict[str, CTRule] = OrderedDict() - # keeps a list of all key signatures in the Score -- avoids duplicates - self.ksList: list[key.KeySignature] = [] + # keeps a list of all keys in the Score -- avoids duplicates + self.ksList: list[key.Key] = [] # same for time signatures self.tsList: list[meter.TimeSignature] = [] - self._partObj = None + self._partObj = stream.Part() self.year = None self._homeTimeSig = None - self._homeKeySig = None + self._homeKey = None self.labelRomanNumerals = True self.labelSubsectionsOnScore = True @@ -528,31 +528,31 @@ def homeTimeSig(self): return self._homeTimeSig @property - def homeKeySig(self): + def homeKey(self): ''' - gets the initial, or 'home', key signature by looking at the music text and locating + gets the initial, or 'home', Key by looking at the music text and locating the key signature at the start of the S: rule. >>> s = romanText.clercqTemperley.CTSong(romanText.clercqTemperley.textString) - >>> s.homeKeySig + >>> s.homeKey ''' - # look at 'S' Rule and grab the home key Signature + # look at 'S' Rule and grab the home key if self.text and 'S:' in self.text: lines = self.text.split('\n') for line in lines: if line.startswith('S:'): for atom in line.split()[1:3]: if '[' not in atom: - self._homeKeySig = key.Key('C') - return self._homeKeySig + self._homeKey = key.Key('C') + return self._homeKey elif '/' not in atom: m21keyStr = key.convertKeyStringToMusic21KeyString(atom[1:-1]) - self._homeKeySig = key.Key(m21keyStr) - return self._homeKeySig + self._homeKey = key.Key(m21keyStr) + return self._homeKey else: pass - return self._homeKeySig + return self._homeKey def toPart(self, labelRomanNumerals=True, labelSubsectionsOnScore=True) -> stream.Part: # noinspection PyShadowingNames @@ -604,7 +604,7 @@ class CTRule(prebase.ProtoM21Object): has a :meth:`~music21.romanText.clercqTemperley.CTRUle.streamFromCTSong` attribute, which is the stream from the entire score that the rule corresponds to. ''' - _DOC_ORDER = ['LHS', 'sectionName', 'musicText', 'homeTimeSig', 'homeKeySig', 'comments'] + _DOC_ORDER = ['LHS', 'sectionName', 'musicText', 'homeTimeSig', 'homeKey', 'comments'] _DOC_ATTR: dict[str, str] = { 'text': ''' The full text of the CTRule, including the LHS, chords, and comments.''', @@ -640,7 +640,7 @@ def _setParent(self, parent): def expand( self, ts: meter.TimeSignature | None = None, - ks: key.KeySignature = None + ks: key.Key | None = None ) -> list[stream.Measure]: ''' The meat of it all -- expand one rule completely and return a list of Measure objects. @@ -716,6 +716,8 @@ def expand( rn = roman.RomanNumeral(atom, ks) rn.editorial.formalDivision = [] if self.isSame(rn, lastChord) and lastChordIsInSameMeasure: + if t.TYPE_CHECKING: + assert lastChord is not None # isSame asserted this. lastChord.duration.quarterLength += atomLength m.coreElementsChanged() else: @@ -805,7 +807,7 @@ def _measureGroups(self) -> list[tuple[str, str, int]]: # second pass -- filter out expansions. for content, sep in measureGroups1: contentList = content.split() - contentOut = [] + contentOut: list[str] = [] for atom in contentList: if atom.startswith('$'): # $BP or $Vr*3, etc. @@ -889,10 +891,13 @@ def addOptionalTieAndLyrics( def insertKsTs(self, m: stream.Measure, ts: meter.TimeSignature, - ks: key.KeySignature) -> None: + ks: key.Key) -> None: ''' - Insert a new time signature or key signature into measure m, if it's + Insert a new time signature or Key into measure m, if it's not already in the stream somewhere. + + Note that the name "ks" is slightly misnamed. It requires a Key, + not KeySignature object. ''' if self.parent is None: m.timeSignature = ts @@ -969,8 +974,7 @@ def comment(self) -> str | None: ''' if '%' in self.text: return self.text[self.text.index('%') + 1:].strip() - else: - return None + return None def _getLHS(self) -> str: if self._LHS not in (None, ''): @@ -983,6 +987,8 @@ def _getLHS(self) -> str: self._LHS = LHS.strip() return self._LHS LHS = LHS + char + # no colon found -- will not happen; it's in self.text + return '' # pragma: no cover else: return '' From 6d83988895c0182e269dc2a043a60d73a8cdba2b Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Sun, 7 May 2023 11:13:22 -0400 Subject: [PATCH 4/5] test issue --- music21/converter/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/music21/converter/__init__.py b/music21/converter/__init__.py index a1e7bb0dcb..a787bb0c66 100644 --- a/music21/converter/__init__.py +++ b/music21/converter/__init__.py @@ -970,6 +970,7 @@ def getSubConverterFormats(self): ('abc', ) ('braille', ) ('capella', ) + ('clercqtemperley', ) ('cttxt', ) ('har', ) ('humdrum', ) From 83696c5960442ec83ec320268d83c36047f94957 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Sun, 7 May 2023 12:06:50 -0400 Subject: [PATCH 5/5] make sections more testable Rewrite nasty nested routine using object attributes --- music21/romanText/clercqTemperley.py | 272 +++++++++++++++++---------- 1 file changed, 172 insertions(+), 100 deletions(-) diff --git a/music21/romanText/clercqTemperley.py b/music21/romanText/clercqTemperley.py index 4d6cad2963..9a3598d543 100644 --- a/music21/romanText/clercqTemperley.py +++ b/music21/romanText/clercqTemperley.py @@ -186,7 +186,6 @@ class CTSong(prebase.ProtoM21Object): >>> secondRN.editorial.formalDivision [] - >>> s.title 'Brown-Eyed Girl' @@ -345,7 +344,7 @@ def __init__(self, textFile: str | pathlib.Path = '', **keywords): # Dictionary of all component rules of the type CTRule self._rules: dict[str, CTRule] = OrderedDict() # keeps a list of all keys in the Score -- avoids duplicates - self.ksList: list[key.Key] = [] + self.keyObjList: list[key.Key] = [] # same for time signatures self.tsList: list[meter.TimeSignature] = [] @@ -375,7 +374,28 @@ def parse(self, textFile: str | pathlib.Path): Called when a CTSong is created by passing a string or filename; in the second case, it opens the file and removes all blank lines, and adds in new line characters - returns pieceString that CTSong can parse. + returns pieceString that CTSong can call .expand() on. + + >>> exCT = romanText.clercqTemperley.exampleClercqTemperley + + This calls parse implicitly: + + >>> s = romanText.clercqTemperley.CTSong(exCT) + + >>> print(s.text) + % Brown-Eyed Girl + VP: I | IV | I | V | + In: $VP*2 + Vr: $VP*4 IV | V | I | vi | IV | V | I | V | % Second half could be called chorus + Ch: V | | $VP*2 I |*4 + Ch2: V | | $VP*3 % Fadeout + S: [G] $In $Vr $Vr $Ch $VP $Vr $Ch2 + + >>> s.lines[0] + '% Brown-Eyed Girl' + + >>> s.lines[-1] + 'S: [G] $In $Vr $Vr $Ch $VP $Vr $Ch2' ''' if isinstance(textFile, str) and '|' in textFile and 'S:' in textFile: lines = textFile.split('\n') @@ -482,6 +502,12 @@ def rules(self): text='Co: R |*4 I |*4'>) ('S', ) + + Rules S is where we begin: + + >>> s.rules['S'] + ''' if self._rules: return self._rules @@ -511,6 +537,9 @@ def homeTimeSig(self): >>> change.homeTimeSig.beatSequence ''' + if self._homeTimeSig: + return self._homeTimeSig + # look at 'S' Rule and grab the home time Signature if self.text and 'S:' in self.text: lines = self.text.split('\n') @@ -537,6 +566,9 @@ def homeKey(self): >>> s.homeKey ''' + if self._homeKey: + return self._homeKey + # look at 'S' Rule and grab the home key if self.text and 'S:' in self.text: lines = self.text.split('\n') @@ -570,10 +602,11 @@ def toPart(self, labelRomanNumerals=True, labelSubsectionsOnScore=True) -> strea ''' self.labelRomanNumerals = labelRomanNumerals self.labelSubsectionsOnScore = labelSubsectionsOnScore - if self._partObj is not None: + if self._partObj[stream.Measure].first(): return self._partObj partObj = stream.Part() - measures = self.rules['S'].expand() + startRule = self.rules['S'] + measures = startRule.expand() for i, m in enumerate(measures): m.number = i + 1 partObj.append(measures) @@ -603,6 +636,8 @@ class CTRule(prebase.ProtoM21Object): has been called on that object. The usefulness of each CTRule object is that each has a :meth:`~music21.romanText.clercqTemperley.CTRUle.streamFromCTSong` attribute, which is the stream from the entire score that the rule corresponds to. + + To parse, put the text into the ''' _DOC_ORDER = ['LHS', 'sectionName', 'musicText', 'homeTimeSig', 'homeKey', 'comments'] _DOC_ATTR: dict[str, str] = { @@ -613,14 +648,23 @@ class CTRule(prebase.ProtoM21Object): SPLITMEASURES = re.compile(r'(\|\*?\d*)') REPETITION = re.compile(r'\*(\d+)') - def __init__(self, text='', parent=None): + def __init__(self, text='', parent: CTSong | None = None): self._parent = None if parent is not None: self.parent = parent - self._musicText = None # just the text above without the rule string or comments - self._LHS = None # rule name string, such as "In" - self.text = text # FULL TEXT OF CTRULE (includes LHS, chords, and comments + self.ts = (self.parent.homeTimeSig if self.parent else None) or meter.TimeSignature('4/4') + self.keyObj = (self.parent.homeKey if self.parent else None) or key.Key('C') + + self.text = text # full text of CTRule input (includes LHS, chords, and comments) + self._musicText = '' # just the text above without the rule string or comments + self._LHS = '' # left hand side: rule name string, such as "In" + + self.measures: list[stream.Measure] = [] + self.lastRegularAtom: str = '' + self.lastChord: chord.Chord | None = None + self._lastChordIsInSameMeasure: bool = False + def _reprInternal(self): return f'text={self.text!r}' @@ -639,102 +683,35 @@ def _setParent(self, parent): def expand( self, - ts: meter.TimeSignature | None = None, - ks: key.Key | None = None + tsContext: meter.TimeSignature | None = None, + keyContext: key.Key | None = None, ) -> list[stream.Measure]: ''' The meat of it all -- expand one rule completely and return a list of Measure objects. + + Parses within the local time signature context and key context. ''' - if ts is None: - ts = meter.TimeSignature('4/4') - if ks is None: - ks = key.Key('C') - measures: list[stream.Measure] = [] + saveTs = self.ts + saveKey = self.keyObj + + if tsContext: + self.ts = tsContext + if keyContext: + self.keyObj = keyContext - lastRegularAtom: str = '' - lastChord: chord.Chord | None = None + self.measures.clear() for content, sep, numReps in self._measureGroups(): - lastChordIsInSameMeasure = False if sep == '$': - if content not in self.parent.rules: - raise CTRuleException(f'Cannot expand rule {content} in {self}') - rule = self.parent.rules[content] - for i in range(numReps): - returnedMeasures = rule.expand(ts, ks) - self.insertKsTs(returnedMeasures[0], ts, ks) - for returnedTs in [m.getElementsByClass(meter.TimeSignature) - for m in returnedMeasures]: - if returnedTs is not ts: - # the TS changed mid-rule; create a new one for return. - ts = copy.deepcopy(ts) - - measures.extend(returnedMeasures) + self.expandExpansionContent(content, numReps) elif sep == '|': - m = stream.Measure() - atoms = content.split() - # key/timeSig pass... - regularAtoms = [] - for atom in atoms: - if atom.startswith('['): - atomContent = atom[1:-1] - if atomContent == '0': - ts = meter.TimeSignature('4/4') - # irregular meter. Cannot fully represent; - # TODO: replace w/ senza misura when possible. - - elif '/' in atomContent: # only one key / ts per measure. - ts = meter.TimeSignature(atomContent) - else: - ks = key.Key(key.convertKeyStringToMusic21KeyString(atomContent)) - - elif atom == '.': - if not lastRegularAtom: - raise CTRuleException(f' . w/o previous atom: {self}') - regularAtoms.append(lastRegularAtom) - elif not atom: - pass - else: - regularAtoms.append(atom) - lastRegularAtom = atom - numAtoms = len(regularAtoms) - if numAtoms == 0: - continue # maybe just ts and ks setting - - self.insertKsTs(m, ts, ks) - - atomLength = common.opFrac(ts.barDuration.quarterLength / numAtoms) - for atom in regularAtoms: - if atom == 'R': - rest = note.Rest(quarterLength=atomLength) - rest.editorial.formalDivision = [] - lastChord = None - lastChordIsInSameMeasure = False - m.append(rest) - else: - atom = self.fixupChordAtom(atom) - rn = roman.RomanNumeral(atom, ks) - rn.editorial.formalDivision = [] - if self.isSame(rn, lastChord) and lastChordIsInSameMeasure: - if t.TYPE_CHECKING: - assert lastChord is not None # isSame asserted this. - lastChord.duration.quarterLength += atomLength - m.coreElementsChanged() - else: - rn.duration.quarterLength = atomLength - self.addOptionalTieAndLyrics(rn, lastChord) - lastChord = rn - lastChordIsInSameMeasure = True - m.append(rn) - measures.append(m) - for i in range(1, numReps): - measures.append(copy.deepcopy(m)) + self.expandSimpleContent(content, numReps) else: environLocal.warn( f'Rule found without | or $, ignoring: {content!r},{sep!r}: in {self.text!r}') # pass - if measures: - for m in measures: + if self.measures: + for m in self.measures: noteIter = m.recurse().notes if (noteIter and (self.parent is None @@ -746,7 +723,102 @@ def expand( rn.editorial.formalDivision.append(self.LHS) break - return measures + self.ts = saveTs + self.keyObj = saveKey + + return self.measures + + def expandExpansionContent( + self, + content: str, + numReps: int, + ) -> None: + ''' + Expand a rule that contains an expansion (i.e., a $) in it. + + Requires CTSong parent to be set. + ''' + if not self.parent or content not in self.parent.rules: + raise CTRuleException(f'Cannot expand rule {content} in {self}') + rule = self.parent.rules[content] + for i in range(numReps): + returnedMeasures = rule.expand(self.ts, self.keyObj) + self.insertKsTs(returnedMeasures[0], self.ts, self.keyObj) + for returnedTs in [m.getElementsByClass(meter.TimeSignature) + for m in returnedMeasures]: + if returnedTs is not self.ts: + # the TS changed mid-rule; create a new one for return. + self.ts = copy.deepcopy(self.ts) + + self.measures.extend(returnedMeasures) + + def expandSimpleContent( + self, + content: str, + numReps: int, + ) -> None: + lastChordIsInSameMeasure = False + + m = stream.Measure() + atoms = content.split() + # key/timeSig pass... + regularAtoms: list[str] = [] + for atom in atoms: + if atom.startswith('['): + atomContent = atom[1:-1] + if atomContent == '0': + self.ts = meter.TimeSignature('4/4') + # irregular meter. Cannot fully represent; + # TODO: replace w/ senza misura when possible. + + elif '/' in atomContent: # only one key / ts per measure. + self.ts = meter.TimeSignature(atomContent) + else: + self.keyObj = key.Key(key.convertKeyStringToMusic21KeyString(atomContent)) + + elif atom == '.': + if not self.lastRegularAtom: + raise CTRuleException(f' . w/o previous atom: {self}') + regularAtoms.append(self.lastRegularAtom) + elif not atom: + pass + else: + regularAtoms.append(atom) + self.lastRegularAtom = atom + numAtoms = len(regularAtoms) + if numAtoms == 0: + return # maybe just ts and keyObj setting + + self.insertKsTs(m, self.ts, self.keyObj) + + atomLength = common.opFrac(self.ts.barDuration.quarterLength / numAtoms) + for atom in regularAtoms: + if atom == 'R': + rest = note.Rest(quarterLength=atomLength) + rest.editorial.formalDivision = [] + self.lastChord = None + lastChordIsInSameMeasure = False + m.append(rest) + else: + atom = self.fixupChordAtom(atom) + rn = roman.RomanNumeral(atom, self.keyObj) + rn.editorial.formalDivision = [] + if self.isSame(rn, self.lastChord) and lastChordIsInSameMeasure: + if t.TYPE_CHECKING: + assert self.lastChord is not None # isSame asserted this. + self.lastChord.duration.quarterLength += atomLength + m.coreElementsChanged() + else: + rn.duration.quarterLength = atomLength + self.addOptionalTieAndLyrics(rn, self.lastChord) + self.lastChord = rn + lastChordIsInSameMeasure = True + m.append(rn) + self.measures.append(m) + for i in range(1, numReps): + newM = copy.deepcopy(m) + newM.removeByClass([meter.TimeSignature, key.Key]) + self.measures.append(newM) def _measureGroups(self) -> list[tuple[str, str, int]]: ''' @@ -891,7 +963,7 @@ def addOptionalTieAndLyrics( def insertKsTs(self, m: stream.Measure, ts: meter.TimeSignature, - ks: key.Key) -> None: + keyObj: key.Key) -> None: ''' Insert a new time signature or Key into measure m, if it's not already in the stream somewhere. @@ -901,15 +973,15 @@ def insertKsTs(self, ''' if self.parent is None: m.timeSignature = ts - m.keySignature = ks + m.keySignature = keyObj return if ts not in self.parent.tsList: m.timeSignature = ts self.parent.tsList.append(ts) - if ks not in self.parent.ksList: - m.keySignature = ks - self.parent.tsList.append(ks) + if keyObj not in self.parent.keyObjList: + m.keySignature = keyObj + self.parent.keyObjList.append(keyObj) def fixupChordAtom(self, atom: str) -> str: ''' @@ -937,7 +1009,7 @@ def _setMusicText(self, value: str) -> None: self._musicText = str(value) def _getMusicText(self): - if self._musicText not in (None, ''): + if self._musicText: return self._musicText if not self.text: @@ -977,7 +1049,7 @@ def comment(self) -> str | None: return None def _getLHS(self) -> str: - if self._LHS not in (None, ''): + if self._LHS: return self._LHS LHS = ''