1# -*- coding: utf-8 -*-
2# ------------------------------------------------------------------------------
3# Name:         graph/plots.py
4# Purpose:      Classes for plotting music21 graphs based on Streams.
5#
6# Authors:      Christopher Ariza
7#               Michael Scott Cuthbert
8#               Evan Lynch
9#
10# Copyright:    Copyright © 2009-2012, 2017 Michael Scott Cuthbert and the music21 Project
11# License:      BSD, see license.txt
12# ------------------------------------------------------------------------------
13'''
14Object definitions for plotting :class:`~music21.stream.Stream` objects.
15
16The :class:`~music21.graph.plot.PlotStream`
17object subclasses combine a Graph object with the PlotStreamMixin to give
18reusable approaches to graphing data and structures in
19:class:`~music21.stream.Stream` objects.
20'''
21import collections
22import os
23import pathlib
24import unittest
25
26# from music21 import common
27from music21 import chord
28from music21 import common
29from music21 import corpus
30from music21 import converter
31from music21 import dynamics
32from music21 import features
33from music21 import note
34from music21 import prebase
35from music21 import stream  # circular, but okay, because not used at top level.
36
37from music21.graph import axis
38from music21.graph import primitives
39from music21.graph.utilities import (GraphException, PlotStreamException)
40
41from music21.analysis import correlate
42from music21.analysis import discrete
43from music21.analysis import reduction
44from music21.analysis import windowed
45
46from music21 import environment
47_MOD = 'graph.plot'
48environLocal = environment.Environment(_MOD)
49
50
51def _mergeDicts(a, b):
52    '''utility function to merge two dictionaries'''
53    c = a.copy()
54    c.update(b)
55    return c
56
57
58# ------------------------------------------------------------------------------
59# graphing utilities that operate on streams
60
61class PlotStreamMixin(prebase.ProtoM21Object):
62    '''
63    This Mixin adds Stream extracting and Axis holding features to any
64    class derived from Graph.
65    '''
66    axesClasses = {'x': axis.Axis, 'y': axis.Axis}
67
68    def __init__(self, streamObj=None, recurse=True, *args, **keywords):
69        # if not isinstance(streamObj, music21.stream.Stream):
70        if streamObj is not None and not hasattr(streamObj, 'elements'):  # pragma: no cover
71            raise PlotStreamException(f'non-stream provided as argument: {streamObj}')
72        self.streamObj = streamObj
73        self.recurse = recurse
74        self.classFilterList = ['Note', 'Chord']
75        self.matchPitchCountForChords = True
76
77        self.data = None  # store native data representation, useful for testing
78
79        for axisName, axisClass in self.axesClasses.items():
80            if axisClass is not None:
81                axisObj = axisClass(self, axisName)
82                setattr(self, 'axis' + axisName.upper(), axisObj)
83
84        self.savedKeywords = keywords
85
86    def _reprInternal(self) -> str:
87        # noinspection PyShadowingNames
88        '''
89        The representation of the Plot shows the stream repr
90        in addition to the class name.
91
92        >>> st = stream.Stream()
93        >>> st.id = 'empty'
94        >>> plot = graph.plot.ScatterPitchClassQuarterLength(st)
95        >>> plot
96        <music21.graph.plot.ScatterPitchClassQuarterLength for <music21.stream.Stream empty>>
97
98        >>> plot = graph.plot.ScatterPitchClassQuarterLength(None)
99        >>> plot
100        <music21.graph.plot.ScatterPitchClassQuarterLength for (no stream)>
101
102        >>> plot.axisX
103        <music21.graph.axis.QuarterLengthAxis: x axis for ScatterPitchClassQuarterLength>
104
105        >>> plot.axisY
106        <music21.graph.axis.PitchClassAxis: y axis for ScatterPitchClassQuarterLength>
107
108        >>> axIsolated = graph.axis.DynamicsAxis(axisName='z')
109        >>> axIsolated
110        <music21.graph.axis.DynamicsAxis: z axis for (no client)>
111        '''
112        s = self.streamObj
113        if s is not None:  # not "if s" because could be empty
114            streamName = repr(s)
115        else:
116            streamName = '(no stream)'
117
118        return f'for {streamName}'
119
120    @property
121    def allAxes(self):
122        '''
123        return a list of axisX, axisY, axisZ if any are defined in the class.
124
125        Some might be None.
126
127        >>> s = stream.Stream()
128        >>> p = graph.plot.ScatterPitchClassOffset(s)
129        >>> p.allAxes
130        [<music21.graph.axis.OffsetAxis: x axis for ScatterPitchClassOffset>,
131         <music21.graph.axis.PitchClassAxis: y axis for ScatterPitchClassOffset>]
132        '''
133        allAxesList = []
134        for axisName in ('axisX', 'axisY', 'axisZ'):
135            if hasattr(self, axisName):
136                allAxesList.append(getattr(self, axisName))
137        return allAxesList
138
139    def run(self):
140        '''
141        main routine to extract data, set axis labels, run process() on the underlying
142        Graph object, and if self.doneAction is not None, either write or show the graph.
143        '''
144        self.setAxisKeywords()
145        self.extractData()
146        if hasattr(self, 'axisY') and self.axisY:
147            self.setTicks('y', self.axisY.ticks())
148            self.setAxisLabel('y', self.axisY.label)
149        if hasattr(self, 'axisX') and self.axisX:
150            self.setTicks('x', self.axisX.ticks())
151            self.setAxisLabel('x', self.axisX.label)
152
153        self.process()
154
155    # --------------------------------------------------------------------------
156    def setAxisKeywords(self):
157        '''
158        Configure axis parameters based on keywords given when creating the Plot.
159
160        Looks in self.savedKeywords, in case any post creation manipulation needs
161        to happen.
162
163        Finds keywords that begin with x, y, z and sets the remainder of the
164        keyword (lowercasing the first letter) as an attribute.  Does not
165        set any new attributes, only existing ones.
166
167        >>> b = corpus.parse('bwv66.6')
168        >>> hist = graph.plot.HistogramPitchSpace(b, xHideUnused=False)
169        >>> hist.axisX.hideUnused
170        True
171        >>> hist.setAxisKeywords()
172        >>> hist.axisX.hideUnused
173        False
174        '''
175        for thisAxis in self.allAxes:
176            if thisAxis is None:
177                continue
178            thisAxisLetter = thisAxis.axisName
179            for kw in self.savedKeywords:
180                if not kw.startswith(thisAxisLetter):
181                    continue
182                if len(kw) < 3:
183                    continue
184                shortKw = kw[1].lower() + kw[2:]
185
186                if not hasattr(thisAxis, shortKw):
187                    continue
188                setattr(thisAxis, shortKw, self.savedKeywords[kw])
189
190    # --------------------------------------------------------------------------
191
192    def extractData(self):
193        if None in self.allAxes:
194            raise PlotStreamException('Set all axes before calling extractData() via run()')
195
196        if self.recurse:
197            sIter = self.streamObj.recurse()
198        else:
199            sIter = self.streamObj.iter()
200
201        if self.classFilterList:
202            sIter = sIter.getElementsByClass(self.classFilterList)
203
204        self.data = []
205
206        for el in sIter:
207            dataList = self.processOneElement(el)
208            if dataList is not None:
209                self.data.extend(dataList)
210
211        self.postProcessData()
212
213        for i, thisAxis in enumerate(self.allAxes):
214            thisAxis.setBoundariesFromData([d[i] for d in self.data])
215
216    def processOneElement(self, el):
217        '''
218        Get a list of data from a single element (generally a Note or chord):
219
220        >>> n = note.Note('C#4')
221        >>> n.offset = 10.25
222        >>> s = stream.Stream([n])
223        >>> pl = graph.plot.ScatterPitchClassOffset(s)
224        >>> pl.processOneElement(n)
225        [(10.25, 1, {})]
226
227        >>> c = chord.Chord(['D4', 'E5'])
228        >>> s.insert(5.0, c)
229        >>> pl.processOneElement(c)
230        [(5.0, 2, {}), (5.0, 4, {})]
231
232        '''
233        elementValues = [[] for _ in range(len(self.allAxes))]
234        formatDict = {}
235        # should be two for most things...
236
237        if not isinstance(el, chord.Chord):
238            for i, thisAxis in enumerate(self.allAxes):
239                axisValue = thisAxis.extractOneElement(el, formatDict)
240                # use isinstance(List) not isiterable, since
241                # extractOneElement can distinguish between a tuple which
242                # represents a single value, or a list of values (or tuples)
243                # which represent multiple values
244                if not isinstance(axisValue, list) and axisValue is not None:
245                    axisValue = [axisValue]
246                elementValues[i] = axisValue
247        else:
248            elementValues = self.extractChordDataMultiAxis(el, formatDict)
249
250        self.postProcessElement(el, formatDict, *elementValues)
251        if None in elementValues:
252            return None
253
254        elementValueLength = max([len(ev) for ev in elementValues])
255        formatDictList = [formatDict.copy() for _ in range(elementValueLength)]
256        elementValues.append(formatDictList)
257        returnList = list(zip(*elementValues))
258        return returnList
259
260    def postProcessElement(self, el, formatDict, *values):
261        pass
262
263    def postProcessData(self):
264        '''
265        Call any post data processing routines here and on any axes.
266        '''
267        for thisAxis in self.allAxes:
268            thisAxis.postProcessData()
269
270    # --------------------------------------------------------------------------
271    @staticmethod
272    def extractChordDataOneAxis(ax, c, formatDict):
273        '''
274        Look for Note-like attributes in a Chord. This is done by first
275        looking at the Chord, and then, if attributes are not found, looking at each pitch.
276
277        Returns a list of values.
278
279
280        '''
281        values = []
282        value = None
283        try:
284            value = ax.extractOneElement(c, formatDict)
285        except AttributeError:
286            pass  # do not try others
287
288        if value is not None:
289            values.append(value)
290
291        if not values:  # still not set, get form chord
292            for n in c:
293                # try to get get values from note inside chords
294                value = None
295                try:
296                    value = ax.extractOneElement(n, formatDict)
297                except AttributeError:  # pragma: no cover
298                    break  # do not try others
299
300                if value is not None:
301                    values.append(value)
302        return values
303
304    def extractChordDataMultiAxis(self, c, formatDict):
305        '''
306        Returns a list of lists of values for each axis.
307        '''
308        elementValues = [self.extractChordDataOneAxis(ax, c, formatDict) for ax in self.allAxes]
309
310        lookIntoChordForNotesGroups = []
311        for thisAxis, values in zip(self.allAxes, elementValues):
312            if not values:
313                lookIntoChordForNotesGroups.append((thisAxis, values))
314
315        for thisAxis, destValues in lookIntoChordForNotesGroups:
316            for n in c:
317                try:
318                    target = thisAxis.extractOneElement(n, formatDict)
319                except AttributeError:  # pragma: no cover
320                    continue  # must try others
321                if target is not None:
322                    destValues.append(target)
323
324        # environLocal.printDebug(['after looking at Pitch:',
325        #    'xValues', xValues, 'yValues', yValues])
326
327        # if we only have one attribute from the Chord, and many from the
328        # Pitches, need to make the number of data points equal by
329        # duplicating data
330        if self.matchPitchCountForChords:
331            self.fillValueLists(elementValues)
332        return elementValues
333
334    @staticmethod
335    def fillValueLists(elementValues, nullFillValue=0):
336        '''
337        pads a list of lists so that each list has the same length.
338        Pads with the first element of the list or nullFillValue if
339        the list has no elements.   Modifies in place so returns None
340
341        Used by extractChordDataMultiAxis
342
343        >>> l0 = [2, 3, 4]
344        >>> l1 = [10, 20, 30, 40, 50]
345        >>> l2 = []
346        >>> listOfLists = [l0, l1, l2]
347        >>> graph.plot.PlotStream.fillValueLists(listOfLists)
348        >>> listOfLists
349        [[2,   3,  4,  2,  2],
350         [10, 20, 30, 40, 50],
351         [0,   0,  0,  0,  0]]
352        '''
353        maxLength = max([len(val) for val in elementValues])
354        for val in elementValues:
355            shortAmount = maxLength - len(val)
356            if val:
357                fillVal = val[0]
358            else:
359                fillVal = nullFillValue
360            if shortAmount:
361                val += [fillVal] * shortAmount
362
363    # --------------------------------------------------------------------------
364    @property
365    def id(self):
366        '''
367        Each PlotStream has a unique id that consists of its class name and
368        the class names of the axes:
369
370        >>> s = stream.Stream()
371        >>> pScatter = graph.plot.ScatterPitchClassQuarterLength(s)
372        >>> pScatter.id
373        'scatter-quarterLength-pitchClass'
374        '''
375        idName = self.graphType
376
377        for axisObj in self.allAxes:
378            if axisObj is None:
379                continue
380            axisName = axisObj.quantities[0]
381            idName += '-' + axisName
382
383        return idName
384
385
386# ------------------------------------------------------------------------------
387
388class PlotStream(primitives.Graph, PlotStreamMixin):
389    def __init__(self, streamObj=None, *args, **keywords):
390        primitives.Graph.__init__(self, *args, **keywords)
391        PlotStreamMixin.__init__(self, streamObj, **keywords)
392
393        self.axisX = axis.OffsetAxis(self, 'x')
394
395
396# ------------------------------------------------------------------------------
397# scatter plots
398
399class Scatter(primitives.GraphScatter, PlotStreamMixin):
400    '''
401    Base class for 2D scatter plots.
402    '''
403
404    def __init__(self, streamObj=None, *args, **keywords):
405        primitives.GraphScatter.__init__(self, *args, **keywords)
406        PlotStreamMixin.__init__(self, streamObj, **keywords)
407
408
409class ScatterPitchSpaceQuarterLength(Scatter):
410    r'''A scatter plot of pitch space and quarter length
411
412
413    >>> s = corpus.parse('bach/bwv324.xml')
414    >>> p = graph.plot.ScatterPitchSpaceQuarterLength(s)
415    >>> p.doneAction = None #_DOCS_HIDE
416    >>> p.id
417    'scatter-quarterLength-pitchSpace'
418    >>> p.run()
419
420    .. image:: images/ScatterPitchSpaceQuarterLength.*
421        :width: 600
422    '''
423    axesClasses = {'x': axis.QuarterLengthAxis,
424                   'y': axis.PitchSpaceAxis}
425
426    def __init__(self, streamObj=None, *args, **keywords):
427        super().__init__(streamObj, *args, **keywords)
428        self.axisX.useLogScale = True
429        # need more space for pitch axis labels
430        if 'figureSize' not in keywords:
431            self.figureSize = (6, 6)
432        if 'title' not in keywords:
433            self.title = 'Pitch by Quarter Length Scatter'
434#         if 'alpha' not in keywords:
435#             self.alpha = 0.7
436
437
438class ScatterPitchClassQuarterLength(ScatterPitchSpaceQuarterLength):
439    '''A scatter plot of pitch class and quarter length
440
441    >>> s = corpus.parse('bach/bwv324.xml') #_DOCS_HIDE
442    >>> p = graph.plot.ScatterPitchClassQuarterLength(s, doneAction=None) #_DOCS_HIDE
443    >>> #_DOCS_SHOW s = corpus.parse('bach/bwv57.8')
444    >>> #_DOCS_SHOW p = graph.plot.ScatterPitchClassQuarterLength(s)
445    >>> p.id
446    'scatter-quarterLength-pitchClass'
447    >>> p.run()
448
449    .. image:: images/ScatterPitchClassQuarterLength.*
450        :width: 600
451    '''
452    axesClasses = {'x': axis.QuarterLengthAxis,
453                   'y': axis.PitchClassAxis}
454
455    def __init__(self, streamObj=None, *args, **keywords):
456        super().__init__(streamObj, *args, **keywords)
457        if 'title' not in keywords:
458            self.title = 'Pitch Class by Quarter Length Scatter'
459
460
461class ScatterPitchClassOffset(Scatter):
462    '''A scatter plot of pitch class and offset
463
464    >>> s = corpus.parse('bach/bwv324.xml') #_DOCS_HIDE
465    >>> p = graph.plot.ScatterPitchClassOffset(s, doneAction=None) #_DOCS_HIDE
466    >>> #_DOCS_SHOW s = corpus.parse('bach/bwv57.8')
467    >>> #_DOCS_SHOW p = graph.plot.ScatterPitchClassOffset(s)
468    >>> p.id
469    'scatter-offset-pitchClass'
470    >>> p.run()
471
472    .. image:: images/ScatterPitchClassOffset.*
473        :width: 600
474    '''
475    axesClasses = {'x': axis.OffsetAxis,
476                   'y': axis.PitchClassAxis}
477
478    def __init__(self, streamObj=None, *args, **keywords):
479        super().__init__(streamObj, *args, **keywords)
480
481        # need more space for pitch axis labels
482        if 'figureSize' not in keywords:
483            self.figureSize = (10, 5)
484        if 'title' not in keywords:
485            self.title = 'Pitch Class by Offset Scatter'
486        if 'alpha' not in keywords:  # will not restrike, so make less transparent
487            self.alpha = 0.7
488
489
490class ScatterPitchSpaceDynamicSymbol(Scatter):
491    '''
492    A graph of dynamics used by pitch space.
493
494    >>> s = converter.parse('tinynotation: 4/4 C4 d E f', makeNotation=False) #_DOCS_HIDE
495    >>> s.insert(0.0, dynamics.Dynamic('pp')) #_DOCS_HIDE
496    >>> s.insert(2.0, dynamics.Dynamic('ff')) #_DOCS_HIDE
497    >>> p = graph.plot.ScatterPitchSpaceDynamicSymbol(s, doneAction=None) #_DOCS_HIDE
498    >>> #_DOCS_SHOW s = converter.parse('/Desktop/schumann/opus41no1/movement2.xml')
499    >>> #_DOCS_SHOW p = graph.plot.ScatterPitchSpaceDynamicSymbol(s)
500    >>> p.run()
501
502    .. image:: images/ScatterPitchSpaceDynamicSymbol.*
503        :width: 600
504    '''
505    # string name used to access this class
506    figureSizeDefault = (12, 6)
507    axesClasses = {'x': axis.PitchSpaceAxis,
508                   'y': axis.DynamicsAxis}
509
510    def __init__(self, streamObj=None, *args, **keywords):
511        super().__init__(streamObj, *args, **keywords)
512
513        self.axisX.showEnharmonic = False
514        # need more space for pitch axis labels
515        if 'figureSize' not in keywords:
516            self.figureSize = self.figureSizeDefault
517        if 'title' not in keywords:
518            self.title = 'Dynamics by Pitch Scatter'
519        if 'alpha' not in keywords:
520            self.alpha = 0.7
521
522    def extractData(self):
523        # get data from correlate object
524        am = correlate.ActivityMatch(self.streamObj)
525        amData = am.pitchToDynamic(dataPoints=True)
526        self.data = []
527        for x, y in amData:
528            self.data.append((x, y, {}))
529
530        xVals = [d[0] for d in self.data]
531        yVals = [d[1] for d in self.data]
532
533        self.axisX.setBoundariesFromData(xVals)
534        self.axisY.setBoundariesFromData(yVals)
535        self.postProcessData()
536
537
538# ------------------------------------------------------------------------------
539# histograms
540class Histogram(primitives.GraphHistogram, PlotStreamMixin):
541    '''
542    Base class for histograms that plot one axis against its count
543    '''
544    axesClasses = {'x': axis.Axis, 'y': axis.CountingAxis}
545
546    def __init__(self, streamObj=None, *args, **keywords):
547        primitives.GraphHistogram.__init__(self, *args, **keywords)
548        PlotStreamMixin.__init__(self, streamObj, **keywords)
549
550        if 'alpha' not in keywords:
551            self.alpha = 1.0
552
553    def run(self):
554        '''
555        Override run method to remap X data into individual bins.
556        '''
557        self.setAxisKeywords()
558        self.extractData()
559        self.setTicks('y', self.axisY.ticks())
560        xTicksNew = self.remapXTicksData()
561        self.setTicks('x', xTicksNew)
562        self.setAxisLabel('y', self.axisY.label)
563        self.setAxisLabel('x', self.axisX.label)
564
565        self.process()
566
567    def remapXTicksData(self):
568        '''
569        Changes the ticks and data so that they both run
570        1, 2, 3, 4, etc.
571        '''
572
573        xTicksOrig = self.axisX.ticks()
574        xTickDict = {v[0]: v[1] for v in xTicksOrig}
575        xTicksNew = []
576        # self.data is already sorted.
577        if ((not hasattr(self.axisX, 'hideUnused') or self.axisX.hideUnused is True)
578                or self.axisX.minValue is None
579                or self.axisX.maxValue is None):
580            for i in range(len(self.data)):
581                dataVal = self.data[i]
582                xDataVal = dataVal[0]
583                self.data[i] = (i + 1,) + dataVal[1:]
584                if xDataVal in xTickDict:  # should be there:
585                    newTick = (i + 1, xTickDict[xDataVal])
586                    xTicksNew.append(newTick)
587        else:
588            from music21 import pitch
589            for i in range(int(self.axisX.minValue), int(self.axisX.maxValue) + 1):
590                if i in xTickDict:
591                    label = xTickDict[i]
592                elif hasattr(self.axisX, 'blankLabelUnused') and not self.axisX.blankLabelUnused:
593                    label = pitch.Pitch(i).name
594                else:
595                    label = ''
596                newTick = (i, label)
597                xTicksNew.append(newTick)
598
599        return xTicksNew
600
601
602class HistogramPitchSpace(Histogram):
603    '''A histogram of pitch space.
604
605
606    >>> s = corpus.parse('bach/bwv324.xml') #_DOCS_HIDE
607    >>> p = graph.plot.HistogramPitchSpace(s, doneAction=None) #_DOCS_HIDE
608    >>> #_DOCS_SHOW s = corpus.parse('bach/bwv57.8')
609    >>> #_DOCS_SHOW p = graph.plot.HistogramPitchSpace(s)
610    >>> p.id
611    'histogram-pitchSpace-count'
612    >>> p.run()  # with defaults and proper configuration, will open graph
613
614    .. image:: images/HistogramPitchSpace.*
615        :width: 600
616    '''
617    axesClasses = _mergeDicts(Histogram.axesClasses, {'x': axis.PitchSpaceAxis})
618
619    def __init__(self, streamObj=None, *args, **keywords):
620        super().__init__(streamObj, *args, **keywords)
621        self.axisX.showEnharmonic = False
622        # need more space for pitch axis labels
623        if 'figureSize' not in keywords:
624            self.figureSize = (10, 6)
625        if 'title' not in keywords:
626            self.title = 'Pitch Histogram'
627
628
629class HistogramPitchClass(Histogram):
630    '''
631    A histogram of pitch class
632
633    >>> s = corpus.parse('bach/bwv324.xml') #_DOCS_HIDE
634    >>> p = graph.plot.HistogramPitchClass(s, doneAction=None) #_DOCS_HIDE
635    >>> #_DOCS_SHOW s = corpus.parse('bach/bwv57.8')
636    >>> #_DOCS_SHOW p = graph.plot.HistogramPitchClass(s)
637    >>> p.id
638    'histogram-pitchClass-count'
639    >>> p.run()  # with defaults and proper configuration, will open graph
640
641    .. image:: images/HistogramPitchClass.*
642        :width: 600
643
644    '''
645    axesClasses = _mergeDicts(Histogram.axesClasses, {'x': axis.PitchClassAxis})
646
647    def __init__(self, streamObj=None, *args, **keywords):
648        super().__init__(streamObj, *args, **keywords)
649        self.axisX.showEnharmonic = False
650        if 'title' not in keywords:
651            self.title = 'Pitch Class Histogram'
652
653
654class HistogramQuarterLength(Histogram):
655    '''A histogram of pitch class
656
657
658    >>> s = corpus.parse('bach/bwv324.xml') #_DOCS_HIDE
659    >>> p = graph.plot.HistogramQuarterLength(s, doneAction=None) #_DOCS_HIDE
660    >>> #_DOCS_SHOW s = corpus.parse('bach/bwv57.8')
661    >>> #_DOCS_SHOW p = graph.plot.HistogramQuarterLength(s)
662    >>> p.id
663    'histogram-quarterLength-count'
664    >>> p.run()  # with defaults and proper configuration, will open graph
665
666    .. image:: images/HistogramQuarterLength.*
667        :width: 600
668
669    '''
670    axesClasses = _mergeDicts(Histogram.axesClasses, {'x': axis.QuarterLengthAxis})
671
672    def __init__(self, streamObj=None, *args, **keywords):
673        super().__init__(streamObj, *args, **keywords)
674        self.axisX = axis.QuarterLengthAxis(self, 'x')
675        self.axisX.useLogScale = False
676        if 'title' not in keywords:
677            self.title = 'Quarter Length Histogram'
678
679
680# ------------------------------------------------------------------------------
681# weighted scatter
682
683class ScatterWeighted(primitives.GraphScatterWeighted, PlotStreamMixin):
684    '''
685    Base class for histograms that plot one axis against its count.
686
687    The count is stored as the Z axis, though it is represented as size.
688    '''
689    axesClasses = {'x': axis.Axis, 'y': axis.Axis, 'z': axis.CountingAxis}
690
691    def __init__(self, streamObj=None, *args, **keywords):
692        primitives.GraphScatterWeighted.__init__(self, *args, **keywords)
693        PlotStreamMixin.__init__(self, streamObj, **keywords)
694
695        self.axisZ.countAxes = ('x', 'y')
696
697
698class ScatterWeightedPitchSpaceQuarterLength(ScatterWeighted):
699    '''A graph of event, sorted by pitch, over time
700
701
702    >>> s = corpus.parse('bach/bwv324.xml') #_DOCS_HIDE
703    >>> p = graph.plot.ScatterWeightedPitchSpaceQuarterLength(s, doneAction=None) #_DOCS_HIDE
704    >>> #_DOCS_SHOW s = corpus.parse('bach/bwv57.8')
705    >>> #_DOCS_SHOW p = graph.plot.ScatterWeightedPitchSpaceQuarterLength(s)
706    >>> p.run()  # with defaults and proper configuration, will open graph
707
708    .. image:: images/ScatterWeightedPitchSpaceQuarterLength.*
709        :width: 600
710    '''
711    axesClasses = _mergeDicts(ScatterWeighted.axesClasses, {'x': axis.QuarterLengthAxis,
712                                                            'y': axis.PitchSpaceAxis})
713
714    def __init__(self, streamObj=None, *args, **keywords):
715        super().__init__(
716            streamObj, *args, **keywords)
717        # need more space for pitch axis labels
718        if 'figureSize' not in keywords:
719            self.figureSize = (7, 7)
720        if 'title' not in keywords:
721            self.title = 'Count of Pitch and Quarter Length'
722        if 'alpha' not in keywords:
723            self.alpha = 0.8
724
725
726class ScatterWeightedPitchClassQuarterLength(ScatterWeighted):
727    '''A graph of event, sorted by pitch class, over time.
728
729
730    >>> s = corpus.parse('bach/bwv324.xml') #_DOCS_HIDE
731    >>> p = graph.plot.ScatterWeightedPitchClassQuarterLength(s, doneAction=None) #_DOCS_HIDE
732    >>> #_DOCS_SHOW s = corpus.parse('bach/bwv57.8')
733    >>> #_DOCS_SHOW p = graph.plot.ScatterWeightedPitchClassQuarterLength(s)
734    >>> p.run()  # with defaults and proper configuration, will open graph
735
736    .. image:: images/ScatterWeightedPitchClassQuarterLength.*
737        :width: 600
738
739    '''
740    axesClasses = _mergeDicts(ScatterWeighted.axesClasses, {'x': axis.QuarterLengthAxis,
741                                                            'y': axis.PitchClassAxis})
742
743    def __init__(self, streamObj=None, *args, **keywords):
744        super().__init__(
745            streamObj, *args, **keywords)
746
747        # need more space for pitch axis labels
748        if 'figureSize' not in keywords:
749            self.figureSize = (7, 7)
750        if 'title' not in keywords:
751            self.title = 'Count of Pitch Class and Quarter Length'
752        if 'alpha' not in keywords:
753            self.alpha = 0.8
754
755
756class ScatterWeightedPitchSpaceDynamicSymbol(ScatterWeighted):
757    '''A graph of dynamics used by pitch space.
758
759    >>> #_DOCS_SHOW s = converter.parse('/Desktop/schumann/opus41no1/movement2.xml')
760    >>> s = converter.parse('tinynotation: 4/4 C4 d E f', makeNotation=False) #_DOCS_HIDE
761    >>> s.insert(0.0, dynamics.Dynamic('pp')) #_DOCS_HIDE
762    >>> s.insert(2.0, dynamics.Dynamic('ff')) #_DOCS_HIDE
763    >>> p = graph.plot.ScatterWeightedPitchSpaceDynamicSymbol(s, doneAction=None) #_DOCS_HIDE
764    >>> #_DOCS_SHOW p = graph.plot.ScatterWeightedPitchSpaceDynamicSymbol(s)
765    >>> p.run()  # with defaults and proper configuration, will open graph
766
767    .. image:: images/ScatterWeightedPitchSpaceDynamicSymbol.*
768        :width: 600
769
770    '''
771    axesClasses = _mergeDicts(ScatterWeighted.axesClasses, {'x': axis.PitchSpaceAxis,
772                                                            'y': axis.DynamicsAxis,
773                                                            })
774
775    def __init__(self, streamObj=None, *args, **keywords):
776        super().__init__(
777            streamObj, *args, **keywords)
778
779        self.axisX.showEnharmonic = False
780
781        # need more space for pitch axis labels
782        if 'figureSize' not in keywords:
783            self.figureSize = (10, 10)
784        if 'title' not in keywords:
785            self.title = 'Count of Pitch Class and Quarter Length'
786        if 'alpha' not in keywords:
787            self.alpha = 0.8
788        # make smaller for axis display
789        if 'tickFontSize' not in keywords:
790            self.tickFontSize = 7
791
792    def extractData(self):
793        # get data from correlate object
794        am = correlate.ActivityMatch(self.streamObj)
795        self.data = am.pitchToDynamic(dataPoints=True)
796        xVals = [x for x, unused_y in self.data]
797        yVals = [y for unused_x, y in self.data]
798        self.data = [[x, y, 1] for x, y in self.data]
799
800        self.axisX.setBoundariesFromData(xVals)
801        self.axisY.setBoundariesFromData(yVals)
802        self.postProcessData()
803
804
805# ------------------------------------------------------------------------------
806# color grids
807
808
809class WindowedAnalysis(primitives.GraphColorGrid, PlotStreamMixin):
810    '''
811    Base Plot for windowed analysis routines such as Key Analysis or Ambitus.
812    '''
813    format = 'colorGrid'
814
815    keywordConfigurables = primitives.GraphColorGrid.keywordConfigurables + (
816        'minWindow', 'maxWindow', 'windowStep', 'windowType', 'compressLegend',
817        'processorClass', 'graphLegend')
818
819    axesClasses = {'x': axis.OffsetAxis, 'y': None}
820    processorClassDefault = discrete.KrumhanslSchmuckler
821
822    def __init__(self, streamObj=None, *args, **keywords):
823        self.processorClass = self.processorClassDefault  # a discrete processor class.
824        self._processor = None
825
826        self.graphLegend = None
827        self.minWindow = 1
828        self.maxWindow = None
829        self.windowStep = 'pow2'
830        self.windowType = 'overlap'
831        self.compressLegend = True
832
833        primitives.GraphColorGrid.__init__(self, *args, **keywords)
834        PlotStreamMixin.__init__(self, streamObj, **keywords)
835
836        self.axisX = axis.OffsetAxis(self, 'x')
837
838    @property
839    def processor(self):
840        if not self.processorClass:
841            return None
842        if not self._processor:
843            self._processor = self.processorClass(self.streamObj)  # pylint: disable=not-callable
844        return self._processor
845
846    def run(self, *args, **keywords):
847        '''
848        actually create the graph...
849        '''
850        if self.title == 'Music21 Graph' and self.processor:
851            self.title = (self.processor.name
852                          + f' ({self.processor.solutionUnitString()})')
853
854        data, yTicks = self.extractData()
855        self.data = data
856        self.setTicks('y', yTicks)
857
858        self.axisX.setBoundariesFromData()
859        xTicks = self.axisX.ticks()
860        # replace offset values with 0 and 1, as proportional here
861        if len(xTicks) >= 2:
862            xTicks = [(0, xTicks[0][1]), (1, xTicks[-1][1])]
863        environLocal.printDebug(['xTicks', xTicks])
864        self.setTicks('x', xTicks)
865        self.setAxisLabel('y', 'Window Size\n(Quarter Lengths)')
866        self.setAxisLabel('x', f'Windows ({self.axisX.label} Span)')
867
868        self.graphLegend = self._getLegend()
869        self.process()
870
871        # uses self.processor
872
873    def extractData(self):
874        '''
875        Extract data actually calls the processing routine.
876
877        Returns two element tuple of the data (colorMatrix) and the yTicks list
878        '''
879        wa = windowed.WindowedAnalysis(self.streamObj, self.processor)
880        unused_solutionMatrix, colorMatrix, metaMatrix = wa.process(self.minWindow,
881                                                                    self.maxWindow,
882                                                                    self.windowStep,
883                                                                    windowType=self.windowType)
884
885        # if more than 12 bars, reduce the number of ticks
886        if len(metaMatrix) > 12:
887            tickRange = range(0, len(metaMatrix), len(metaMatrix) // 12)
888        else:
889            tickRange = range(len(metaMatrix))
890
891        environLocal.printDebug(['tickRange', tickRange])
892        # environLocal.printDebug(['last start color', colorMatrix[-1][0]])
893
894        # get dictionaries of meta data for each row
895        pos = 0
896        yTicks = []
897
898        for y in tickRange:
899            thisWindowSize = metaMatrix[y]['windowSize']
900            # pad three ticks for each needed
901            yTicks.append([pos, ''])  # pad first
902            yTicks.append([pos + 1, str(thisWindowSize)])
903            yTicks.append([pos + 2, ''])  # pad last
904            pos += 3
905
906        return colorMatrix, yTicks
907
908    def _getLegend(self):
909        '''
910        Returns a solution legend for a WindowedAnalysis
911        '''
912        graphLegend = primitives.GraphColorGridLegend(doneAction=None,
913                                                      title=self.title)
914        graphData = self.processor.solutionLegend(compress=self.compressLegend)
915        graphLegend.data = graphData
916        return graphLegend
917
918    def write(self, fp=None):  # pragma: no cover
919        '''
920        Overrides the normal write method here to add a legend.
921        '''
922        # call the process routine in the base graph
923        super().write(fp)
924
925        if fp is None:
926            fp = environLocal.getTempFile('.png', returnPathlib=True)
927        else:
928            fp = common.cleanpath(fp, returnPathlib=True)
929
930        directory, fn = os.path.split(fp)
931        fpLegend = os.path.join(directory, 'legend-' + fn)
932        # create a new graph of the legend
933        self.graphLegend.process()
934        self.graphLegend.write(fpLegend)
935
936
937class WindowedKey(WindowedAnalysis):
938    '''
939    Stream plotting of windowed version of Krumhansl-Schmuckler analysis routine.
940    See :class:`~music21.analysis.discrete.KrumhanslSchmuckler` for more details.
941
942
943    >>> s = corpus.parse('bach/bwv66.6')
944    >>> p = graph.plot.WindowedKey(s.parts[0])
945    >>> p.doneAction = None #_DOCS_HIDE
946    >>> p.run()  # with defaults and proper configuration, will open graph
947
948    .. image:: images/WindowedKrumhanslSchmuckler.*
949        :width: 600
950
951    .. image:: images/legend-WindowedKrumhanslSchmuckler.*
952
953    Set the processor class to one of the following for different uses:
954
955    >>> p = graph.plot.WindowedKey(s.parts.first())
956    >>> p.processorClass = analysis.discrete.AardenEssen
957    >>> p.processorClass = analysis.discrete.SimpleWeights
958    >>> p.processorClass = analysis.discrete.BellmanBudge
959    >>> p.processorClass = analysis.discrete.TemperleyKostkaPayne
960    >>> p.doneAction = None #_DOCS_HIDE
961    >>> p.run()
962
963    '''
964    processorClassDefault = discrete.KrumhanslSchmuckler
965
966
967class WindowedAmbitus(WindowedAnalysis):
968    '''
969    Stream plotting of basic pitch span.
970
971    >>> s = corpus.parse('bach/bwv66.6')
972    >>> p = graph.plot.WindowedAmbitus(s.parts.first())
973    >>> p.doneAction = None #_DOCS_HIDE
974    >>> p.run()  # with defaults and proper configuration, will open graph
975
976    .. image:: images/WindowedAmbitus.*
977        :width: 600
978
979    .. image:: images/legend-WindowedAmbitus.*
980
981    '''
982    processorClassDefault = discrete.Ambitus
983
984# ------------------------------------------------------------------------------
985# horizontal bar graphs
986
987
988class HorizontalBar(primitives.GraphHorizontalBar, PlotStreamMixin):
989    '''
990    A graph of events, sorted by pitch, over time
991    '''
992    axesClasses = {'x': axis.OffsetEndAxis, 'y': axis.PitchSpaceAxis}
993
994    def __init__(self, streamObj=None, *args, **keywords):
995        primitives.GraphHorizontalBar.__init__(self, *args, **keywords)
996        PlotStreamMixin.__init__(self, streamObj, **keywords)
997
998        self.axisY.hideUnused = False
999
1000    def postProcessData(self):
1001        '''
1002        Call any post data processing routines here and on any axes.
1003        '''
1004        super().postProcessData()
1005        self.axisY.setBoundariesFromData([d[1] for d in self.data])
1006        yTicks = self.axisY.ticks()
1007
1008        pitchSpanDict = {}
1009        newData = []
1010        dictOfFormatDicts = {}
1011
1012        for positionData, pitchData, formatDict in self.data:
1013            if pitchData not in pitchSpanDict:
1014                pitchSpanDict[pitchData] = []
1015                dictOfFormatDicts[pitchData] = {}
1016
1017            pitchSpanDict[pitchData].append(positionData)
1018            _mergeDicts(dictOfFormatDicts[pitchData], formatDict)
1019
1020        for unused_k, v in pitchSpanDict.items():
1021            v.sort()  # sort these tuples.
1022
1023        for numericValue, label in yTicks:
1024            if numericValue in pitchSpanDict:
1025                newData.append([label,
1026                                pitchSpanDict[numericValue],
1027                                dictOfFormatDicts[numericValue]])
1028            else:
1029                newData.append([label, [], {}])
1030        self.data = newData
1031
1032
1033class HorizontalBarPitchClassOffset(HorizontalBar):
1034    '''A graph of events, sorted by pitch class, over time
1035
1036
1037    >>> s = corpus.parse('bach/bwv324.xml') #_DOCS_HIDE
1038    >>> p = graph.plot.HorizontalBarPitchClassOffset(s, doneAction=None) #_DOCS_HIDE
1039    >>> #_DOCS_SHOW s = corpus.parse('bach/bwv57.8')
1040    >>> #_DOCS_SHOW p = graph.plot.HorizontalBarPitchClassOffset(s)
1041    >>> p.run()  # with defaults and proper configuration, will open graph
1042
1043    .. image:: images/HorizontalBarPitchClassOffset.*
1044        :width: 600
1045
1046    '''
1047    axesClasses = _mergeDicts(HorizontalBar.axesClasses, {'y': axis.PitchClassAxis})
1048
1049    def __init__(self, streamObj=None, *args, **keywords):
1050        super().__init__(streamObj, *args, **keywords)
1051        self.axisY = axis.PitchClassAxis(self, 'y')
1052        self.axisY.hideUnused = False
1053
1054        # need more space for pitch axis labels
1055        if 'figureSize' not in keywords:
1056            self.figureSize = (10, 4)
1057        if 'title' not in keywords:
1058            self.title = 'Note Quarter Length and Offset by Pitch Class'
1059
1060
1061class HorizontalBarPitchSpaceOffset(HorizontalBar):
1062    '''A graph of events, sorted by pitch space, over time
1063
1064
1065    >>> s = corpus.parse('bach/bwv324.xml') #_DOCS_HIDE
1066    >>> p = graph.plot.HorizontalBarPitchSpaceOffset(s, doneAction=None) #_DOCS_HIDE
1067    >>> #_DOCS_SHOW s = corpus.parse('bach/bwv57.8')
1068    >>> #_DOCS_SHOW p = graph.plot.HorizontalBarPitchSpaceOffset(s)
1069    >>> p.run()  # with defaults and proper configuration, will open graph
1070
1071    .. image:: images/HorizontalBarPitchSpaceOffset.*
1072        :width: 600
1073    '''
1074
1075    def __init__(self, streamObj=None, *args, **keywords):
1076        super().__init__(streamObj, *args, **keywords)
1077
1078        if 'figureSize' not in keywords:
1079            self.figureSize = (10, 6)
1080        if 'title' not in keywords:
1081            self.title = 'Note Quarter Length by Pitch'
1082
1083
1084# ------------------------------------------------------------------------------
1085class HorizontalBarWeighted(primitives.GraphHorizontalBarWeighted, PlotStreamMixin):
1086    '''
1087    A base class for plots of Scores with weighted (by height) horizontal bars.
1088    Many different weighted segments can provide a
1089    representation of a dynamic parameter of a Part.
1090    '''
1091    axesClasses = {
1092        'x': axis.OffsetAxis,
1093        'y': None
1094    }
1095    keywordConfigurables = primitives.GraphHorizontalBarWeighted.keywordConfigurables + (
1096        'fillByMeasure', 'segmentByTarget', 'normalizeByPart', 'partGroups')
1097
1098    def __init__(self, streamObj=None, *args, **keywords):
1099        self.fillByMeasure = False
1100        self.segmentByTarget = True
1101        self.normalizeByPart = False
1102        self.partGroups = None
1103
1104        primitives.GraphHorizontalBarWeighted.__init__(self, *args, **keywords)
1105        PlotStreamMixin.__init__(self, streamObj, **keywords)
1106
1107    def extractData(self):
1108        '''
1109        Extract the data from the Stream.
1110        '''
1111        if not isinstance(self.streamObj, stream.Score):
1112            raise GraphException('provided Stream must be Score')
1113        # parameters: x, span, heightScalar, color, alpha, yShift
1114        pr = reduction.PartReduction(
1115            self.streamObj,
1116            partGroups=self.partGroups,
1117            fillByMeasure=self.fillByMeasure,
1118            segmentByTarget=self.segmentByTarget,
1119            normalizeByPart=self.normalizeByPart)
1120        pr.process()
1121        data = pr.getGraphHorizontalBarWeightedData()
1122        # environLocal.printDebug(['data', data])
1123        uniqueOffsets = []
1124        for unused_key, value in data:
1125            for dataList in value:
1126                start = dataList[0]
1127                dur = dataList[1]
1128                if start not in uniqueOffsets:
1129                    uniqueOffsets.append(start)
1130                if start + dur not in uniqueOffsets:
1131                    uniqueOffsets.append(start + dur)
1132        # use default args for now
1133        self.axisX.minValue = min(uniqueOffsets)
1134        self.axisX.maxValue = max(uniqueOffsets)
1135        self.data = data
1136
1137
1138class Dolan(HorizontalBarWeighted):
1139    '''
1140    A graph of the activity of a parameter of a part (or a group of parts) over time.
1141    The default parameter graphed is Dynamics. Dynamics are assumed to extend activity
1142    to the next change in dynamics.
1143
1144    Numerous parameters can be configured based on functionality encoded in
1145    the :class:`~music21.analysis.reduction.PartReduction` object.
1146
1147
1148    If the `fillByMeasure` parameter is True, and if measures are available, each part
1149    will segment by Measure divisions, and look for the target activity only once per
1150    Measure. If more than one target is found in the Measure, values will be averaged.
1151    If `fillByMeasure` is False, the part will be segmented by each Note.
1152
1153    The `segmentByTarget` parameter is True, segments, which may be Notes or Measures,
1154    will be divided if necessary to show changes that occur over the duration of the
1155    segment by a target object.
1156
1157    If the `normalizeByPart` parameter is True, each part will be normalized within the
1158    range only of that part. If False, all parts will be normalized by the max of all parts.
1159    The default is True.
1160
1161    >>> s = corpus.parse('bwv66.6')
1162    >>> dyn = ['p', 'mf', 'f', 'ff', 'mp', 'fff', 'ppp']
1163    >>> i = 0
1164    >>> for p in s.parts:
1165    ...     for m in p.getElementsByClass('Measure'):
1166    ...         m.insert(0, dynamics.Dynamic(dyn[i % len(dyn)]))
1167    ...         i += 1
1168    ...
1169    >>> #_DOCS_SHOW s.plot('dolan', fillByMeasure=True, segmentByTarget=True)
1170
1171    .. image:: images/Dolan.*
1172        :width: 600
1173
1174    '''
1175
1176    def __init__(self, streamObj=None, *args, **keywords):
1177        super().__init__(streamObj, *args, **keywords)
1178
1179        # self.fy = lambda n: n.pitch.pitchClass
1180        # self.fyTicks = self.ticksPitchClassUsage
1181        # must set part groups if not defined here
1182        if streamObj is not None:
1183            self._getPartGroups()
1184        # need more space for pitch axis labels
1185        if 'figureSize' not in keywords:
1186            self.figureSize = (10, 4)
1187
1188        if 'title' not in keywords:
1189            self.title = 'Instrumentation'
1190            if self.streamObj and self.streamObj.metadata is not None:
1191                if self.streamObj.metadata.title is not None:
1192                    self.title = self.streamObj.metadata.title
1193        if 'hideYGrid' not in keywords:
1194            self.hideYGrid = True
1195
1196    def _getPartGroups(self):
1197        '''
1198        Examine the instruments in the Score and determine if there
1199        is a good match for a default configuration of parts.
1200        '''
1201        if self.partGroups:
1202            return  # keep what the user set
1203        if self.streamObj:
1204            return None
1205        instStream = self.streamObj.flatten().getElementsByClass('Instrument')
1206        if not instStream:
1207            return  # do not set anything
1208
1209        if len(instStream) == 4 and self.streamObj.getElementById('Soprano') is not None:
1210            pgOrc = [
1211                {'name': 'Soprano', 'color': 'purple', 'match': ['soprano', '0']},
1212                {'name': 'Alto', 'color': 'orange', 'match': ['alto', '1']},
1213                {'name': 'Tenor', 'color': 'lightgreen', 'match': ['tenor']},
1214                {'name': 'Bass', 'color': 'mediumblue', 'match': ['bass']},
1215            ]
1216            self.partGroups = pgOrc
1217
1218        elif len(instStream) == 4 and self.streamObj.getElementById('Viola') is not None:
1219            pgOrc = [
1220                {'name': '1st Violin', 'color': 'purple',
1221                    'match': ['1st violin', '0', 'violin 1', 'violin i']},
1222                {'name': '2nd Violin', 'color': 'orange',
1223                    'match': ['2nd violin', '1', 'violin 2', 'violin ii']},
1224                {'name': 'Viola', 'color': 'lightgreen', 'match': ['viola']},
1225                {'name': 'Cello', 'color': 'mediumblue',
1226                    'match': ['cello', 'violoncello', "'cello"]},
1227            ]
1228            self.partGroups = pgOrc
1229
1230        elif len(instStream) > 10:
1231            pgOrc = [
1232                {'name': 'Flute', 'color': '#C154C1', 'match': ['flauto', r'flute \d']},
1233                {'name': 'Oboe', 'color': 'blue', 'match': ['oboe', r'oboe \d']},
1234                {'name': 'Clarinet', 'color': 'mediumblue',
1235                 'match': ['clarinetto', r'clarinet in \w* \d']},
1236                {'name': 'Bassoon', 'color': 'purple', 'match': ['fagotto', r'bassoon \d']},
1237
1238                {'name': 'Horns', 'color': 'orange', 'match': ['corno', r'horn in \w* \d']},
1239                {'name': 'Trumpet', 'color': 'red',
1240                 'match': ['tromba', r'trumpet \d', r'trumpet in \w* \d']},
1241                {'name': 'Trombone', 'color': 'red', 'match': [r'trombone \d']},
1242                {'name': 'Timpani', 'color': '#5C3317', 'match': None},
1243
1244
1245                {'name': 'Violin I', 'color': 'lightgreen', 'match': ['violino i', 'violin i']},
1246                {'name': 'Violin II', 'color': 'green', 'match': ['violino ii', 'violin ii']},
1247                {'name': 'Viola', 'color': 'forestgreen', 'match': None},
1248                {'name': 'Violoncello & CB', 'color': 'dark green',
1249                 'match': ['violoncello', 'contrabasso']},
1250                #            {'name':'CB', 'color':'#003000', 'match':['contrabasso']},
1251            ]
1252            self.partGroups = pgOrc
1253
1254
1255# ------------------------------------------------------------------------------------------
1256# 3D plots
1257
1258class Plot3DBars(primitives.Graph3DBars, PlotStreamMixin):
1259    '''
1260    Base class for Stream plotting classes.
1261    '''
1262    axesClasses = {'x': axis.QuarterLengthAxis,
1263                   'y': axis.PitchClassAxis,
1264                   'z': axis.CountingAxis, }
1265
1266    def __init__(self, streamObj=None, *args, **keywords):
1267        primitives.Graph3DBars.__init__(self, *args, **keywords)
1268        PlotStreamMixin.__init__(self, streamObj, **keywords)
1269
1270        self.axisZ.countAxes = ('x', 'y')
1271
1272
1273class Plot3DBarsPitchSpaceQuarterLength(Plot3DBars):
1274    '''
1275    A scatter plot of pitch and quarter length
1276
1277    >>> s = corpus.parse('bach/bwv324.xml') #_DOCS_HIDE
1278    >>> p = graph.plot.Plot3DBarsPitchSpaceQuarterLength(s, doneAction=None) #_DOCS_HIDE
1279    >>> #_DOCS_SHOW from music21.musicxml import testFiles
1280    >>> #_DOCS_SHOW s = converter.parse(testFiles.mozartTrioK581Excerpt)
1281    >>> #_DOCS_SHOW p = graph.plot.Plot3DBarsPitchSpaceQuarterLength(s)
1282    >>> p.id
1283    '3DBars-quarterLength-pitchSpace-count'
1284    >>> p.run()  # with defaults and proper configuration, will open graph
1285
1286    .. image:: images/Plot3DBarsPitchSpaceQuarterLength.*
1287        :width: 600
1288    '''
1289    axesClasses = _mergeDicts(Plot3DBars.axesClasses, {'y': axis.PitchSpaceAxis})
1290
1291    def __init__(self, streamObj=None, *args, **keywords):
1292        super().__init__(streamObj, *args, **keywords)
1293
1294        # need more space for pitch axis labels
1295        if 'figureSize' not in keywords:
1296            self.figureSize = (6, 6)
1297        if 'title' not in keywords:
1298            self.title = 'Pitch by Quarter Length Count'
1299
1300
1301# ------------------------------------------------------------------------------
1302# base class for multi-stream displays
1303
1304class MultiStream(primitives.GraphGroupedVerticalBar, PlotStreamMixin):
1305    '''
1306    Approaches to plotting and graphing multiple Streams.
1307    A base class from which Stream plotting Classes inherit.
1308
1309    Not yet integrated into the new 2017 system, unfortunately...
1310
1311    Provide a list of Streams as an argument. Optionally
1312    provide an additional list of labels for each list.
1313    '''
1314    axesClasses = {}
1315
1316    def __init__(self, streamList, labelList=None, *args, **keywords):
1317        primitives.GraphGroupedVerticalBar.__init__(self, *args, **keywords)
1318        PlotStreamMixin.__init__(self, None)
1319
1320        if labelList is None:
1321            labelList = []
1322        self.streamList = None
1323        foundPaths = self.parseStreams(streamList)
1324
1325        # use found paths if no labels are provided
1326        if not labelList and len(foundPaths) == len(streamList):
1327            self.labelList = foundPaths
1328        else:
1329            self.labelList = labelList
1330
1331        self.data = None  # store native data representation, useful for testing
1332
1333    def parseStreams(self, streamList):
1334        self.streamList = []
1335        foundPaths = []
1336        for s in streamList:
1337            # could be corpus or file path
1338            if isinstance(s, str):
1339                foundPaths.append(os.path.basename(s))
1340                if os.path.exists(s):
1341                    s = converter.parse(s)
1342                else:  # assume corpus
1343                    s = corpus.parse(s)
1344            elif isinstance(s, pathlib.Path):
1345                foundPaths.append(s.name)
1346                if s.exists():
1347                    s = converter.parse(s)
1348                else:  # assume corpus
1349                    s = corpus.parse(s)
1350            # otherwise assume a parsed stream
1351            self.streamList.append(s)
1352        return foundPaths
1353
1354
1355class Features(MultiStream):
1356    '''
1357    Plots the output of a set of feature extractors.
1358
1359    FeatureExtractors can be ids or classes.
1360    '''
1361    format = 'features'
1362
1363    def __init__(self, streamList, featureExtractors, labelList=None, *args, **keywords):
1364        if labelList is None:
1365            labelList = []
1366
1367        super().__init__(streamList, labelList, *args, **keywords)
1368
1369        self.featureExtractors = featureExtractors
1370
1371        self.xTickLabelRotation = 90
1372        self.xTickLabelHorizontalAlignment = 'left'
1373        self.xTickLabelVerticalAlignment = 'top'
1374
1375        # self.graph.setAxisLabel('y', 'Count')
1376        # self.graph.setAxisLabel('x', 'Streams')
1377
1378        # need more space for pitch axis labels
1379        if 'figureSize' not in keywords:
1380            self.figureSize = (10, 6)
1381        if 'title' not in keywords:
1382            self.title = None
1383
1384    def run(self):
1385        # will use self.fx and self.fxTick to extract data
1386        self.setAxisKeywords()
1387
1388        self.data, xTicks, yTicks = self.extractData()
1389
1390        self.grid = False
1391
1392        self.setTicks('x', xTicks)
1393        self.setTicks('y', yTicks)
1394        self.process()
1395
1396    def extractData(self):
1397        if len(self.labelList) != len(self.streamList):
1398            labelList = [x + 1 for x in range(len(self.streamList))]
1399        else:
1400            labelList = self.labelList
1401
1402        feList = []
1403        for fe in self.featureExtractors:
1404            if isinstance(fe, str):
1405                post = features.extractorsById(fe)
1406                for sub in post:
1407                    feList.append(sub())
1408            else:  # assume a class
1409                feList.append(fe())
1410
1411        # store each stream in a data instance
1412        diList = []
1413        for s in self.streamList:
1414            di = features.DataInstance(s)
1415            diList.append(di)
1416
1417        data = []
1418        for i, di in enumerate(diList):
1419            sub = collections.OrderedDict()
1420            for fe in feList:
1421                fe.data = di
1422                v = fe.extract().vector
1423                if len(v) == 1:
1424                    sub[fe.name] = v[0]
1425                # average all values?
1426                else:
1427                    sub[fe.name] = sum(v) / len(v)
1428            dataPoint = [labelList[i], sub]
1429            data.append(dataPoint)
1430
1431        # environLocal.printDebug(['data', data])
1432
1433        xTicks = []
1434        for x, label in enumerate(labelList):
1435            # first value needs to be center of bar
1436            # value of tick is the string entry
1437            xTicks.append([x + 0.5, f'{label}'])
1438        # always have min and max
1439        yTicks = []
1440        return data, xTicks, yTicks
1441
1442# -----------------------------------------------------------------------------------
1443
1444
1445class TestExternalManual(unittest.TestCase):  # pragma: no cover
1446
1447    def testHorizontalBarPitchSpaceOffset(self):
1448        a = corpus.parse('bach/bwv57.8')
1449        # do not need to call flat version
1450        b = HorizontalBarPitchSpaceOffset(a.parts[0], title='Bach (soprano voice)')
1451        b.run()
1452
1453        b = HorizontalBarPitchSpaceOffset(a, title='Bach (all parts)')
1454        b.run()
1455
1456    def testHorizontalBarPitchClassOffset(self):
1457        a = corpus.parse('bach/bwv57.8')
1458        b = HorizontalBarPitchClassOffset(a.parts[0], title='Bach (soprano voice)')
1459        b.run()
1460
1461        a = corpus.parse('bach/bwv57.8')
1462        b = HorizontalBarPitchClassOffset(a.parts[0].measures(3, 6),
1463                                              title='Bach (soprano voice, mm 3-6)')
1464        b.run()
1465
1466    def testScatterWeightedPitchSpaceQuarterLength(self):
1467        a = corpus.parse('bach/bwv57.8').parts[0].flatten()
1468        for xLog in [True, False]:
1469            b = ScatterWeightedPitchSpaceQuarterLength(
1470                a, title='Pitch Space Bach (soprano voice)',
1471            )
1472            b.axisX.useLogScale = xLog
1473            b.run()
1474
1475            b = ScatterWeightedPitchClassQuarterLength(
1476                a, title='Pitch Class Bach (soprano voice)',
1477            )
1478            b.axisX.useLogScale = xLog
1479            b.run()
1480
1481    def testPitchSpace(self):
1482        a = corpus.parse('bach/bwv57.8')
1483        b = HistogramPitchSpace(a.parts[0].flatten(), title='Bach (soprano voice)')
1484        b.run()
1485
1486    def testPitchClass(self):
1487        a = corpus.parse('bach/bwv57.8')
1488        b = HistogramPitchClass(a.parts[0].flatten(), title='Bach (soprano voice)')
1489        b.run()
1490
1491    def testQuarterLength(self):
1492        a = corpus.parse('bach/bwv57.8')
1493        b = HistogramQuarterLength(a.parts[0].flatten(), title='Bach (soprano voice)')
1494        b.run()
1495
1496    def testScatterPitchSpaceQuarterLength(self):
1497        for xLog in [True, False]:
1498
1499            a = corpus.parse('bach/bwv57.8')
1500            b = ScatterPitchSpaceQuarterLength(a.parts[0].flatten(), title='Bach (soprano voice)',
1501                                               )
1502            b.axisX.useLogScale = xLog
1503            b.run()
1504
1505            b = ScatterPitchClassQuarterLength(a.parts[0].flatten(), title='Bach (soprano voice)',
1506                                               )
1507            b.axisX.useLogScale = xLog
1508            b.run()
1509
1510    def testScatterPitchClassOffset(self):
1511        a = corpus.parse('bach/bwv57.8')
1512        b = ScatterPitchClassOffset(a.parts[0].flatten(), title='Bach (soprano voice)')
1513        b.run()
1514
1515    def testScatterPitchSpaceDynamicSymbol(self):
1516        a = corpus.parse('schumann/opus41no1', 2)
1517        b = ScatterPitchSpaceDynamicSymbol(a.parts[0].flatten(), title='Schumann (soprano voice)')
1518        b.run()
1519
1520        b = ScatterWeightedPitchSpaceDynamicSymbol(a.parts[0].flatten(),
1521                                                       title='Schumann (soprano voice)')
1522        b.run()
1523
1524    def testPlot3DPitchSpaceQuarterLengthCount(self):
1525        a = corpus.parse('schoenberg/opus19', 6)  # also tests Tuplets
1526        b = Plot3DBarsPitchSpaceQuarterLength(a.flatten().stripTies(),
1527                                              title='Schoenberg pitch space')
1528        b.run()
1529
1530    def writeAllPlots(self):
1531        '''
1532        Write a graphic file for all graphs, naming them after the appropriate class.
1533        This is used to generate documentation samples.
1534        '''
1535        # TODO: need to add strip() ties here; but need stripTies on Score
1536        from music21.musicxml import testFiles
1537
1538        plotClasses = [
1539            # histograms
1540            (HistogramPitchSpace, None, None),
1541            (HistogramPitchClass, None, None),
1542            (HistogramQuarterLength, None, None),
1543            # scatters
1544            (ScatterPitchSpaceQuarterLength, None, None),
1545            (ScatterPitchClassQuarterLength, None, None),
1546            (ScatterPitchClassOffset, None, None),
1547            (ScatterPitchSpaceDynamicSymbol,
1548             corpus.getWork('schumann/opus41no1', 2),
1549             'Schumann Opus 41 No 1'),
1550
1551            # offset based horizontal
1552            (HorizontalBarPitchSpaceOffset, None, None),
1553            (HorizontalBarPitchClassOffset, None, None),
1554            # weighted scatter
1555            (ScatterWeightedPitchSpaceQuarterLength, None, None),
1556            (ScatterWeightedPitchClassQuarterLength, None, None),
1557            (ScatterWeightedPitchSpaceDynamicSymbol,
1558             corpus.getWork('schumann/opus41no1', 2),
1559             'Schumann Opus 41 No 1'),
1560
1561
1562            # 3d graphs
1563            (Plot3DBarsPitchSpaceQuarterLength,
1564             testFiles.mozartTrioK581Excerpt,
1565             'Mozart Trio K581 Excerpt'),
1566
1567            (WindowedKey, corpus.getWork('bach/bwv66.6.xml'), 'Bach BWV 66.6'),
1568            (WindowedAmbitus, corpus.getWork('bach/bwv66.6.xml'), 'Bach BWV 66.6'),
1569
1570        ]
1571
1572        sDefault = corpus.parse('bach/bwv57.8')
1573
1574        for plotClassName, work, titleStr in plotClasses:
1575            if work is None:
1576                s = sDefault
1577
1578            else:  # expecting data
1579                s = converter.parse(work)
1580
1581            if titleStr is not None:
1582                obj = plotClassName(s, doneAction=None, title=titleStr)
1583            else:
1584                obj = plotClassName(s, doneAction=None)
1585
1586            obj.run()
1587            fn = obj.__class__.__name__ + '.png'
1588            fp = str(environLocal.getRootTempDir() / fn)
1589            environLocal.printDebug(['writing fp:', fp])
1590            obj.write(fp)
1591
1592
1593class Test(unittest.TestCase):
1594
1595    def testCopyAndDeepcopy(self):
1596        '''
1597        Test copying all objects defined in this module
1598        '''
1599        import copy
1600        import sys
1601        import types
1602        for part in sys.modules[self.__module__].__dict__:
1603            match = False
1604            for skip in ['_', '__', 'Test', 'Exception']:
1605                if part.startswith(skip) or part.endswith(skip):
1606                    match = True
1607            if match:
1608                continue
1609            name = getattr(sys.modules[self.__module__], part)
1610            # noinspection PyTypeChecker
1611            if callable(name) and not isinstance(name, types.FunctionType):
1612                try:  # see if obj can be made w/ args
1613                    obj = name()
1614                except TypeError:
1615                    continue
1616                unused_a = copy.copy(obj)
1617                unused_b = copy.deepcopy(obj)
1618
1619    def testPitchSpaceDurationCount(self):
1620        a = corpus.parse('bach/bwv57.8')
1621        b = ScatterWeightedPitchSpaceQuarterLength(a.parts[0].flatten(), doneAction=None,
1622                                                   title='Bach (soprano voice)')
1623        b.run()
1624
1625    def testPitchSpace(self):
1626        a = corpus.parse('bach')
1627        b = HistogramPitchSpace(a.parts[0].flatten(), doneAction=None, title='Bach (soprano voice)')
1628        b.run()
1629
1630    def testPitchClass(self):
1631        a = corpus.parse('bach/bwv57.8')
1632        b = HistogramPitchClass(a.parts[0].flatten(),
1633                                doneAction=None,
1634                                title='Bach (soprano voice)')
1635        b.run()
1636
1637    def testQuarterLength(self):
1638        a = corpus.parse('bach/bwv57.8')
1639        b = HistogramQuarterLength(a.parts[0].flatten(),
1640                                   doneAction=None,
1641                                   title='Bach (soprano voice)')
1642        b.run()
1643
1644    def testPitchDuration(self):
1645        a = corpus.parse('schoenberg/opus19', 2)
1646        b = ScatterPitchSpaceDynamicSymbol(a.parts[0].flatten(),
1647                                           doneAction=None,
1648                                           title='Schoenberg (piano)')
1649        b.run()
1650
1651        b = ScatterWeightedPitchSpaceDynamicSymbol(a.parts[0].flatten(),
1652                                                   doneAction=None,
1653                                                   title='Schoenberg (piano)')
1654        b.run()
1655
1656    def testWindowed(self, doneAction=None):
1657        a = corpus.parse('bach/bwv66.6')
1658        fn = 'bach/bwv66.6'
1659        windowStep = 20  # set high to be fast
1660
1661#         b = WindowedAmbitus(a.parts, title='Bach Ambitus',
1662#             minWindow=1, maxWindow=8, windowStep=3,
1663#             doneAction=doneAction)
1664#         b.run()
1665
1666        b = WindowedKey(a.flatten(), title=fn,
1667                        minWindow=1, windowStep=windowStep,
1668                        doneAction=doneAction, dpi=300)
1669        b.run()
1670        self.assertEqual(b.graphLegend.data,
1671            [
1672                ['Major',
1673                    [('C#', '#f0727a'), ('D', '#ffd752'), ('E', '#eeff9a'),
1674                     ('F#', '#b9f0ff'), ('A', '#bb9aff'), ('B', '#ffb5ff')
1675                     ]
1676                 ],
1677                ['Minor',
1678                    [('c#', '#8c0e16'), ('', '#ffffff'), ('', '#ffffff'),
1679                     ('f#', '#558caa'), ('', '#ffffff'), ('b', '#9b519b')
1680                     ]
1681                 ]
1682            ]
1683        )
1684
1685    def testFeatures(self):
1686        streamList = ['bach/bwv66.6', 'schoenberg/opus19/movement2', 'corelli/opus3no1/1grave']
1687        feList = ['ql1', 'ql2', 'ql3']
1688
1689        p = Features(streamList, featureExtractors=feList, doneAction=None)
1690        p.run()
1691
1692    def testPianoRollFromOpus(self):
1693        o = corpus.parse('josquin/laDeplorationDeLaMorteDeJohannesOckeghem')
1694        s = o.mergeScores()
1695
1696        b = HorizontalBarPitchClassOffset(s, doneAction=None)
1697        b.run()
1698
1699    def testChordsA(self):
1700        from music21 import scale
1701        sc = scale.MajorScale('c4')
1702
1703        b = Histogram(stream.Stream(), doneAction=None)
1704        c = chord.Chord(['b', 'c', 'd'])
1705        b.axisX = axis.PitchSpaceAxis(b, 'x')  # pylint: disable=attribute-defined-outside-init
1706        self.assertEqual(b.extractChordDataOneAxis(b.axisX, c, {}), [71, 60, 62])
1707
1708        s = stream.Stream()
1709        s.append(chord.Chord(['b', 'c#', 'd']))
1710        s.append(note.Note('c3'))
1711        s.append(note.Note('c5'))
1712        b = HistogramPitchSpace(s, doneAction=None)
1713        b.run()
1714
1715        # b.write()
1716        self.assertEqual(b.data, [(1, 1, {}), (2, 1, {}), (3, 1, {}), (4, 1, {}), (5, 1, {})])
1717
1718        s = stream.Stream()
1719        s.append(sc.getChord('e3', 'a3'))
1720        s.append(note.Note('c3'))
1721        s.append(note.Note('c3'))
1722        b = HistogramPitchClass(s, doneAction=None)
1723        b.run()
1724
1725        # b.write()
1726        self.assertEqual(b.data, [(1, 2, {}), (2, 1, {}), (3, 1, {}), (4, 1, {}), (5, 1, {})])
1727
1728        s = stream.Stream()
1729        s.append(sc.getChord('e3', 'a3', quarterLength=2))
1730        s.append(note.Note('c3', quarterLength=0.5))
1731        b = HistogramQuarterLength(s, doneAction=None)
1732        b.run()
1733
1734        # b.write()
1735        self.assertEqual(b.data, [(1, 1, {}), (2, 1, {})])
1736
1737        # test scatter plots
1738
1739        b = Scatter(stream.Stream(), doneAction=None)
1740        b.axisX = axis.PitchSpaceAxis(b, 'x')  # pylint: disable=attribute-defined-outside-init
1741        b.axisY = axis.QuarterLengthAxis(b, 'y')  # pylint: disable=attribute-defined-outside-init
1742        b.axisY.useLogScale = False
1743        c = chord.Chord(['b', 'c', 'd'], quarterLength=0.5)
1744
1745        self.assertEqual(b.extractChordDataMultiAxis(c, {}),
1746                         [[71, 60, 62], [0.5, 0.5, 0.5]])
1747
1748        b.matchPitchCountForChords = False
1749        self.assertEqual(b.extractChordDataMultiAxis(c, {}), [[71, 60, 62], [0.5]])
1750        # matching the number of pitches for each data point may be needed
1751
1752    def testChordsA2(self):
1753        from music21 import scale
1754        sc = scale.MajorScale('c4')
1755
1756        s = stream.Stream()
1757        s.append(sc.getChord('e3', 'a3', quarterLength=0.5))
1758        s.append(sc.getChord('b3', 'c5', quarterLength=1.5))
1759        s.append(note.Note('c3', quarterLength=2))
1760        b = ScatterPitchSpaceQuarterLength(s, doneAction=None)
1761        b.axisX.useLogScale = False
1762        b.run()
1763
1764        match = [(0.5, 52.0, {}), (0.5, 53.0, {}), (0.5, 55.0, {}), (0.5, 57.0, {}),
1765                 (1.5, 59.0, {}), (1.5, 60.0, {}),
1766                 (1.5, 62.0, {}), (1.5, 64.0, {}),
1767                 (1.5, 65.0, {}), (1.5, 67.0, {}),
1768                 (1.5, 69.0, {}), (1.5, 71.0, {}), (1.5, 72.0, {}),
1769                 (2.0, 48.0, {})]
1770        self.assertEqual(b.data, match)
1771        # b.write()
1772
1773    def testChordsA3(self):
1774        from music21 import scale
1775        sc = scale.MajorScale('c4')
1776
1777        s = stream.Stream()
1778        s.append(sc.getChord('e3', 'a3', quarterLength=0.5))
1779        s.append(sc.getChord('b3', 'c5', quarterLength=1.5))
1780        s.append(note.Note('c3', quarterLength=2))
1781        b = ScatterPitchClassQuarterLength(s, doneAction=None)
1782        b.axisX.useLogScale = False
1783        b.run()
1784
1785        match = [(0.5, 4, {}), (0.5, 5, {}), (0.5, 7, {}), (0.5, 9, {}),
1786                 (1.5, 11, {}), (1.5, 0, {}), (1.5, 2, {}), (1.5, 4, {}), (1.5, 5, {}),
1787                 (1.5, 7, {}), (1.5, 9, {}), (1.5, 11, {}), (1.5, 0, {}),
1788                 (2.0, 0, {})]
1789        self.assertEqual(b.data, match)
1790        # b.write()
1791
1792    def testChordsA4(self):
1793        from music21 import scale
1794        sc = scale.MajorScale('c4')
1795
1796        s = stream.Stream()
1797        s.append(sc.getChord('e3', 'a3', quarterLength=0.5))
1798        s.append(note.Note('c3', quarterLength=2))
1799        s.append(sc.getChord('b3', 'e4', quarterLength=1.5))
1800        s.append(note.Note('d3', quarterLength=2))
1801        self.assertEqual([e.offset for e in s], [0.0, 0.5, 2.5, 4.0])
1802
1803        # s.show()
1804        b = ScatterPitchClassOffset(s, doneAction=None)
1805        b.run()
1806
1807        match = [(0.0, 4, {}), (0.0, 5, {}), (0.0, 7, {}), (0.0, 9, {}),
1808                 (0.5, 0, {}),
1809                 (2.5, 11, {}), (2.5, 0, {}), (2.5, 2, {}), (2.5, 4, {}),
1810                 (4.0, 2, {})]
1811        self.assertEqual(b.data, match)
1812        # b.write()
1813
1814    def testChordsA5(self):
1815        from music21 import scale
1816        sc = scale.MajorScale('c4')
1817
1818        s = stream.Stream()
1819        s.append(dynamics.Dynamic('f'))
1820        s.append(sc.getChord('e3', 'a3', quarterLength=0.5))
1821        # s.append(note.Note('c3', quarterLength=2))
1822        s.append(dynamics.Dynamic('p'))
1823        s.append(sc.getChord('b3', 'e4', quarterLength=1.5))
1824        # s.append(note.Note('d3', quarterLength=2))
1825
1826        # s.show()
1827        b = ScatterPitchSpaceDynamicSymbol(s, doneAction=None)
1828        b.run()
1829
1830        self.assertEqual(b.data, [(52, 8, {}), (53, 8, {}), (55, 8, {}),
1831                                  (57, 8, {}), (59, 8, {}), (59, 5, {}),
1832                                  (60, 8, {}), (60, 5, {}), (62, 8, {}),
1833                                  (62, 5, {}), (64, 8, {}), (64, 5, {})])
1834        # b.write()
1835
1836    def testChordsB(self):
1837        from music21 import scale
1838        sc = scale.MajorScale('c4')
1839
1840        s = stream.Stream()
1841        s.append(note.Note('c3'))
1842        s.append(sc.getChord('e3', 'a3', quarterLength=0.5))
1843        # s.append(note.Note('c3', quarterLength=2))
1844        s.append(sc.getChord('b3', 'e4', quarterLength=1.5))
1845
1846        b = HorizontalBarPitchClassOffset(s, doneAction=None)
1847        b.run()
1848
1849        match = [['C', [(0.0, 0.9375), (1.5, 1.4375)], {}],
1850                 ['', [], {}],
1851                 ['D', [(1.5, 1.4375)], {}],
1852                 ['', [], {}],
1853                 ['E', [(1.0, 0.4375), (1.5, 1.4375)], {}],
1854                 ['F', [(1.0, 0.4375)], {}],
1855                 ['', [], {}],
1856                 ['G', [(1.0, 0.4375)], {}],
1857                 ['', [], {}],
1858                 ['A', [(1.0, 0.4375)], {}],
1859                 ['', [], {}],
1860                 ['B', [(1.5, 1.4375)], {}]]
1861        self.assertEqual(b.data, match)
1862        # b.write()
1863
1864        s = stream.Stream()
1865        s.append(note.Note('c3'))
1866        s.append(sc.getChord('e3', 'a3', quarterLength=0.5))
1867        # s.append(note.Note('c3', quarterLength=2))
1868        s.append(sc.getChord('b3', 'e4', quarterLength=1.5))
1869
1870        b = HorizontalBarPitchSpaceOffset(s, doneAction=None)
1871        b.run()
1872        match = [['C3', [(0.0, 0.9375)], {}],
1873                 ['', [], {}],
1874                 ['', [], {}],
1875                 ['', [], {}],
1876                 ['E', [(1.0, 0.4375)], {}],
1877                 ['F', [(1.0, 0.4375)], {}],
1878                 ['', [], {}],
1879                 ['G', [(1.0, 0.4375)], {}],
1880                 ['', [], {}],
1881                 ['A', [(1.0, 0.4375)], {}],
1882                 ['', [], {}],
1883                 ['B', [(1.5, 1.4375)], {}],
1884                 ['C4', [(1.5, 1.4375)], {}],
1885                 ['', [], {}],
1886                 ['D', [(1.5, 1.4375)], {}],
1887                 ['', [], {}],
1888                 ['E', [(1.5, 1.4375)], {}]]
1889
1890        self.assertEqual(b.data, match)
1891        # b.write()
1892
1893        s = stream.Stream()
1894        s.append(note.Note('c3'))
1895        s.append(sc.getChord('e3', 'a3', quarterLength=0.5))
1896        # s.append(note.Note('c3', quarterLength=2))
1897        s.append(sc.getChord('b3', 'e4', quarterLength=1.5))
1898        s.append(sc.getChord('f4', 'g5', quarterLength=3))
1899        s.append(sc.getChord('f4', 'g5', quarterLength=3))
1900        s.append(note.Note('c5', quarterLength=3))
1901
1902        b = ScatterWeightedPitchSpaceQuarterLength(s, doneAction=None)
1903        b.axisX.useLogScale = False
1904        b.run()
1905
1906        self.assertEqual(b.data[0:7], [(0.5, 52.0, 1, {}), (0.5, 53.0, 1, {}), (0.5, 55.0, 1, {}),
1907                                       (0.5, 57.0, 1, {}), (1.0, 48.0, 1, {}), (1.5, 59.0, 1, {}),
1908                                       (1.5, 60.0, 1, {})])
1909        # b.write()
1910
1911        s = stream.Stream()
1912        s.append(note.Note('c3'))
1913        s.append(sc.getChord('e3', 'a3', quarterLength=0.5))
1914        # s.append(note.Note('c3', quarterLength=2))
1915        s.append(sc.getChord('b3', 'e4', quarterLength=1.5))
1916        s.append(sc.getChord('f4', 'g5', quarterLength=3))
1917        s.append(sc.getChord('f4', 'g5', quarterLength=3))
1918        s.append(note.Note('c5', quarterLength=3))
1919
1920        b = ScatterWeightedPitchClassQuarterLength(s, doneAction=None)
1921        b.axisX.useLogScale = False
1922        b.run()
1923
1924        self.assertEqual(b.data[0:8], [(0.5, 4, 1, {}), (0.5, 5, 1, {}), (0.5, 7, 1, {}),
1925                                       (0.5, 9, 1, {}),
1926                                       (1.0, 0, 1, {}),
1927                                       (1.5, 0, 1, {}), (1.5, 2, 1, {}), (1.5, 4, 1, {})])
1928        # b.write()
1929
1930    def testChordsB2(self):
1931        from music21 import scale
1932        sc = scale.MajorScale('c4')
1933
1934        s = stream.Stream()
1935        s.append(dynamics.Dynamic('f'))
1936        # s.append(note.Note('c3'))
1937        c = sc.getChord('e3', 'a3', quarterLength=0.5)
1938        self.assertEqual(repr(c), '<music21.chord.Chord E3 F3 G3 A3>')
1939        self.assertEqual([n.pitch.ps for n in c], [52.0, 53.0, 55.0, 57.0])
1940        s.append(c)
1941        # s.append(note.Note('c3', quarterLength=2))
1942        s.append(dynamics.Dynamic('mf'))
1943        s.append(sc.getChord('b3', 'e4', quarterLength=1.5))
1944        s.append(dynamics.Dynamic('pp'))
1945        s.append(sc.getChord('f4', 'g5', quarterLength=3))
1946        s.append(sc.getChord('f4', 'g5', quarterLength=3))
1947        s.append(note.Note('c5', quarterLength=3))
1948
1949        b = ScatterWeightedPitchSpaceDynamicSymbol(s, doneAction=None)
1950        b.axisX.useLogScale = False
1951        b.run()
1952        match = [(52.0, 8, 1, {}), (53.0, 8, 1, {}), (55.0, 8, 1, {}), (57.0, 8, 1, {}),
1953                 (59.0, 7, 1, {}), (59.0, 8, 1, {}), (60.0, 7, 1, {}), (60.0, 8, 1, {}),
1954                 (62.0, 7, 1, {}), (62.0, 8, 1, {}), (64.0, 7, 1, {}), (64.0, 8, 1, {}),
1955                 (65.0, 4, 2, {}), (65.0, 7, 1, {}),
1956                 (67.0, 4, 2, {}), (67.0, 7, 1, {}),
1957                 (69.0, 4, 2, {}), (69.0, 7, 1, {}), (71.0, 4, 2, {}), (71.0, 7, 1, {}),
1958                 (72.0, 4, 3, {}), (72.0, 7, 1, {}), (74.0, 4, 2, {}), (74.0, 7, 1, {}),
1959                 (76.0, 4, 2, {}), (76.0, 7, 1, {}), (77.0, 4, 2, {}), (77.0, 7, 1, {}),
1960                 (79.0, 4, 2, {}), (79.0, 7, 1, {})]
1961
1962        self.maxDiff = 2048
1963        # TODO: Is this right? why are the old dynamics still active?
1964        self.assertEqual(b.data, match)
1965        # b.write()
1966
1967    def testChordsB3(self):
1968        from music21 import scale
1969        sc = scale.MajorScale('c4')
1970
1971        s = stream.Stream()
1972        s.append(dynamics.Dynamic('f'))
1973        s.append(note.Note('c3'))
1974        s.append(sc.getChord('e3', 'a3', quarterLength=0.5))
1975        s.append(dynamics.Dynamic('mf'))
1976        s.append(sc.getChord('b3', 'e4', quarterLength=1.5))
1977        s.append(dynamics.Dynamic('pp'))
1978        s.append(sc.getChord('f4', 'g5', quarterLength=3))
1979        s.append(note.Note('c5', quarterLength=3))
1980
1981        b = Plot3DBarsPitchSpaceQuarterLength(s, doneAction=None)
1982        b.axisX.useLogScale = False
1983        b.run()
1984
1985        self.assertEqual(b.data[0], (0.5, 52.0, 1, {}))
1986        # b.write()
1987
1988    def testDolanA(self):
1989        a = corpus.parse('bach/bwv57.8')
1990        b = Dolan(a, title='Bach', doneAction=None)
1991        b.run()
1992
1993        # b.show()
1994
1995
1996# ------------------------------------------------------------------------------
1997# define presented order in documentation
1998_DOC_ORDER = [
1999    HistogramPitchSpace,
2000    HistogramPitchClass,
2001    HistogramQuarterLength,
2002    # windowed
2003    WindowedKey,
2004    WindowedAmbitus,
2005    # scatters
2006    ScatterPitchSpaceQuarterLength,
2007    ScatterPitchClassQuarterLength,
2008    ScatterPitchClassOffset,
2009    ScatterPitchSpaceDynamicSymbol,
2010    # offset based horizontal
2011    HorizontalBarPitchSpaceOffset,
2012    HorizontalBarPitchClassOffset,
2013    Dolan,
2014    # weighted scatter
2015    ScatterWeightedPitchSpaceQuarterLength,
2016    ScatterWeightedPitchClassQuarterLength,
2017    ScatterWeightedPitchSpaceDynamicSymbol,
2018    # 3d graphs
2019    Plot3DBarsPitchSpaceQuarterLength,
2020]
2021
2022
2023if __name__ == '__main__':
2024    import music21
2025    music21.mainTest(TestExternalManual)  # , runTest='test3DPitchSpaceQuarterLengthCount')
2026