diff --git a/music21/abcFormat/__init__.py b/music21/abcFormat/__init__.py index aadba100e0..5b6a65265b 100644 --- a/music21/abcFormat/__init__.py +++ b/music21/abcFormat/__init__.py @@ -2627,7 +2627,7 @@ def __add__(self, other): return ah # -------------------------------------------------------------------------- - # utility methods for post processing + # utility methods for post-processing def definesReferenceNumbers(self): ''' diff --git a/music21/bar.py b/music21/bar.py index ff3103317f..6d9345ead6 100644 --- a/music21/bar.py +++ b/music21/bar.py @@ -49,6 +49,8 @@ class BarException(exceptions21.Music21Exception): 'final': 'light-heavy', } +strongBarlineTypes = {'heavy', 'double', 'final', 'heavy-light', 'heavy-heavy'} # set + def typeToMusicXMLBarStyle(value): ''' diff --git a/music21/figuredBass/checker.py b/music21/figuredBass/checker.py index bf9be20733..f8482070b1 100644 --- a/music21/figuredBass/checker.py +++ b/music21/figuredBass/checker.py @@ -202,7 +202,7 @@ def correlateHarmonies(currentMapping, music21Part): return newMapping # ------------------------------------------------------------------------------ -# Generic methods for checking for composition rule violations in streams +# Generic functions for checking for composition rule violations in streams def checkSinglePossibilities(music21Stream, functionToApply, color="#FF0000", debug=False): diff --git a/music21/figuredBass/examples.py b/music21/figuredBass/examples.py index 3302b9cd48..0f73c0dc55 100644 --- a/music21/figuredBass/examples.py +++ b/music21/figuredBass/examples.py @@ -400,7 +400,7 @@ def twelveBarBlues(): return realizer.figuredBassFromStream(s) # ----------------------------------------------------------------- -# METHODS FOR GENERATION OF BLUES VAMPS +# Functions that generate Boogie/Blues vamps. def generateBoogieVamp(blRealization=None, numRepeats=5): diff --git a/music21/figuredBass/possibility.py b/music21/figuredBass/possibility.py index 461c562707..fbd0714917 100644 --- a/music21/figuredBass/possibility.py +++ b/music21/figuredBass/possibility.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # ------------------------------------------------------------------------------ # Name: possibility.py -# Purpose: rule checking methods for a possibility represented as a tuple. +# Purpose: rule checking functions for a "possibility" represented as a tuple. # Authors: Jose Cabal-Ugaz # # Copyright: Copyright © 2011 Michael Scott Asato Cuthbert and the music21 Project diff --git a/music21/graph/__init__.py b/music21/graph/__init__.py index e1ea21ac0c..f81f355f80 100644 --- a/music21/graph/__init__.py +++ b/music21/graph/__init__.py @@ -7,18 +7,37 @@ # Michael Scott Asato Cuthbert # Evan Lynch # -# Copyright: Copyright © 2009-2012, 2017 Michael Scott Asato Cuthbert and the music21 Project +# Copyright: Copyright © 2009-2022 Michael Scott Asato Cuthbert +# and the music21 Project # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' -Object definitions for graphing and plotting :class:`~music21.stream.Stream` objects. +Tools for graphing, plotting, or otherwise visualizing :class:`~music21.stream.Stream` objects. -The :class:`~music21.graph.primitives.Graph` object subclasses primitive, abstract fundamental -graphing archetypes using the matplotlib library. The :class:`~music21.graph.plot.PlotStream` -object subclasses provide reusable approaches to graphing data and structures in -:class:`~music21.stream.Stream` objects. +The easiest and most common way of using plotting functions is to call +`.plot('typeOfGraph')` on a Stream. See :meth:`~music21.stream.Stream.plot`. +That method uses tools from the `music21.graph.findPlot` module to map between +names of plots and classes that can show them. + +The :class:`~music21.graph.plot.PlotStream` +subclasses in the `music21.graph.plot` module give easy to use +and configurable ways of graphing data and structures in +:class:`~music21.stream.Stream` objects. These Plot objects use classes from +the `music21.graph.axis` module to automatically extract relevant data for you. + +At a lower level, the :class:`~music21.graph.primitives.Graph` subclasses +in the `music21.graph.primitives` modules give abstract fundamental +graphing archetypes using the matplotlib library. They are to be used when +you already have data extracted on your own but still want to take advantage +of musically-aware axes and scaling. + +From highest level to lowest level usage, ways of graphing are as follows: + + 1. `streamObj.plot('graphName')` + 2. `graph.plot.Class(streamObj).run()` + 3. `plotter = graph.primitives.Class(); plotter.data = ...; plotter.process()` + 4. Use `matplotlib` directly to create any graph, musical or non-musical. -The most common way of using plotting functions is to call `.plot()` on a Stream. ''' __all__ = [ 'axis', 'findPlot', 'plot', 'primitives', 'utilities', diff --git a/music21/graph/axis.py b/music21/graph/axis.py index 233d8bc77f..fbcba25c0d 100644 --- a/music21/graph/axis.py +++ b/music21/graph/axis.py @@ -7,7 +7,8 @@ # Michael Scott Asato Cuthbert # Evan Lynch # -# Copyright: Copyright © 2009-2012, 2017 Michael Scott Asato Cuthbert and the music21 Project +# Copyright: Copyright © 2009-2022 Michael Scott Asato Cuthbert +# and the music21 Project # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -21,6 +22,7 @@ from music21.graph.utilities import accidentalLabelToUnicode, GraphException +from music21 import bar from music21 import common from music21 import duration from music21 import dynamics @@ -32,6 +34,9 @@ from music21.analysis import pitchAnalysis +USE_GRACE_NOTE_SPACING = -1 + + class Axis(prebase.ProtoM21Object): ''' An Axis is an easier way of specifying what to plot on any given axis. @@ -181,6 +186,7 @@ def extractOneElement(self, n, formatDict): return 1 def setBoundariesFromData(self, values): + # noinspection PyShadowingNames ''' If self.minValue is not set, then set self.minValue to be the minimum of these values. @@ -313,6 +319,7 @@ def __init__(self, client=None, axisName='x'): @staticmethod def makePitchLabelsUnicode(ticks): + # noinspection PyShadowingNames ''' Given a list of ticks, replace all labels with alternative/unicode symbols where necessary. @@ -364,18 +371,19 @@ def weightedSortHelper(x): ''' ensure that higher weighed weights come first, but then alphabetical by name, except that G comes before - A... since we are only comparing enharmonics... + A. That's the only "out of order" item we need to be + concerned with since we are only comparing enharmonics. ''' - weight, name = x - if name.startswith('A'): - name = 'H' + name[1:] - return (-1 * weight, name) + weight, sort_name = x + if sort_name.startswith('A'): + sort_name = 'H' + sort_name[1:] + return (-1 * weight, sort_name) def unweightedSortHelper(x): - weight, name = x - if name.startswith('A'): - name = 'H' + name[1:] - return (weight, name) + weight, sort_name = x + if sort_name.startswith('A'): + sort_name = 'H' + sort_name[1:] + return (weight, sort_name) for i in range(int(self.minValue), int(self.maxValue) + 1): p = pitch.Pitch() @@ -763,6 +771,13 @@ class OffsetAxis(PositionAxis): If True then only the first and last values will be used to create ticks for measures. Default False. ''', + 'minValue': 'The lowest starting position (as an offset). Will be set automatically.', + 'maxValue': 'The highest ending position (as an offset). Will be set automatically.', + 'mostMeasureTicksToShow': ''' + When plotting measures, will limit the number of ticks given to at most + this number. Note that since all double/final/heavy bars are show, this number + may be exceeded if there are more that this number of double bars. Default: 20. + ''', } labelDefault = 'Offset' @@ -773,6 +788,7 @@ def __init__(self, client=None, axisName='x'): self.useMeasures = None # self.displayMeasureNumberZero = False # not used... self.offsetStepSize = 10 + self.mostMeasureTicksToShow = 20 self.minMaxMeasureOnly = False def extractOneElement(self, n, formatDict): @@ -821,34 +837,69 @@ def ticks(self): ''' Get offset or measure ticks - >>> s = corpus.parse('bach/bwv281.xml') - >>> plotS = graph.plot.PlotStream(s) + >>> bach = corpus.parse('bach/bwv281.xml') + >>> plotS = graph.plot.PlotStream(bach) >>> ax = graph.axis.OffsetAxis(plotS) >>> ax.setBoundariesFromData() >>> ax.ticks() # on whole score, showing anacrusis spacing [(0.0, '0'), (1.0, '1'), (5.0, '2'), (9.0, '3'), (13.0, '4'), (17.0, '5'), (21.0, '6'), (25.0, '7'), (29.0, '8')] - >>> a = graph.plot.PlotStream(s.parts.first().flatten()) # on a Part - >>> plotS = graph.plot.PlotStream(s) - >>> ax = graph.axis.OffsetAxis(plotS) + We can reduce the number of ticks shown: + + >>> ax.mostMeasureTicksToShow = 4 + >>> ax.ticks() + [(0.0, '0'), (9.0, '3'), (21.0, '6'), (29.0, '8')] + + + We can also plot on a part: + + >>> soprano = bach.parts.first() + >>> plotSoprano = graph.plot.PlotStream(soprano) + >>> ax = graph.axis.OffsetAxis(plotSoprano) >>> ax.setBoundariesFromData() >>> ax.ticks() # on whole score, showing anacrusis spacing [(0.0, '0'), (1.0, '1'), (5.0, '2'), (9.0, '3'), (13.0, '4'), (17.0, '5'), (21.0, '6'), (25.0, '7'), (29.0, '8')] + Now we will show just the first and last measure: + >>> ax.minMaxMeasureOnly = True - >>> ax.ticks() # on whole score, showing anacrusis spacing + >>> ax.ticks() [(0.0, '0'), (29.0, '8')] + Only show ticks between minValue and maxValue (in offsets): + >>> ax.minMaxMeasureOnly = False >>> ax.minValue = 8 >>> ax.maxValue = 12 >>> ax.ticks() [(9.0, '3')] - >>> n = note.Note('a') # on a raw collection of notes with no measures + + Double bars and other heavy bars always show up. + (Let's get a new axis object to see.) + + >>> ax = graph.axis.OffsetAxis(plotSoprano) + >>> ax.setBoundariesFromData() + >>> ax.mostMeasureTicksToShow = 4 + >>> ax.ticks() + [(0.0, '0'), (9.0, '3'), (21.0, '6'), (29.0, '8')] + >>> m5 = soprano.getElementsByClass('Measure')[5] + >>> m5.number + 5 + >>> m5.rightBarline = bar.Barline('double') + >>> ax.ticks() + [(0.0, '0'), (13.0, '4'), (17.0, '5'), (29.0, '8')] + + Future improvements might make the spacing around the double bars + a bit better. It'd be nice to see measure 2 or 3 ticked rather + than measure 4. + + On a raw collection of notes with no measures, offsets are used: + + >>> n = note.Note('a') >>> s = stream.Stream() >>> s.repeatAppend(n, 20) >>> plotS = graph.plot.PlotStream(s) @@ -856,6 +907,10 @@ def ticks(self): >>> ax.setBoundariesFromData() >>> ax.ticks() [(0, '0'), (10, '10'), (20, '20')] + + The space between offsets is configured by `.offsetStepSize`. At + present mostMeasureTicksToShow to does affect streams without measures. + >>> ax.offsetStepSize = 5 >>> ax.ticks() [(0, '0'), (5, '5'), (10, '10'), (15, '15'), (20, '20')] @@ -902,23 +957,47 @@ def _measureTicks(self, dataMin, dataMax, offsetMap): offset = mNoToUse[i] mNumber = offsetMap[offset][0].number tickTuple = (offset, str(mNumber)) - ticks.append(tickTuple) - else: # get all of them - if len(mNoToUse) > 20: - # get about 10 ticks - mNoStepSize = int(len(mNoToUse) / 10) - else: - mNoStepSize = 1 - # for i in range(0, len(mNoToUse), mNoStepSize): - i = 0 # always start with first - while i < len(mNoToUse): - offset = mNoToUse[i] + if tickTuple not in ticks: + ticks.append(tickTuple) + else: + tickIndexesUsed = set() + + # noinspection PyShadowingNames + def add_tick_tuple(index_in_mNoToUse): + if index_in_mNoToUse in tickIndexesUsed: + return + offset = mNoToUse[index_in_mNoToUse] # this should be a measure object foundMeasure = offsetMap[offset][0] mNumber = foundMeasure.number tickTuple = (offset, str(mNumber)) ticks.append(tickTuple) + tickIndexesUsed.add(index_in_mNoToUse) + + # always add first + add_tick_tuple(0) + # always add last + add_tick_tuple(len(mNoToUse) - 1) # do not use -1, since it is a different key. + + # add all double bars -- might exceed mostMeasureTicksToShow + for i in range(1, len(mNoToUse) - 1): + mapOffset = mNoToUse[i] + mapMeasure = offsetMap[mapOffset][0] + if (mapMeasure.rightBarline is not None + and mapMeasure.rightBarline.type in bar.strongBarlineTypes): + add_tick_tuple(i) + + # default get 10-19 ticks for long scores, or every measure for short scores + maxMoreTicksToAdd = min(self.mostMeasureTicksToShow - len(tickIndexesUsed) + 1, + len(mNoToUse)) + mNoStepSize = max(len(mNoToUse) // maxMoreTicksToAdd, 1) + i = mNoStepSize + while i < len(mNoToUse) - 1: + add_tick_tuple(i) i += mNoStepSize + + ticks.sort() + return ticks def getOffsetMap(self): @@ -1060,6 +1139,7 @@ def dataFromQL(self, ql): return x def ticks(self): + # noinspection PyShadowingNames ''' Get ticks for quarterLength. @@ -1070,7 +1150,6 @@ def ticks(self): Note that mix and max do nothing, but must be included in order to set the tick style. - >>> s = stream.Stream() >>> for t in ['32nd', '16th', 'eighth', 'quarter', 'half']: ... n = note.Note() @@ -1178,25 +1257,27 @@ class OffsetEndAxis(OffsetAxis): _DOC_ATTR = { 'noteSpacing': ''' amount in QL to leave blank between untied notes. - (default = graceNoteQL) + (default = self.graceNoteQL) ''' } quantities = ('offsetEnd', 'timespans', 'timespan') - def __init__(self, client=None, axisName='x'): + def __init__(self, client=None, axisName='x', noteSpacing=USE_GRACE_NOTE_SPACING): super().__init__(client, axisName) - self.noteSpacing = self.graceNoteQL + self.noteSpacing = noteSpacing + if noteSpacing == USE_GRACE_NOTE_SPACING: + self.noteSpacing = self.graceNoteQL def extractOneElement(self, n, formatDict): off = float(n.getOffsetInHierarchy(self.stream)) useQL = float(n.duration.quarterLength) - if useQL < self.graceNoteQL: - useQL = self.graceNoteQL - elif useQL > self.graceNoteQL * 2: + if useQL < self.noteSpacing: + useQL = self.noteSpacing + elif useQL > self.noteSpacing * 2: if hasattr(n, 'tie') and n.tie is not None and n.tie.type in ('start', 'continue'): pass else: - useQL -= self.graceNoteQL + useQL -= self.noteSpacing return (off, useQL) diff --git a/music21/graph/findPlot.py b/music21/graph/findPlot.py index 2c1dae59f9..32c3a28a46 100644 --- a/music21/graph/findPlot.py +++ b/music21/graph/findPlot.py @@ -1,16 +1,17 @@ # -*- coding: utf-8 -*- # ------------------------------------------------------------------------------ # Name: graph/findPlot.py -# Purpose: Methods for finding appropriate plots for plotStream. +# Purpose: Functions that find appropriate plots for graph.plot # # Authors: Michael Scott Asato Cuthbert # Christopher Ariza # -# Copyright: Copyright © 2017 Michael Scott Asato Cuthbert and the music21 Project +# Copyright: Copyright © 2017-22 Michael Scott Asato Cuthbert +# and the music21 Project # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' -Methods for finding appropriate plots for plotStream. +Functions that find appropriate plots for graph.plot. ''' import collections import types @@ -64,6 +65,7 @@ def getPlotClasses(): # noinspection PyTypeChecker if (callable(name) and not isinstance(name, types.FunctionType) + and hasattr(name, '__mro__') and plot.PlotStreamMixin in name.__mro__ and primitives.Graph in name.__mro__): allPlot.append(name) @@ -279,7 +281,7 @@ def getPlotsToMake(graphFormat=None, [, ] - Just one value but it is in the wrong axis... + Just one value, but it is in the wrong axis... >>> graph.findPlot.getPlotsToMake('scatter', 'pitchClass') [, diff --git a/music21/graph/plot.py b/music21/graph/plot.py index 44454bf259..feee666144 100644 --- a/music21/graph/plot.py +++ b/music21/graph/plot.py @@ -7,7 +7,8 @@ # Michael Scott Asato Cuthbert # Evan Lynch # -# Copyright: Copyright © 2009-2012, 2017 Michael Scott Asato Cuthbert and the music21 Project +# Copyright: Copyright © 2009-2022 Michael Scott Asato Cuthbert, +# and the music21 Project # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' @@ -22,7 +23,10 @@ import os import pathlib import unittest +import numbers +from typing import Dict, List, Any +from music21 import base # from music21 import common from music21 import chord from music21 import common @@ -213,7 +217,7 @@ def extractData(self): for i, thisAxis in enumerate(self.allAxes): thisAxis.setBoundariesFromData([d[i] for d in self.data]) - def processOneElement(self, el): + def processOneElement(self, el: base.Music21Object): ''' Get a list of data from a single element (generally a Note or chord): @@ -228,7 +232,6 @@ def processOneElement(self, el): >>> s.insert(5.0, c) >>> pl.processOneElement(c) [(5.0, 2, {}), (5.0, 4, {})] - ''' elementValues = [[] for _ in range(len(self.allAxes))] formatDict = {} @@ -239,7 +242,7 @@ def processOneElement(self, el): axisValue = thisAxis.extractOneElement(el, formatDict) # use isinstance(List) not isiterable, since # extractOneElement can distinguish between a tuple which - # represents a single value, or a list of values (or tuples) + # represents a single value, or a list of values (or list of tuples) # which represent multiple values if not isinstance(axisValue, list) and axisValue is not None: axisValue = [axisValue] @@ -257,7 +260,15 @@ def processOneElement(self, el): returnList = list(zip(*elementValues)) return returnList - def postProcessElement(self, el, formatDict, *values): + def postProcessElement(self, + el: base.Music21Object, + formatDict: Dict[Any, Any], + *values: List[numbers.Real]) -> None: + ''' + Any processing that needs to take place for each element, independent + of what the axis is finding can go here. For chords, a single + formatDict applies to all pitches/notes in the chord. + ''' pass def postProcessData(self): @@ -290,7 +301,7 @@ def extractChordDataOneAxis(ax, c, formatDict): if not values: # still not set, get form chord for n in c: - # try to get get values from note inside chords + # try to get values from note inside chords value = None try: value = ax.extractOneElement(n, formatDict) @@ -891,7 +902,7 @@ def extractData(self): environLocal.printDebug(['tickRange', tickRange]) # environLocal.printDebug(['last start color', colorMatrix[-1][0]]) - # get dictionaries of meta data for each row + # get dictionaries of metadata for each row pos = 0 yTicks = [] @@ -987,16 +998,65 @@ class WindowedAmbitus(WindowedAnalysis): class HorizontalBar(primitives.GraphHorizontalBar, PlotStreamMixin): ''' - A graph of events, sorted by pitch, over time + A graph of events, sorted by pitch, over time. + + If colorByPart is True, then each part will get its own color from + `self.colors` (unless there are more parts than colors). ''' axesClasses = {'x': axis.OffsetEndAxis, 'y': axis.PitchSpaceAxis} - def __init__(self, streamObj=None, *args, **keywords): + def __init__(self, streamObj=None, *args, colorByPart=False, **keywords): + self.colorByPart = colorByPart + self._partsToColor: Dict[stream.Part, str] = {} + primitives.GraphHorizontalBar.__init__(self, *args, **keywords) PlotStreamMixin.__init__(self, streamObj, **keywords) self.axisY.hideUnused = False + def run(self): + ''' + Optionally assign colors to Part objects and then do the normal run. + ''' + if self.colorByPart: + self.assignColorsToParts() + super().run() + + def assignColorsToParts(self) -> Dict[stream.Part, str]: + ''' + Give a different color for each part, if self.colorByPart is True. + + Returns the assignment for any further manipulation. + + Currently, two piano hands (PartStaff objects) get different colors. + + >>> bach = corpus.parse('bwv66.6') + >>> plot = graph.plot.HorizontalBar(bach, colorByPart=True) + >>> plot.assignColorsToParts() + {: '#605c7f', + : '#5c7f60', + : '#988969', + : '#628297'} + ''' + s = self.streamObj + numColors = len(self.colors) + for i, p in enumerate(s[stream.Part]): + self._partsToColor[p] = self.colors[i % numColors] + return self._partsToColor + + + def postProcessElement(self, + el: base.Music21Object, + formatDict: Dict[Any, Any], + *values: List[numbers.Real]): + ''' + Assign colors to each element if colorByPart is True. + ''' + super().postProcessElement(el, formatDict, *values) + if self.colorByPart: + formatDict['color'] = self._partsToColor.get(el.getContextByClass(stream.Part), + self.colors[0]) + def postProcessData(self): ''' Call any post data processing routines here and on any axes. @@ -1014,13 +1074,17 @@ def postProcessData(self): pitchSpanDict[pitchData] = [] dictOfFormatDicts[pitchData] = {} - pitchSpanDict[pitchData].append(positionData) + positionDataWithFormatDict = (*positionData, formatDict) + pitchSpanDict[pitchData].append(positionDataWithFormatDict) _mergeDicts(dictOfFormatDicts[pitchData], formatDict) for unused_k, v in pitchSpanDict.items(): - v.sort() # sort these tuples. + # sort these tuples, ignoring unhashable dict. + v.sort(key=lambda point: (point[0], point[1])) for numericValue, label in yTicks: + # make sure there is an entry for each yTick, regardless + # of whether we have any data for it or not. if numericValue in pitchSpanDict: newData.append([label, pitchSpanDict[numericValue], @@ -1031,8 +1095,8 @@ def postProcessData(self): class HorizontalBarPitchClassOffset(HorizontalBar): - '''A graph of events, sorted by pitch class, over time - + ''' + A graph of events, sorted by pitch class, over time. >>> s = corpus.parse('bach/bwv324.xml') #_DOCS_HIDE >>> p = graph.plot.HorizontalBarPitchClassOffset(s, doneAction=None) #_DOCS_HIDE @@ -1046,8 +1110,8 @@ class HorizontalBarPitchClassOffset(HorizontalBar): ''' axesClasses = _mergeDicts(HorizontalBar.axesClasses, {'y': axis.PitchClassAxis}) - def __init__(self, streamObj=None, *args, **keywords): - super().__init__(streamObj, *args, **keywords) + def __init__(self, streamObj=None, *args, colorByPart=False, **keywords): + super().__init__(streamObj, *args, colorByPart=colorByPart, **keywords) self.axisY = axis.PitchClassAxis(self, 'y') self.axisY.hideUnused = False @@ -1059,8 +1123,9 @@ def __init__(self, streamObj=None, *args, **keywords): class HorizontalBarPitchSpaceOffset(HorizontalBar): - '''A graph of events, sorted by pitch space, over time - + ''' + A graph of events, sorted by pitch space, over time, generally called + a "piano roll". >>> s = corpus.parse('bach/bwv324.xml') #_DOCS_HIDE >>> p = graph.plot.HorizontalBarPitchSpaceOffset(s, doneAction=None) #_DOCS_HIDE @@ -1072,8 +1137,8 @@ class HorizontalBarPitchSpaceOffset(HorizontalBar): :width: 600 ''' - def __init__(self, streamObj=None, *args, **keywords): - super().__init__(streamObj, *args, **keywords) + def __init__(self, streamObj=None, *args, colorByPart=False, **keywords): + super().__init__(streamObj, *args, colorByPart=colorByPart, **keywords) if 'figureSize' not in keywords: self.figureSize = (10, 6) @@ -1085,15 +1150,18 @@ def __init__(self, streamObj=None, *args, **keywords): class HorizontalBarWeighted(primitives.GraphHorizontalBarWeighted, PlotStreamMixin): ''' A base class for plots of Scores with weighted (by height) horizontal bars. - Many different weighted segments can provide a - representation of a dynamic parameter of a Part. + Many weighted segments represent a dynamic parameter of a Part. ''' axesClasses = { 'x': axis.OffsetAxis, 'y': None } keywordConfigurables = primitives.GraphHorizontalBarWeighted.keywordConfigurables + ( - 'fillByMeasure', 'segmentByTarget', 'normalizeByPart', 'partGroups') + 'fillByMeasure', + 'normalizeByPart', + 'partGroups', + 'segmentByTarget', + ) def __init__(self, streamObj=None, *args, **keywords): self.fillByMeasure = False @@ -1848,18 +1916,18 @@ def testChordsB(self): b = HorizontalBarPitchClassOffset(s, doneAction=None) b.run() - match = [['C', [(0.0, 0.9375), (1.5, 1.4375)], {}], + match = [['C', [(0.0, 0.9375, {}), (1.5, 1.4375, {})], {}], ['', [], {}], - ['D', [(1.5, 1.4375)], {}], + ['D', [(1.5, 1.4375, {})], {}], ['', [], {}], - ['E', [(1.0, 0.4375), (1.5, 1.4375)], {}], - ['F', [(1.0, 0.4375)], {}], + ['E', [(1.0, 0.4375, {}), (1.5, 1.4375, {})], {}], + ['F', [(1.0, 0.4375, {})], {}], ['', [], {}], - ['G', [(1.0, 0.4375)], {}], + ['G', [(1.0, 0.4375, {})], {}], ['', [], {}], - ['A', [(1.0, 0.4375)], {}], + ['A', [(1.0, 0.4375, {})], {}], ['', [], {}], - ['B', [(1.5, 1.4375)], {}]] + ['B', [(1.5, 1.4375, {})], {}]] self.assertEqual(b.data, match) # b.write() @@ -1871,23 +1939,23 @@ def testChordsB(self): b = HorizontalBarPitchSpaceOffset(s, doneAction=None) b.run() - match = [['C3', [(0.0, 0.9375)], {}], + match = [['C3', [(0.0, 0.9375, {})], {}], ['', [], {}], ['', [], {}], ['', [], {}], - ['E', [(1.0, 0.4375)], {}], - ['F', [(1.0, 0.4375)], {}], + ['E', [(1.0, 0.4375, {})], {}], + ['F', [(1.0, 0.4375, {})], {}], ['', [], {}], - ['G', [(1.0, 0.4375)], {}], + ['G', [(1.0, 0.4375, {})], {}], ['', [], {}], - ['A', [(1.0, 0.4375)], {}], + ['A', [(1.0, 0.4375, {})], {}], ['', [], {}], - ['B', [(1.5, 1.4375)], {}], - ['C4', [(1.5, 1.4375)], {}], + ['B', [(1.5, 1.4375, {})], {}], + ['C4', [(1.5, 1.4375, {})], {}], ['', [], {}], - ['D', [(1.5, 1.4375)], {}], + ['D', [(1.5, 1.4375, {})], {}], ['', [], {}], - ['E', [(1.5, 1.4375)], {}]] + ['E', [(1.5, 1.4375, {})], {}]] self.assertEqual(b.data, match) # b.write() @@ -2024,4 +2092,4 @@ def testDolanA(self): if __name__ == '__main__': import music21 - music21.mainTest(TestExternalManual) # , runTest='test3DPitchSpaceQuarterLengthCount') + music21.mainTest(Test) # , TestExternalManual) diff --git a/music21/graph/primitives.py b/music21/graph/primitives.py index 4ff037c666..c4a25e8a63 100644 --- a/music21/graph/primitives.py +++ b/music21/graph/primitives.py @@ -7,18 +7,27 @@ # Michael Scott Asato Cuthbert # Evan Lynch # -# Copyright: Copyright © 2009-2012, 2017 Michael Scott Asato Cuthbert and the music21 Project +# Copyright: Copyright © 2009-2022 Michael Scott Asato Cuthbert, +# and the music21 Project # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' Object definitions for graphing and plotting :class:`~music21.stream.Stream` objects. -The :class:`~music21.graph.primitives.Graph` object subclasses primitive, abstract fundamental -graphing archetypes using the matplotlib library. +The :class:`~music21.graph.primitives.Graph` object subclasses primitive, +abstract fundamental graphing archetypes using the matplotlib library. + +From highest level to lowest level usage, ways of graphing are as follows: + + 1. streamObj.plot('graphName') + 2. graph.plot.Class(streamObj).run() + 3. plotter = graph.primitives.Class(); plotter.data = ...; plotter.process() + 4. Use matplotlib directly to create your graph. ''' import math import random import unittest +from typing import Union, List from music21 import common from music21.graph.utilities import (getExtendedModules, @@ -94,13 +103,27 @@ class Graph(prebase.ProtoM21Object): figureSizeDefault = (6, 6) keywordConfigurables = ( - 'alpha', 'dpi', 'colorBackgroundData', 'colorBackgroundFigure', - 'colorGrid', 'title', 'figureSize', 'marker', 'markersize', - 'colors', 'tickFontSize', 'tickColors', 'titleFontSize', 'labelFontSize', - 'fontFamily', 'hideXGrid', 'hideYGrid', - 'xTickLabelRotation', - 'xTickLabelHorizontalAlignment', 'xTickLabelVerticalAlignment', + 'alpha', + 'colorBackgroundData', + 'colorBackgroundFigure', + 'colorGrid', + 'colors', 'doneAction', + 'dpi', + 'figureSize', + 'fontFamily', + 'hideXGrid', + 'hideYGrid', + 'labelFontSize', + 'marker', + 'markersize', + 'tickColors', + 'tickFontSize', + 'title', + 'titleFontSize', + 'xTickLabelHorizontalAlignment', + 'xTickLabelRotation', + 'xTickLabelVerticalAlignment', ) def __init__(self, *args, **keywords): @@ -132,7 +155,15 @@ def __init__(self, *args, **keywords): self.figureSize = self.figureSizeDefault self.marker = 'o' self.markersize = 6 # lowercase as in matplotlib - self.colors = ['#605c7f', '#5c7f60', '#715c7f'] + + # all default colors are on the slate-side of colors. + self.colors = ['#605c7f', # purple + '#5c7f60', # green + '#988969', # khaki + '#628297', # cyan + '#ad776d', # pink, + '#80a364', # lime, + ] self.tickFontSize = 7 self.tickColors = {'x': '#000000', 'y': '#000000'} @@ -195,8 +226,7 @@ def nextColor(self): Utility function that cycles through the colors of self.colors... >>> g = graph.primitives.Graph() - >>> g.colors - ['#605c7f', '#5c7f60', '#715c7f'] + >>> g.colors = ['#605c7f', '#5c7f60', '#715c7f'] >>> g.nextColor() '#605c7f' @@ -500,8 +530,8 @@ def process(self): self.hideAxisSpines(self.subplot, leftBottom=self.hideLeftBottomSpines) self.applyFormatting(self.subplot) self.callDoneAction() -# if self.doneAction is None: -# extm.matplotlib.interactive(False) + # if self.doneAction is None: + # extm.matplotlib.interactive(False) def renderSubplot(self, subplot): ''' @@ -670,7 +700,7 @@ def __init__(self, *args, **kwargs): self.hideLeftBottomSpines = True super().__init__(*args, **kwargs) - def renderSubplot(self, subplot): # do not need grid for outer container + def renderSubplot(self, subplot): # do not need a grid for the outer container # these approaches do not work: # adjust face color of axTop independently @@ -892,6 +922,11 @@ class GraphHorizontalBar(Graph): Data provided is a list of pairs, where the first value becomes the key, the second value is a list of x-start, x-length values. + Note how the second element in each data point is the length, so + subtracting death year from birth year gives the appropriate length. + + Example: Plot the life-span of four composers whose lives were entertwined: + Chopin, Robert and Clara Schumann, and Brahms. >>> a = graph.primitives.GraphHorizontalBar() >>> a.doneAction = None #_DOCS_HIDE @@ -904,16 +939,35 @@ class GraphHorizontalBar(Graph): .. image:: images/GraphHorizontalBar.* :width: 600 + Data is a list of tuples in the form, where each entry represents a space on the + Y axis: + + * Label + * List of tuples of numeric data where each tuple has two or three elements: + * Start x-position + * Length of bar + * Optional: dictionary of format information about this point. + * Optional: dictionary of format informmation for all points at this level. + (this will be overridden by any information for the particular point) + + To make an equally spaced plot, like in a Pitch Space plot, leave empty data in the form: + + `('', [], {})` + ''' _DOC_ATTR = { 'barSpace': 'Amount of vertical space each bar takes; default 8', - 'margin': 'Space around the bars, default 2', + 'margin': ''' + Vertical space above and below the bars, default 2 (= total4 space between bars) + ''', } graphType = 'horizontalBar' figureSizeDefault = (10, 4) keywordConfigurables = Graph.keywordConfigurables + ( - 'barSpace', 'margin') + 'barSpace', + 'margin', + ) def __init__(self, *args, **keywords): self.barSpace = 8 @@ -942,22 +996,37 @@ def renderSubplot(self, subplot): for info in self.data: if len(info) == 2: key, points = info - unused_formatDict = {} + fullRowFormatDict = {} else: - key, points, unused_formatDict = info + key, points, fullRowFormatDict = info keys.append(key) # provide a list of start, end points; # then start y position, bar height - faceColor = self.nextColor() + faceColor = fullRowFormatDict.get('color', self.nextColor()) if points: + uniformFormatPerRow = (len(points[0]) == 2) + rowFaceColors: Union[str, List[str]] + if uniformFormatPerRow: + rowFaceColors = faceColor + positionPoints = points + else: + rowFaceColors = [p[2].get('color', faceColor) for p in points] + positionPoints = [p[:2] for p in points] + yRange = (yPos + self.margin, self.barHeight) - subplot.broken_barh(points, + + subplot.broken_barh(positionPoints, yRange, - facecolors=faceColor, + facecolors=rowFaceColors, alpha=self.alpha) - for xStart, xLen in points: + for p in points: + if len(p) >= 2: + xStart, xLen = p[:2] + else: + raise ValueError(f'Points must be length 2 or 3, not {len(p)}: {p}') + xEnd = xStart + xLen for x in [xStart, xEnd]: if x not in xPoints: @@ -1005,7 +1074,9 @@ class GraphHorizontalBarWeighted(Graph): figureSizeDefault = (10, 4) keywordConfigurables = Graph.keywordConfigurables + ( - 'barSpace', 'margin') + 'barSpace', + 'margin', + ) def __init__(self, *args, **keywords): self.barSpace = 8 @@ -1017,13 +1088,14 @@ def __init__(self, *args, **keywords): if 'alpha' not in keywords: self.alpha = 1 -# example data -# data = [ -# ('Violins', [(3, 5, 1, '#fff000'), (1, 12, 0.2, '#3ff203')] ), -# ('Celli', [(2, 7, 0.2, '#0ff302'), (10, 3, 0.6, '#ff0000', 1)] ), -# ('Clarinet', [(5, 1, 0.5, '#3ff203')] ), -# ('Flute', [(5, 1, 0.1, '#00ff00'), (7, 20, 0.3, '#00ff88')] ), -# ] + # example data + # data = [ + # ('Violins', [(3, 5, 1, '#fff000'), (1, 12, 0.2, '#3ff203')] ), + # ('Celli', [(2, 7, 0.2, '#0ff302'), (10, 3, 0.6, '#ff0000', 1)] ), + # ('Clarinet', [(5, 1, 0.5, '#3ff203')] ), + # ('Flute', [(5, 1, 0.1, '#00ff00'), (7, 20, 0.3, '#00ff88')] ), + # ] + @property def barHeight(self): return self.barSpace - (self.margin * 2) @@ -1086,7 +1158,7 @@ def renderSubplot(self, subplot): yRanges.append((adjustedY, h)) for i, xRange in enumerate(xRanges): - # note: can get ride of bounding lines by providing + # note: can get rid of bounding lines by providing # linewidth=0, however, this may leave gaps in adjacent regions subplot.broken_barh([xRange], yRanges[i], @@ -1111,17 +1183,17 @@ def renderSubplot(self, subplot): self.setAxisRange('x', (xMin, xMax), paddingFraction=0.01) self.setTicks('y', yTicks) - # first, see if ticks have been set externally -# if 'ticks' in self.axis['x'] and len(self.axis['x']['ticks']) == 0: -# rangeStep = int(xMin round(xRange/10)) -# if rangeStep == 0: -# rangeStep = 1 -# for x in range(int(math.floor(xMin)), -# round(math.ceil(xMax)), -# rangeStep): -# xTicks.append([x, '%s' % x]) -# self.setTicks('x', xTicks) -# environLocal.printDebug(['xTicks', xTicks]) + # # first, see if ticks have been set externally + # if 'ticks' in self.axis['x'] and len(self.axis['x']['ticks']) == 0: + # rangeStep = int(xMin round(xRange/10)) + # if rangeStep == 0: + # rangeStep = 1 + # for x in range(int(math.floor(xMin)), + # round(math.ceil(xMax)), + # rangeStep): + # xTicks.append([x, '%s' % x]) + # self.setTicks('x', xTicks) + # environLocal.printDebug(['xTicks', xTicks]) class GraphScatterWeighted(Graph): @@ -1237,7 +1309,7 @@ def renderSubplot(self, subplot): # # can do this here # environLocal.printDebug([e]) - # only show label if min if greater than zNorm min + # only show label if min is greater than zNorm min if zList[i] > 1: # xdistort does not seem to # width shift can be between 0.1 and 0.25 @@ -1260,7 +1332,8 @@ def renderSubplot(self, subplot): class GraphScatter(Graph): ''' - Graph two parameters in a scatter plot. Data representation is a list of points of values. + Graph two parameters in a scatter plot. + Data representation is a list of points of values. >>> g = graph.primitives.GraphScatter() >>> g.doneAction = None #_DOCS_HIDE @@ -1580,7 +1653,8 @@ def renderSubplot(self, subplot): class Test(unittest.TestCase): def testCopyAndDeepcopy(self): - '''Test copying all objects defined in this module + ''' + Test copying all objects defined in this module ''' import copy import sys diff --git a/music21/graph/utilities.py b/music21/graph/utilities.py index de419a16a3..abbbf9e0bb 100644 --- a/music21/graph/utilities.py +++ b/music21/graph/utilities.py @@ -1,19 +1,21 @@ # -*- coding: utf-8 -*- # ------------------------------------------------------------------------------ # Name: graph/utilities.py -# Purpose: Methods for finding external modules, manipulating colors, etc. +# Purpose: Functions for finding external modules, manipulating colors, etc. # # Authors: Christopher Ariza # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2009-2012, 2017 Michael Scott Asato Cuthbert and the music21 Project +# Copyright: Copyright © 2009-2022 Michael Scott Asato Cuthbert, +# and the music21 Project # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' -Methods for finding external modules, converting colors to Matplotlib colors, etc. +Functions for finding external modules, converting colors to Matplotlib colors, etc. ''' import unittest from collections import namedtuple +from typing import Tuple, cast import webcolors @@ -30,7 +32,7 @@ ExtendedModules = namedtuple('ExtendedModules', - 'matplotlib Axes3D collections patches plt networkx') + ['matplotlib', 'Axes3D', 'collections', 'patches', 'plt', 'networkx']) def getExtendedModules(): @@ -179,7 +181,7 @@ def getColor(color): if len(color) == 1: color = [color[0], color[0], color[0]] # convert to 0 100% values as strings with % symbol - colorStrList = [str(x * 100) + '%' for x in color] + colorStrList = cast(Tuple[str, str, str], tuple(str(x * 100) + '%' for x in color)) return webcolors.rgb_percent_to_hex(colorStrList) else: # assume integers return webcolors.rgb_to_hex(tuple(color)) diff --git a/music21/mei/base.py b/music21/mei/base.py index 594e6eaabb..adf471687f 100644 --- a/music21/mei/base.py +++ b/music21/mei/base.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # ----------------------------------------------------------------------------- # Name: mei/base.py -# Purpose: Public methods for the MEI module +# Purpose: Public interfaces for the MEI module # # Authors: Christopher Antila # @@ -9,7 +9,7 @@ # License: BSD, see license.txt # ----------------------------------------------------------------------------- ''' -These are the public methods for the MEI module by Christopher Antila +These are the public interfaces for the MEI module by Christopher Antila To convert a string with MEI markup into music21 objects, use :meth:`~music21.mei.MeiToM21Converter.convertFromString`. diff --git a/music21/meter/tests.py b/music21/meter/tests.py index f34caddb50..62106f2f77 100644 --- a/music21/meter/tests.py +++ b/music21/meter/tests.py @@ -6,7 +6,7 @@ # Authors: Christopher Ariza # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2009-2012, 2015, 2021 Michael Scott Asato Cuthbert +# Copyright: Copyright © 2009-2022 Michael Scott Asato Cuthbert # and the music21 Project # License: BSD, see license.txt # ----------------------------------------------------------------------------- diff --git a/music21/musicxml/xmlSoundParser.py b/music21/musicxml/xmlSoundParser.py index bcd7d07741..c28992c385 100644 --- a/music21/musicxml/xmlSoundParser.py +++ b/music21/musicxml/xmlSoundParser.py @@ -9,7 +9,7 @@ # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' -Methods for converting tag to the many music21 +Functions that convert tag to the many music21 objects that this tag might represent. Pulled out because xmlToM21 is getting way too big. diff --git a/music21/note.py b/music21/note.py index 6f7630fd2d..2de6009815 100644 --- a/music21/note.py +++ b/music21/note.py @@ -6,7 +6,8 @@ # Authors: Michael Scott Asato Cuthbert # Christopher Ariza # -# Copyright: Copyright © 2006-2019 Michael Scott Asato Cuthbert and the music21 Project +# Copyright: Copyright © 2006-2022 Michael Scott Asato Cuthbert +# and the music21 Project # License: BSD, see license.txt # ------------------------------------------------------------------------------ ''' diff --git a/music21/stream/iterator.py b/music21/stream/iterator.py index 5e309e9552..c88831c308 100644 --- a/music21/stream/iterator.py +++ b/music21/stream/iterator.py @@ -6,7 +6,8 @@ # Authors: Michael Scott Asato Cuthbert # Christopher Ariza # -# Copyright: Copyright © 2008-2016 Michael Scott Asato Cuthbert and the music21 Project +# Copyright: Copyright © 2008-2022 Michael Scott Asato Cuthbert +# and the music21 Project # License: BSD, see license.txt # ----------------------------------------------------------------------------- ''' @@ -1581,7 +1582,7 @@ def reset(self): # ----------------------------------------------------------------------------- -class RecursiveIterator(StreamIterator[M21ObjType], collections.abc.Sequence): +class RecursiveIterator(StreamIterator, Generic[M21ObjType]): ''' One of the most powerful iterators in music21. Generally not called directly, but created by being invoked on a stream with `Stream.recurse()` diff --git a/music21/test/testLint.py b/music21/test/testLint.py index 7b6df7a944..2e55a4f4bd 100644 --- a/music21/test/testLint.py +++ b/music21/test/testLint.py @@ -6,7 +6,8 @@ # Authors: Christopher Ariza # Michael Scott Asato Cuthbert # -# Copyright: Copyright © 2009-2010, 2015 Michael Scott Asato Cuthbert and the music21 Project +# Copyright: Copyright © 2009-2022 Michael Scott Asato Cuthbert, +# and the music21 Project # License: BSD, see license.txt # ------------------------------------------------------------------------------