Skip to content

Commit

Permalink
iterateAllVoiceLeadingQuartets()
Browse files Browse the repository at this point in the history
Add safety to getAllVoiceleading quartets

add some typing.
  • Loading branch information
mscuthbert committed Apr 2, 2024
1 parent dbd8c9f commit ac92e57
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 46 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ __pycache__/
.idea/vcs.xml
.idea/workspace.xml
.idea/markdown-*.xml
.idea/copilot

# OSX
.DS_Store
Expand Down
2 changes: 1 addition & 1 deletion music21/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion music21/note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ()
Expand Down
20 changes: 14 additions & 6 deletions music21/tree/timespanTree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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,))
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
Expand Down
135 changes: 98 additions & 37 deletions music21/tree/verticality.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import copy
import itertools
import typing as t
from typing import overload
import unittest

from music21 import chord
Expand All @@ -32,11 +33,18 @@
# 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.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
Expand Down Expand Up @@ -202,7 +210,7 @@ class Verticality(prebase.ProtoM21Object):

def __init__(
self,
offset=None,
offset: OffsetQL | None = None,
overlapTimespans=(),
startTimespans=(),
stopTimespans=(),
Expand All @@ -213,21 +221,22 @@ def __init__(
raise VerticalityException(
f'timespanTree {timespanTree!r} is not a OffsetTree or None')

self.timespanTree = timespanTree
self.offset = offset
self.timespanTree: trees.OffsetTree | None = timespanTree
self.offset: OffsetQL | None = 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 #

Expand Down Expand Up @@ -933,23 +942,50 @@ 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: bool = 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,
... classList=(note.Note, chord.Chord))
>>> verticality22 = tsCol.getVerticalityAt(22.0)
>>> from pprint import pprint as pp
>>> from pprint import pprint
>>> for vlq in verticality22.getAllVoiceLeadingQuartets():
... pp(vlq)
<music21.voiceLeading.VoiceLeadingQuartet
Expand All @@ -971,7 +1007,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)
Expand Down Expand Up @@ -1003,27 +1039,33 @@ def getAllVoiceLeadingQuartets(
* Changed in v8: all parameters are keyword only.
'''
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
Expand All @@ -1034,23 +1076,36 @@ 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
n11 = pairedMotion[0][0].element
n12 = pairedMotion[0][1].element
n21 = pairedMotion[1][0].element
n22 = pairedMotion[1][1].element

if (n11 is not None
and n12 is not None
and n21 is not None
and n22 is not None):
if t.TYPE_CHECKING:
assert isinstance(n11, note.Note)
assert isinstance(n12, note.Note)
assert isinstance(n21, note.Note)
assert 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??]
Expand Down Expand Up @@ -1097,16 +1152,20 @@ def getPairedMotion(self, includeRests=True, includeOblique=True):
... print(pm)
(<PitchedTimespan (21.0 to 22.0) <music21.note.Note E>>,
<PitchedTimespan (22.0 to 23.0) <music21.note.Note F>>)
Changed in v9.3 -- arguments are keyword only
'''
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 not isinstance(previousTs, spans.PitchedTimespan):
continue # first not in piece in this part...

if includeRests is False:
Expand All @@ -1119,6 +1178,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)

Expand Down
39 changes: 38 additions & 1 deletion music21/voiceLeading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +35,7 @@
'''
from __future__ import annotations

from collections.abc import Generator
import enum
import typing as t
import unittest
Expand All @@ -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
Expand Down Expand Up @@ -2406,6 +2409,40 @@ 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 quartets
>>> 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 <music21.voiceLeading.VoiceLeadingQuartet v1n1=B4, v1n2=A4, v2n1=B3, v2n2=C#4>
0 Soprano Bass <music21.voiceLeading.VoiceLeadingQuartet v1n1=B4, v1n2=A4, v2n1=G#3, v2n2=F#3>
0 Tenor Bass <music21.voiceLeading.VoiceLeadingQuartet v1n1=B3, v1n2=C#4, v2n1=G#3, v2n2=F#3>
1 Soprano Alto <music21.voiceLeading.VoiceLeadingQuartet v1n1=A4, v1n2=B4, v2n1=F#4, v2n2=E4>
1 Soprano Tenor <music21.voiceLeading.VoiceLeadingQuartet v1n1=A4, v1n2=B4, v2n1=C#4, v2n2=B3>
1 Soprano Bass <music21.voiceLeading.VoiceLeadingQuartet v1n1=A4, v1n2=B4, v2n1=F#3, v2n2=G#3>
1 Alto Tenor <music21.voiceLeading.VoiceLeadingQuartet v1n1=F#4, v1n2=E4, v2n1=C#4, v2n2=B3>
1 Alto Bass <music21.voiceLeading.VoiceLeadingQuartet v1n1=F#4, v1n2=E4, v2n1=F#3, v2n2=G#3>
...
'''
for v in s.asTimespans().iterateVerticalities(reverse=reverse):
for vlq in v.getAllVoiceLeadingQuartets(
includeRests=includeRests, includeOblique=includeOblique, includeNoMotion=includeNoMotion
):
yield vlq

# ------------------------------------------------------------------------------

class Test(unittest.TestCase):
Expand Down

0 comments on commit ac92e57

Please sign in to comment.