diff --git a/.gitignore b/.gitignore index 04aa7dd0e..e9db9c422 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ __pycache__/ .idea/vcs.xml .idea/workspace.xml .idea/markdown-*.xml +.idea/copilot # OSX .DS_Store diff --git a/music21/base.py b/music21/base.py index ccca277de..a474d78f4 100644 --- a/music21/base.py +++ b/music21/base.py @@ -899,7 +899,7 @@ def getOffsetBySite( returnSpecial: bool = False, ) -> OffsetQL|OffsetSpecial: return 0.0 # dummy until Astroid #1015 is fixed. Replace with ... - # using bool instead of t.Literal[True] because of + # using bool instead of t.Literal[True] because of other errors def getOffsetBySite( self, diff --git a/music21/note.py b/music21/note.py index 11946ab51..c38b000b0 100644 --- a/music21/note.py +++ b/music21/note.py @@ -871,7 +871,7 @@ def fullName(self) -> str: @property def pitches(self) -> tuple[Pitch, ...]: ''' - Returns an empty tuple. (Useful for iterating over NotRests since they + Returns an empty tuple. (Useful for iterating over GeneralNotes since they include Notes and Chords.) ''' return () diff --git a/music21/tree/timespanTree.py b/music21/tree/timespanTree.py index 5ad401330..fabe7db21 100644 --- a/music21/tree/timespanTree.py +++ b/music21/tree/timespanTree.py @@ -34,7 +34,7 @@ if t.TYPE_CHECKING: - from music21.tree.verticality import VerticalitySequence + from music21.tree.verticality import Verticality, VerticalitySequence environLocal = environment.Environment('tree.timespanTree') @@ -244,11 +244,15 @@ def removeTimespan(self, elements, offsets=None, runUpdate=True): ''' self.removeElements(elements, offsets, runUpdate) - def findNextPitchedTimespanInSameStreamByClass(self, pitchedTimespan, classList=None): + def findNextPitchedTimespanInSameStreamByClass( + self, + pitchedTimespan: spans.PitchedTimespan, + classList=None + ) -> spans.PitchedTimespan | None: r''' Finds next element timespan in the same stream class as `PitchedTimespan`. - Default classList is (stream.Part, ) + Default classList is (stream.Part,) >>> score = corpus.parse('bwv66.6') >>> scoreTree = score.asTimespans(classList=(note.Note,)) @@ -289,7 +293,11 @@ def findNextPitchedTimespanInSameStreamByClass(self, pitchedTimespan, classList= pitchedTimespan.getParentageByClass(classList)): return nextPitchedTimespan - def findPreviousPitchedTimespanInSameStreamByClass(self, pitchedTimespan, classList=None): + def findPreviousPitchedTimespanInSameStreamByClass( + self, + pitchedTimespan: spans.PitchedTimespan, + classList=None + ) -> spans.PitchedTimespan | None: r''' Finds next element timespan in the same Part/Measure, etc. (specify in classList) as the `pitchedTimespan`. @@ -445,8 +453,8 @@ def iterateConsonanceBoundedVerticalities(self): def iterateVerticalities( self, - reverse=False, - ): + reverse: bool = False, + ) -> Generator[Verticality, None, None]: r''' Iterates all vertical moments in this TimespanTree, represented as :class:`~music21.tree.verticality.Verticality` objects. diff --git a/music21/tree/verticality.py b/music21/tree/verticality.py index ee091e791..ebe8043cc 100644 --- a/music21/tree/verticality.py +++ b/music21/tree/verticality.py @@ -20,6 +20,7 @@ import copy import itertools import typing as t +from typing import overload import unittest from music21 import chord @@ -32,11 +33,19 @@ # from music21 import key # from music21 import pitch from music21.common.types import OffsetQL, OffsetQLIn - from music21.tree import spans +if t.TYPE_CHECKING: + from music21.tree.trees import OffsetTree + from music21.voiceLeading import VoiceLeadingQuartet + environLocal = environment.Environment('tree.verticality') +PitchedTimespanQuartet = tuple[ + tuple[spans.PitchedTimespan, spans.PitchedTimespan], + tuple[spans.PitchedTimespan, spans.PitchedTimespan], +] + class VerticalityException(exceptions21.TreeException): pass @@ -123,6 +132,7 @@ class Verticality(prebase.ProtoM21Object): # CLASS VARIABLES # __slots__ = ( + 'offsetTree', 'timespanTree', 'overlapTimespans', 'startTimespans', @@ -131,8 +141,11 @@ class Verticality(prebase.ProtoM21Object): ) _DOC_ATTR: dict[str, str] = { + 'offsetTree': r''' + Returns the tree initially set else None + ''', 'timespanTree': r''' - Returns the timespanTree initially set. + Returns the tree initially set if it was a TimespanTree, else None ''', 'overlapTimespans': r''' Gets timespans overlapping the start offset of a verticality. @@ -202,32 +215,33 @@ class Verticality(prebase.ProtoM21Object): def __init__( self, - offset=None, - overlapTimespans=(), - startTimespans=(), - stopTimespans=(), + offset: OffsetQL = 0.0, + overlapTimespans: tuple[spans.ElementTimespan, ...] = (), + startTimespans: tuple[spans.ElementTimespan, ...] = (), + stopTimespans: tuple[spans.ElementTimespan, ...] = (), timespanTree=None, ): - from music21.tree import trees - if timespanTree is not None and not isinstance(timespanTree, trees.OffsetTree): - raise VerticalityException( - f'timespanTree {timespanTree!r} is not a OffsetTree or None') + from music21.tree.timespanTree import TimespanTree + self.offsetTree: OffsetTree | None = timespanTree + self.timespanTree: TimespanTree | None = None + if isinstance(timespanTree, TimespanTree): + self.timespanTree = timespanTree - self.timespanTree = timespanTree - self.offset = offset + self.offset: OffsetQL = offset if not isinstance(startTimespans, tuple): - raise VerticalityException(f'startTimespans must be a tuple, not {startTimespans!r}') - if not isinstance(stopTimespans, (tuple, type(None))): raise VerticalityException( - f'stopTimespans must be a tuple or None, not {stopTimespans!r}') - if not isinstance(overlapTimespans, (tuple, type(None))): + f'startTimespans must be a tuple of ElementTimespans, not {startTimespans!r}') + if not isinstance(stopTimespans, tuple): + raise VerticalityException( + f'stopTimespans must be a tuple of ElementTimespans, not {stopTimespans!r}') + if not isinstance(overlapTimespans, tuple): raise VerticalityException( - f'overlapTimespans must be a tuple or None, not {overlapTimespans!r}') + f'overlapTimespans must be a tuple of ElementTimespans, not {overlapTimespans!r}') - self.startTimespans = startTimespans - self.stopTimespans = stopTimespans - self.overlapTimespans = overlapTimespans + self.startTimespans: tuple[spans.ElementTimespan, ...] = startTimespans + self.stopTimespans: tuple[spans.ElementTimespan, ...] = stopTimespans + self.overlapTimespans: tuple[spans.ElementTimespan, ...] = overlapTimespans # SPECIAL METHODS # @@ -350,7 +364,7 @@ def nextStartOffset(self) -> float|None: If a verticality has no tree attached, then it will return None ''' - tree = self.timespanTree + tree = self.offsetTree if tree is None: return None offset = tree.getPositionAfter(self.offset) @@ -382,7 +396,7 @@ def nextVerticality(self): >>> verticality.nextVerticality ''' - tree = self.timespanTree + tree = self.offsetTree if tree is None: return None offset = tree.getPositionAfter(self.offset) @@ -496,7 +510,7 @@ def previousVerticality(self): >>> verticality.previousVerticality ''' - tree = self.timespanTree + tree = self.offsetTree if tree is None: return None offset = tree.getPositionBefore(self.offset) @@ -777,6 +791,9 @@ def newNote(ts, n: note.Note) -> note.Note: return nNew offsetDifference = common.opFrac(self.offset - ts.offset) + if t.TYPE_CHECKING: + assert quarterLength is not None + endTimeDifference = common.opFrac(ts.endTime - (self.offset + quarterLength)) if t.TYPE_CHECKING: assert endTimeDifference is not None @@ -933,16 +950,43 @@ def conditionalAdd(ts, n: note.Note) -> None: return c # Analysis type things... + @overload def getAllVoiceLeadingQuartets( self, *, includeRests=True, includeOblique=True, includeNoMotion=False, - returnObjects=True, - partPairNumbers=None - ): - # noinspection PyShadowingNames + returnObjects: t.Literal[False], + partPairNumbers: list[tuple[int, int]] | None = None + ) -> list[PitchedTimespanQuartet]: + # dummy until Astroid #1015 is fixed. Replace with ... + return [] + + @overload + def getAllVoiceLeadingQuartets( + self, + *, + includeRests=True, + includeOblique=True, + includeNoMotion=False, + returnObjects: t.Literal[True] = True, + partPairNumbers: list[tuple[int, int]] | None = None + ) -> list[VoiceLeadingQuartet]: + # dummy until Astroid #1015 is fixed. Replace with ... + return [] + + + def getAllVoiceLeadingQuartets( + self, + *, + includeRests=True, + includeOblique=True, + includeNoMotion=False, + returnObjects: bool = True, + partPairNumbers: list[tuple[int, int]] | None = None + ) -> list[VoiceLeadingQuartet] | list[PitchedTimespanQuartet]: + # noinspection PyShadowingNames,PyCallingNonCallable ''' >>> c = corpus.parse('luca/gloria').measures(1, 8) >>> tsCol = tree.fromStream.asTimespans(c, flatten=True, @@ -971,7 +1015,7 @@ def getAllVoiceLeadingQuartets( [] - Raw output + Raw output, returns a 2-element tuple of 2-element tuples of PitchedTimespans >>> for vlqRaw in verticality22.getAllVoiceLeadingQuartets(returnObjects=False): ... pp(vlqRaw) @@ -1002,28 +1046,37 @@ def getAllVoiceLeadingQuartets( * Changed in v8: all parameters are keyword only. ''' + if not self.timespanTree: + raise VerticalityException('Cannot iterate without .timespanTree defined') + from music21.voiceLeading import VoiceLeadingQuartet - pairedMotionList = self.getPairedMotion(includeRests=includeRests, - includeOblique=includeOblique) - allQuartets = itertools.combinations(pairedMotionList, 2) - filteredList = [] + pairedMotionList: list[ + tuple[spans.PitchedTimespan, spans.PitchedTimespan] + ] = self.getPairedMotion( + includeRests=includeRests, + includeOblique=includeOblique + ) + allPairedMotion = itertools.combinations(pairedMotionList, 2) + filteredList: list[PitchedTimespanQuartet] = [] + filteredQuartet: list[VoiceLeadingQuartet] = [] verticalityStreamParts = self.timespanTree.source.parts - for thisQuartet in allQuartets: - if not hasattr(thisQuartet[0][0], 'pitches'): + pairedMotion: PitchedTimespanQuartet + for pairedMotion in allPairedMotion: + if not hasattr(pairedMotion[0][0], 'pitches'): continue # not a PitchedTimespan if includeNoMotion is False: - if (thisQuartet[0][0].pitches == thisQuartet[0][1].pitches - and thisQuartet[1][0].pitches == thisQuartet[1][1].pitches): + if (pairedMotion[0][0].pitches == pairedMotion[0][1].pitches + and pairedMotion[1][0].pitches == pairedMotion[1][1].pitches): continue if partPairNumbers is not None: isAppropriate = False for pp in partPairNumbers: - thisQuartetTopPart = thisQuartet[0][0].part - thisQuartetBottomPart = thisQuartet[1][0].part + thisQuartetTopPart = pairedMotion[0][0].part + thisQuartetBottomPart = pairedMotion[1][0].part if ((verticalityStreamParts[pp[0]] == thisQuartetTopPart or verticalityStreamParts[pp[0]] == thisQuartetBottomPart) and (verticalityStreamParts[pp[1]] == thisQuartetTopPart @@ -1034,23 +1087,31 @@ def getAllVoiceLeadingQuartets( continue if returnObjects is False: - filteredList.append(thisQuartet) + filteredList.append(pairedMotion) else: - n11 = thisQuartet[0][0].element - n12 = thisQuartet[0][1].element - n21 = thisQuartet[1][0].element - n22 = thisQuartet[1][1].element - - if (n11 is not None - and n12 is not None - and n21 is not None - and n22 is not None): + n11 = pairedMotion[0][0].element + n12 = pairedMotion[0][1].element + n21 = pairedMotion[1][0].element + n22 = pairedMotion[1][1].element + + # fail on Chords for now. + if (isinstance(n11, note.Note) + and isinstance(n12, note.Note) + and isinstance(n21, note.Note) + and isinstance(n22, note.Note)): vlq = VoiceLeadingQuartet(n11, n12, n21, n22) - filteredList.append(vlq) + filteredQuartet.append(vlq) + if returnObjects: + return filteredQuartet return filteredList - def getPairedMotion(self, includeRests=True, includeOblique=True): + def getPairedMotion( + self, + *, + includeRests: bool = True, + includeOblique: bool = True + ) -> list[tuple[spans.PitchedTimespan, spans.PitchedTimespan]]: ''' Get a list of two-element tuples that are in the same part [TODO: or containing stream??] @@ -1097,16 +1158,23 @@ def getPairedMotion(self, includeRests=True, includeOblique=True): ... print(pm) (>, >) + + Changed in v9.3 -- arguments are keyword only ''' + if not self.timespanTree: + return [] + stopTss = self.stopTimespans startTss = self.startTimespans overlapTss = self.overlapTimespans - allPairedMotions = [] + allPairedMotions: list[tuple[spans.PitchedTimespan, spans.PitchedTimespan]] = [] for startingTs in startTss: + if not isinstance(startingTs, spans.PitchedTimespan): + continue previousTs = self.timespanTree.findPreviousPitchedTimespanInSameStreamByClass( startingTs) - if previousTs is None: + if previousTs is None or not isinstance(previousTs, spans.PitchedTimespan): continue # first not in piece in this part... if includeRests is False: @@ -1119,6 +1187,8 @@ def getPairedMotion(self, includeRests=True, includeOblique=True): if includeOblique is True: for overlapTs in overlapTss: + if not isinstance(overlapTs, spans.PitchedTimespan): + continue tsTuple = (overlapTs, overlapTs) allPairedMotions.append(tsTuple) diff --git a/music21/voiceLeading.py b/music21/voiceLeading.py index ba6c9b5e4..ad107fdc0 100644 --- a/music21/voiceLeading.py +++ b/music21/voiceLeading.py @@ -19,7 +19,7 @@ The list of objects included here are: * :class:`~music21.voiceLeading.VoiceLeadingQuartet` : two by two matrix of notes - +* :func:`~music21.voiceLeading.iterateAllVoiceLeadingQuartets` : yields each VLQ in a piece. * :class:`~music21.voiceLeading.Verticality` : vertical context in a score, composed of any music21 objects * :class:`~music21.voiceLeading.VerticalityNTuplet` : group of three @@ -35,6 +35,7 @@ ''' from __future__ import annotations +from collections.abc import Generator import enum import typing as t import unittest @@ -51,6 +52,8 @@ from music21 import pitch from music21 import scale +if t.TYPE_CHECKING: + from music21 import stream # from music21 import harmony can't do this either # from music21 import roman Can't import roman because of circular @@ -2406,6 +2409,42 @@ def bassInterval(self): return interval.notesToChromatic(self.chordList[0].bass(), self.chordList[1].bass()) +def iterateAllVoiceLeadingQuartets( + s: stream.Stream, + *, + includeRests: bool = True, + includeOblique: bool = True, + includeNoMotion: bool = False, + reverse: bool = False, +) -> Generator[VoiceLeadingQuartet, None, None]: + ''' + Iterate through all VoiceLeading quartets in a Stream (generally a Score), + yielding a generator of VoiceLeadingQuartets. N.B. does not yet support Streams with + Chords in them. + + >>> b = corpus.parse('bwv66.6') + >>> for vlq in voiceLeading.iterateAllVoiceLeadingQuartets(b): + ... print(vlq.v1n1.measureNumber, + ... vlq.v1n1.getContextByClass(stream.Part).id, + ... vlq.v2n1.getContextByClass(stream.Part).id, + ... vlq) + 0 Soprano Tenor + 0 Soprano Bass + 0 Tenor Bass + 1 Soprano Alto + 1 Soprano Tenor + 1 Soprano Bass + 1 Alto Tenor + 1 Alto Bass + ... + ''' + for v in s.asTimespans().iterateVerticalities(reverse=reverse): + yield from v.getAllVoiceLeadingQuartets( + includeRests=includeRests, + includeOblique=includeOblique, + includeNoMotion=includeNoMotion + ) + # ------------------------------------------------------------------------------ class Test(unittest.TestCase):