Source code for music21.figuredBass.checker

# ------------------------------------------------------------------------------
# Name:         checker.py
# Purpose:      checks figured basses for following voice-leading rules
# Authors:      Jose Cabal-Ugaz
#
# Copyright:    Copyright © 2012 Michael Scott Asato Cuthbert
# License:      BSD, see license.txt
# ------------------------------------------------------------------------------
from __future__ import annotations

import collections
import copy
import typing as t
import unittest

from music21 import note
from music21 import pitch
from music21 import stream
from music21 import voiceLeading
from music21.common.numberTools import opFrac
from music21.common.types import OffsetQL
from music21.figuredBass import possibility
from music21.exceptions21 import Music21Exception

if t.TYPE_CHECKING:
    from collections.abc import Callable

# A vertical sonority observed from a score for voice-leading checking: one
# element per part, each a Pitch or the rest placeholder 'RT' returned by
# generalNoteToPitch.  Distinct from possibility.Possibility, which is pitch-only
# and represents a candidate realization rather than an observed sonority.
type PossibilityWithRests = tuple[pitch.Pitch | t.Literal['RT'], ...]
# (offset, endTime) delimiting a voice-leading moment.
type OffsetEndTime = tuple[OffsetQL, OffsetQL]
# (partNumberA, partNumberB) one-indexed voice pair forming a violation.
type PartPair = tuple[int, int]


# ------------------------------------------------------------------------------
# Parsing scores into voice leading moments (a.k.a. harmonies)


[docs] def getVoiceLeadingMoments(music21Stream: stream.Stream) -> stream.Score: ''' Takes in a :class:`~music21.stream.Stream` and returns a :class:`~music21.stream.Score` of the :class:`~music21.stream.Stream` broken up into its voice leading moments. >>> #_DOCS_SHOW score = corpus.parse('corelli/opus3no1/1grave').measures(1, 3) >>> #_DOCS_SHOW score.show() .. image:: images/figuredBass/corelli_grave.* :width: 700 >>> from music21.figuredBass import checker >>> score = corpus.parse('bwv66.6') #_DOCS_HIDE >>> vlMoments = checker.getVoiceLeadingMoments(score) >>> #_DOCS_SHOW vlMoments.show() .. image:: images/figuredBass/corelli_vlm.* :width: 700 ''' allHarmonies = extractHarmonies(music21Stream) allParts: stream.Stream[stream.Part] = music21Stream.getElementsByClass(stream.Part).stream() newParts: list[stream.Stream] = [ allParts[i].flatten().getElementsNotOfClass('GeneralNote').stream() for i in range(len(allParts)) ] firstMeasure = t.cast('stream.Measure', allParts[0].getElementsByClass(stream.Measure).first()) paddingLeft = firstMeasure.paddingLeft for (offsets, notes) in sorted(allHarmonies.items()): (initOffset, endTime) = offsets for genNoteIndex in range(len(notes)): music21GeneralNote = notes[genNoteIndex] newGeneralNote = copy.deepcopy(music21GeneralNote) newGeneralNote.quarterLength = endTime - initOffset newGeneralNote.tie = None newParts[genNoteIndex].insert(initOffset + paddingLeft, newGeneralNote) for givenPart in newParts: givenPart.makeNotation(inPlace=True, cautionaryNotImmediateRepeat=False) if paddingLeft != 0.0: givenPart[0].padAsAnacrusis() for m in givenPart: m.number -= 1 newScore = stream.Score(newParts) return newScore
[docs] def extractHarmonies( music21Stream: stream.Stream ) -> dict[OffsetEndTime, list[note.GeneralNote]]: # noinspection PyShadowingNames ''' Takes in a :class:`~music21.stream.Stream` and returns a dictionary whose values are the voice leading moments of the :class:`~music21.stream.Stream` and whose keys are (offset, endTime) pairs delimiting their duration. The voice leading moments are spelled out from the first or highest :class:`~music21.stream.Part` to the lowest one. >>> score = corpus.parse('corelli/opus3no1/1grave').measures(1, 3) >>> #_DOCS_SHOW score.show() .. image:: images/figuredBass/corelli_grave.* :width: 700 >>> from music21.figuredBass import checker >>> allHarmonies = checker.extractHarmonies(score) >>> for (offsets, notes) in sorted(allHarmonies.items()): ... print(f'{offsets!s:15}[{notes[0]!s:23}{notes[1]!s:23}{notes[2]!s:22}]') (0.0, 1.5) [<music21.note.Note C> <music21.note.Note A> <music21.note.Note F> ] (1.5, 2.0) [<music21.note.Note C> <music21.note.Note A> <music21.note.Note F> ] (2.0, 3.0) [<music21.note.Note B-> <music21.note.Note G> <music21.note.Note G> ] (3.0, 3.5) [<music21.note.Note A> <music21.note.Note F> <music21.note.Note A> ] (3.5, 4.0) [<music21.note.Note A> <music21.note.Note F> <music21.note.Note B->] (4.0, 6.0) [<music21.note.Note G> <music21.note.Note E> <music21.note.Note C> ] (6.0, 6.5) [<music21.note.Note A> <music21.note.Note F> <music21.note.Note A> ] (6.5, 7.0) [<music21.note.Note B-> <music21.note.Note F> <music21.note.Note A> ] (7.0, 7.5) [<music21.note.Note C> <music21.note.Note F> <music21.note.Note A> ] (7.5, 8.0) [<music21.note.Note C> <music21.note.Note E> <music21.note.Note A> ] (8.0, 8.5) [<music21.note.Note C> <music21.note.Note D> <music21.note.Note B->] (8.5, 9.0) [<music21.note.Note F> <music21.note.Note D> <music21.note.Note B->] (9.0, 9.5) [<music21.note.Note B-> <music21.note.Note D> <music21.note.Note B->] (9.5, 10.0) [<music21.note.Note B-> <music21.note.Note G> <music21.note.Note B->] (10.0, 10.5) [<music21.note.Note B-> <music21.note.Note E> <music21.note.Note C> ] (10.5, 11.0) [<music21.note.Note B-> <music21.note.Note C> <music21.note.Note C> ] (11.0, 11.5) [<music21.note.Note A> <music21.note.Note F> <music21.note.Note D> ] (11.5, 12.0) [<music21.note.Note A> <music21.note.Note F> <music21.note.Note A> ] ''' allParts = music21Stream.getElementsByClass(stream.Part) if len(allParts) < 2: raise Music21Exception('There must be at least two parts to extract harmonies') allHarmonies = createOffsetMapping(allParts[0]) for music21Part in allParts[1:]: allHarmonies = correlateHarmonies(allHarmonies, music21Part) return allHarmonies
[docs] def createOffsetMapping( music21Part: stream.Part ) -> dict[OffsetEndTime, list[note.GeneralNote]]: ''' Creates an initial offset mapping of a :class:`~music21.stream.Part`. >>> from music21.figuredBass import checker >>> score = corpus.parse('corelli/opus3no1/1grave').measures(1, 3) >>> v0 = score[0] >>> offsetMapping = checker.createOffsetMapping(v0) >>> for (offsets, notes) in sorted(offsetMapping.items()): ... print(f'{offsets!s:15}[{notes[0]!s:22}]') (0.0, 1.5) [<music21.note.Note C> ] (1.5, 2.0) [<music21.note.Note C> ] (2.0, 3.0) [<music21.note.Note B->] (3.0, 4.0) [<music21.note.Note A> ] (4.0, 6.0) [<music21.note.Note G> ] (6.0, 6.5) [<music21.note.Note A> ] (6.5, 7.0) [<music21.note.Note B->] (7.0, 8.0) [<music21.note.Note C> ] (8.0, 8.5) [<music21.note.Note C> ] (8.5, 9.0) [<music21.note.Note F> ] (9.0, 11.0) [<music21.note.Note B->] (11.0, 12.0) [<music21.note.Note A> ] ''' currentMapping: dict[OffsetEndTime, list[note.GeneralNote]] = collections.defaultdict(list) for music21GeneralNote in music21Part.flatten().notesAndRests: initOffset = music21GeneralNote.offset endTime = initOffset + music21GeneralNote.quarterLength currentMapping[(initOffset, endTime)].append(music21GeneralNote) return currentMapping
[docs] def correlateHarmonies( currentMapping: dict[OffsetEndTime, list[note.GeneralNote]], music21Part: stream.Part ) -> dict[OffsetEndTime, list[note.GeneralNote]]: # noinspection PyShadowingNames ''' Adds a new :class:`~music21.stream.Part` to an existing offset mapping. >>> from music21.figuredBass import checker >>> score = corpus.parse('corelli/opus3no1/1grave').measures(1, 3) >>> v0 = score[0] >>> offsetMapping = checker.createOffsetMapping(v0) >>> v1 = score[1] >>> newMapping = checker.correlateHarmonies(offsetMapping, v1) >>> for (offsets, notes) in sorted(newMapping.items()): ... print(f'{offsets!s:15}[{notes[0]!s:23}{notes[1]!s:21}]') (0.0, 1.5) [<music21.note.Note C> <music21.note.Note A>] (1.5, 2.0) [<music21.note.Note C> <music21.note.Note A>] (2.0, 3.0) [<music21.note.Note B-> <music21.note.Note G>] (3.0, 4.0) [<music21.note.Note A> <music21.note.Note F>] (4.0, 6.0) [<music21.note.Note G> <music21.note.Note E>] (6.0, 6.5) [<music21.note.Note A> <music21.note.Note F>] (6.5, 7.0) [<music21.note.Note B-> <music21.note.Note F>] (7.0, 7.5) [<music21.note.Note C> <music21.note.Note F>] (7.5, 8.0) [<music21.note.Note C> <music21.note.Note E>] (8.0, 8.5) [<music21.note.Note C> <music21.note.Note D>] (8.5, 9.0) [<music21.note.Note F> <music21.note.Note D>] (9.0, 9.5) [<music21.note.Note B-> <music21.note.Note D>] (9.5, 10.0) [<music21.note.Note B-> <music21.note.Note G>] (10.0, 10.5) [<music21.note.Note B-> <music21.note.Note E>] (10.5, 11.0) [<music21.note.Note B-> <music21.note.Note C>] (11.0, 12.0) [<music21.note.Note A> <music21.note.Note F>] ''' newMapping: dict[OffsetEndTime, list[note.GeneralNote]] = {} for offsets in sorted(currentMapping.keys()): (initOffset, endTime) = offsets notesInRange = music21Part.flatten().notesAndRests.getElementsByOffset( initOffset, offsetEnd=endTime, includeEndBoundary=False, mustFinishInSpan=False, mustBeginInSpan=False, includeElementsThatEndAtStart=False) allNotesSoFar = currentMapping[offsets] for music21GeneralNote in notesInRange: newInitOffset = initOffset newEndTime = endTime if not music21GeneralNote.offset < initOffset: newInitOffset = music21GeneralNote.offset if not music21GeneralNote.offset + music21GeneralNote.quarterLength > endTime: newEndTime = opFrac(music21GeneralNote.offset + music21GeneralNote.quarterLength) allNotesCopy = copy.copy(allNotesSoFar) allNotesCopy.append(music21GeneralNote) newMapping[(newInitOffset, newEndTime)] = allNotesCopy return newMapping
# ------------------------------------------------------------------------------ # Generic functions for checking for composition rule violations in streams
[docs] def checkSinglePossibilities( music21Stream: stream.Score, functionToApply: Callable[[PossibilityWithRests], list[PartPair]], color: str | None = '#FF0000', debug: bool = False ) -> None: # noinspection PyShadowingNames ''' Takes in a :class:`~music21.stream.Score` and a functionToApply which takes in a possibility instance, a tuple with pitches or rests comprising a vertical sonority. Changes the color of notes in the :class:`~music21.stream.Score` which comprise rule violations as determined by functionToApply. .. note:: Colored notes are NOT supported in Finale. >>> music21Stream = corpus.parse('corelli/opus3no1/1grave').measures(1, 6) >>> #_DOCS_SHOW music21Stream.show() .. image:: images/figuredBass/corelli_grave2.* :width: 700 >>> from music21.figuredBass import checker >>> functionToApply = checker.voiceCrossing >>> checker.checkSinglePossibilities(music21Stream, functionToApply, debug=True) Function To Apply: voiceCrossing (Offset, End Time): Part Numbers: (16.0, 16.5) (1, 2) (16.5, 17.0) (1, 2) Voice Crossing is present in the fifth measure between the first and second voices, and the notes in question are highlighted in the music21Stream. >>> #_DOCS_SHOW music21Stream.show() .. image:: images/figuredBass/corelli_voiceCrossing.* :width: 700 ''' debugInfo: list[str] = [] if debug: debugInfo.append('Function To Apply: ' + functionToApply.__name__) debugInfo.append(f"{'(Offset, End Time):'!s:25}Part Numbers:") allHarmonies = sorted(list(extractHarmonies(music21Stream).items())) allParts = [p.flatten() for p in music21Stream.getElementsByClass(stream.Part)] for (offsets, notes) in allHarmonies: vlm = tuple(generalNoteToPitch(n) for n in notes) vlm_violations = functionToApply(vlm) initOffset = offsets[0] for partNumberTuple in vlm_violations: for partNumber in partNumberTuple: if color is not None: noteA = allParts[partNumber - 1].getElementsByOffset( initOffset, initOffset, mustBeginInSpan=False)[0] noteA.style.color = color if debug: debugInfo.append(f'{offsets!s:25}{partNumberTuple!s}') if debug: if len(debugInfo) == 2: debugInfo.append('No violations to report.') for lineInfo in debugInfo: print(lineInfo)
[docs] def checkConsecutivePossibilities( music21Stream: stream.Score, functionToApply: Callable[[PossibilityWithRests, PossibilityWithRests], list[PartPair]], color: str | None = '#FF0000', debug: bool = False ) -> None: # noinspection PyShadowingNames ''' Takes in a :class:`~music21.stream.Score` and a functionToApply which takes in two consecutive possibility instances, each a tuple with pitches or rests comprising a vertical sonority. Changes the color of notes in the :class:`~music21.stream.Score` which comprise rule violations as determined by functionToApply. .. note:: Colored notes are NOT supported in Finale. >>> music21Stream = corpus.parse('theoryExercises/checker_demo.xml') >>> #_DOCS_SHOW music21Stream.show() .. image:: images/figuredBass/checker_demo.* :width: 700 >>> from music21.figuredBass import checker >>> functionToApply = checker.parallelOctaves >>> checker.checkConsecutivePossibilities(music21Stream, functionToApply, debug=True) Function To Apply: parallelOctaves (Offset A, End Time A): (Offset B, End Time B): Part Numbers: (1.0, 2.0) (2.0, 3.0) (2, 4) (2.0, 3.0) (3.0, 5.0) (2, 4) (8.0, 9.0) (9.0, 11.0) (1, 3) Parallel octaves can be found in the first measure, between the first two measures, and between the third and the fourth measure. The notes in question are highlighted in the music21Stream, as shown below. >>> #_DOCS_SHOW music21Stream.show() .. image:: images/figuredBass/checker_parallelOctaves.* :width: 700 ''' debugInfo: list[str] = [] if debug: debugInfo.append('Function To Apply: ' + functionToApply.__name__) debugInfo.append('(Offset A, End Time A): (Offset B, End Time B): Part Numbers:') allHarmonies = sorted(extractHarmonies(music21Stream).items()) allParts = [p.flatten() for p in music21Stream.getElementsByClass(stream.Part)] (previousOffsets, previousNotes) = allHarmonies[0] vlmA = tuple(generalNoteToPitch(n) for n in previousNotes) initOffsetA = previousOffsets[0] for (offsets, notes) in allHarmonies[1:]: vlmB = tuple(generalNoteToPitch(n) for n in notes) initOffsetB = offsets[0] vlm_violations = functionToApply(vlmA, vlmB) for partNumberTuple in vlm_violations: for partNumber in partNumberTuple: if color is not None: noteA = allParts[partNumber - 1].getElementsByOffset( initOffsetA, initOffsetA, mustBeginInSpan=False).first() noteB = allParts[partNumber - 1].getElementsByOffset( initOffsetB, initOffsetB, mustBeginInSpan=False).first() if noteA is None or noteB is None: raise Music21Exception( 'Expected notes to color at the violation offset, but found none') noteA.style.color = color noteB.style.color = color if debug: debugInfo.append(f'{previousOffsets!s:25}{offsets!s:25}{partNumberTuple!s}') # Current vlm becomes previous previousOffsets = offsets vlmA = vlmB initOffsetA = initOffsetB if debug: if len(debugInfo) == 2: debugInfo.append('No violations to report.') for lineInfo in debugInfo: print(lineInfo)
# ------------------------------------------------------------------------------ # Single Possibility Rule-Checking Methods # Takes in a possibility, returns (partNumberA, partNumberB) which # represent two voices which form a voice crossing.
[docs] def voiceCrossing(possibA: PossibilityWithRests) -> list[PartPair]: ''' Returns a list of (partNumberA, partNumberB) pairs, each representing two voices which form a voice crossing. The parts from the lowest part to the highest part (right to left) must correspond to increasingly higher pitches in order for there to be no voice crossing. Comparisons between pitches are done using pitch comparison methods, which are based on pitch space values (see :class:`~music21.pitch.Pitch`). >>> from music21.figuredBass import checker >>> C4 = pitch.Pitch('C4') >>> E4 = pitch.Pitch('E4') >>> C5 = pitch.Pitch('C5') >>> G5 = pitch.Pitch('G5') >>> possibA1 = (C5, G5, E4) >>> checker.voiceCrossing(possibA1) # G5 > C5 [(1, 2)] >>> possibA2 = (C5, E4, C4) >>> checker.voiceCrossing(possibA2) [] ''' partViolations: list[PartPair] = [] for part1Index in range(len(possibA)): higherPitch = possibA[part1Index] if not isinstance(higherPitch, pitch.Pitch): continue for part2Index in range(part1Index + 1, len(possibA)): lowerPitch = possibA[part2Index] if not isinstance(lowerPitch, pitch.Pitch): continue if higherPitch < lowerPitch: partViolations.append((part1Index + 1, part2Index + 1)) return partViolations
# ------------------------------------------------------------------------------ # Consecutive Possibility Rule-Checking Methods # The speedup tables are shared with figuredBass.possibility: both modules feed the # same VoiceLeadingQuartet (in the same pitch order) into the same checks, so a quartet # computed by one module is valid for the other. See possibility.py for the definitions.
[docs] def parallelFifths(possibA: PossibilityWithRests, possibB: PossibilityWithRests) -> list[PartPair]: ''' Returns a list of (partNumberA, partNumberB) pairs, each representing two voices which form parallel fifths. If pitchA1 and pitchA2 in possibA are separated by a simple interval of a perfect fifth, and they move to a pitchB1 and pitchB2 in possibB also separated by the simple interval of a perfect fifth, then this constitutes parallel fifths between these two parts. >>> from music21.figuredBass import checker >>> C3 = pitch.Pitch('C3') >>> D3 = pitch.Pitch('D3') >>> G3 = pitch.Pitch('G3') >>> A3 = pitch.Pitch('A3') >>> A4 = pitch.Pitch('A4') >>> B4 = pitch.Pitch('B4') Here, the bass moves from C3 to D3 and the tenor moves from G3 to A3. The interval between C3 and G3, as well as between D3 and A3, is a perfect fifth. These two parts, and therefore the two possibilities, have parallel fifths. >>> possibA1 = (B4, G3, C3) >>> possibB1 = (A4, A3, D3) >>> checker.parallelFifths(possibA1, possibB1) [(2, 3)] Now, the tenor moves instead to F3. The interval between D3 and F3 is a minor third. The bass and tenor parts don't form parallel fifths. The soprano part forms parallel fifths with neither the bass nor tenor parts. The two possibilities, therefore, have no parallel fifths. >>> F3 = pitch.Pitch('F3') >>> possibA2 = (B4, G3, C3) >>> possibB2 = (A4, F3, D3) >>> checker.parallelFifths(possibA2, possibB2) [] ''' partViolations: list[PartPair] = [] pairsList = list(zip(possibA, possibB, strict=True)) for pair1Index in range(len(pairsList)): (higherPitchA, higherPitchB) = pairsList[pair1Index] if not isinstance(higherPitchA, pitch.Pitch): continue if not isinstance(higherPitchB, pitch.Pitch): continue for pair2Index in range(pair1Index + 1, len(pairsList)): (lowerPitchA, lowerPitchB) = pairsList[pair2Index] if not isinstance(lowerPitchA, pitch.Pitch): continue if not isinstance(lowerPitchB, pitch.Pitch): continue if not abs(higherPitchA.ps - lowerPitchA.ps) % 12 == 7: continue if not abs(higherPitchB.ps - lowerPitchB.ps) % 12 == 7: continue # Very high probability of ||5, but still not certain. pitchQuartet = (lowerPitchA, lowerPitchB, higherPitchA, higherPitchB) if pitchQuartet in possibility.parallelFifthsTable: hasParallelFifths = possibility.parallelFifthsTable[pitchQuartet] else: vlq = voiceLeading.VoiceLeadingQuartet(*pitchQuartet) hasParallelFifths = vlq.parallelFifth() possibility.parallelFifthsTable[pitchQuartet] = hasParallelFifths if hasParallelFifths: partViolations.append((pair1Index + 1, pair2Index + 1)) return partViolations
[docs] def hiddenFifths(possibA: PossibilityWithRests, possibB: PossibilityWithRests) -> list[PartPair]: ''' Returns a list with a (highestPart, lowestPart) pair which represents a hidden fifth between shared outer parts of possibA and possibB. The outer parts here are the first and last elements of each possibility. If sopranoPitchA and bassPitchA in possibA move to a sopranoPitchB and bassPitchB in possibB in similar motion, and the simple interval between sopranoPitchB and bassPitchB is that of a perfect fifth, then this constitutes a hidden octave between the two possibilities. >>> from music21.figuredBass import checker >>> C3 = pitch.Pitch('C3') >>> D3 = pitch.Pitch('D3') >>> E3 = pitch.Pitch('E3') >>> F3 = pitch.Pitch('F3') >>> E5 = pitch.Pitch('E5') >>> A5 = pitch.Pitch('A5') Here, the bass part moves up from C3 to D3 and the soprano part moves up from E5 to A5. The simple interval between D3 and A5 is a perfect fifth. Therefore, there is a hidden fifth between the two possibilities. >>> possibA1 = (E5, E3, C3) >>> possibB1 = (A5, F3, D3) >>> checker.hiddenFifths(possibA1, possibB1) [(1, 3)] Here, the soprano and bass parts also move in similar motion, but the simple interval between D3 and Ab5 is a diminished fifth. Consequently, there is no hidden fifth. >>> Ab5 = pitch.Pitch('A-5') >>> possibA2 = (E5, E3, C3) >>> possibB2 = (Ab5, F3, D3) >>> checker.hiddenFifths(possibA2, possibB2) [] Now, we have the soprano and bass parts again moving to A5 and D3, whose simple interval is a perfect fifth. However, the bass moves up while the soprano moves down. Therefore, there is no hidden fifth. >>> E6 = pitch.Pitch('E6') >>> possibA3 = (E6, E3, C3) >>> possibB3 = (A5, F3, D3) >>> checker.hiddenFifths(possibA3, possibB3) [] * Changed in v11: renamed from hiddenFifth (singular) to match parallelFifths. ''' partViolations: list[PartPair] = [] pairsList = list(zip(possibA, possibB, strict=True)) (highestPitchA, highestPitchB) = pairsList[0] (lowestPitchA, lowestPitchB) = pairsList[-1] if not isinstance(highestPitchA, pitch.Pitch): return partViolations if not isinstance(highestPitchB, pitch.Pitch): return partViolations if not isinstance(lowestPitchA, pitch.Pitch): return partViolations if not isinstance(lowestPitchB, pitch.Pitch): return partViolations if abs(highestPitchB.ps - lowestPitchB.ps) % 12 == 7: # Very high probability of hidden fifth, but still not certain. pitchQuartet = (lowestPitchA, lowestPitchB, highestPitchA, highestPitchB) if pitchQuartet in possibility.hiddenFifthsTable: hasHiddenFifth = possibility.hiddenFifthsTable[pitchQuartet] else: vlq = voiceLeading.VoiceLeadingQuartet(*pitchQuartet) hasHiddenFifth = vlq.hiddenFifth() possibility.hiddenFifthsTable[pitchQuartet] = hasHiddenFifth if hasHiddenFifth: partViolations.append((1, len(possibB))) return partViolations
[docs] def parallelOctaves(possibA: PossibilityWithRests, possibB: PossibilityWithRests) -> list[PartPair]: ''' Returns a list of (partNumberA, partNumberB) pairs, each representing two voices which form parallel octaves. If pitchA1 and pitchA2 in possibA are separated by a simple interval of a perfect octave, and they move to a pitchB1 and pitchB2 in possibB also separated by the simple interval of a perfect octave, then this constitutes parallel octaves between these two parts. >>> from music21.figuredBass import checker >>> C3 = pitch.Pitch('C3') >>> D3 = pitch.Pitch('D3') >>> G3 = pitch.Pitch('G3') >>> A3 = pitch.Pitch('A3') >>> C4 = pitch.Pitch('C4') >>> D4 = pitch.Pitch('D4') Here, the soprano moves from C4 to D4 and the bass moves from C3 to D3. The interval between C3 and C4, as well as between D3 and D4, is a parallel octave. The two parts, and therefore the two possibilities, have parallel octaves. >>> possibA1 = (C4, G3, C3) >>> possibB1 = (D4, A3, D3) >>> checker.parallelOctaves(possibA1, possibB1) [(1, 3)] Now, the soprano moves down to B3. The interval between D3 and B3 is a major sixth. The soprano and bass parts no longer have parallel octaves. The tenor part forms a parallel octave with neither the bass nor soprano, so the two possibilities do not have parallel octaves. (Notice, however, the parallel fifth between the bass and tenor!) >>> B3 = pitch.Pitch('B3') >>> possibA2 = (C4, G3, C3) >>> possibB2 = (B3, A3, D3) >>> checker.parallelOctaves(possibA2, possibB2) [] ''' partViolations: list[PartPair] = [] pairsList = list(zip(possibA, possibB, strict=True)) for pair1Index in range(len(pairsList)): (higherPitchA, higherPitchB) = pairsList[pair1Index] if not isinstance(higherPitchA, pitch.Pitch): continue if not isinstance(higherPitchB, pitch.Pitch): continue for pair2Index in range(pair1Index + 1, len(pairsList)): (lowerPitchA, lowerPitchB) = pairsList[pair2Index] if not isinstance(lowerPitchA, pitch.Pitch): continue if not isinstance(lowerPitchB, pitch.Pitch): continue if not abs(higherPitchA.ps - lowerPitchA.ps) % 12 == 0: continue if not abs(higherPitchB.ps - lowerPitchB.ps) % 12 == 0: continue # Very high probability of ||8, but still not certain. pitchQuartet = (lowerPitchA, lowerPitchB, higherPitchA, higherPitchB) if pitchQuartet in possibility.parallelOctavesTable: hasParallelOctaves = possibility.parallelOctavesTable[pitchQuartet] else: vlq = voiceLeading.VoiceLeadingQuartet(*pitchQuartet) hasParallelOctaves = vlq.parallelOctave() possibility.parallelOctavesTable[pitchQuartet] = hasParallelOctaves if hasParallelOctaves: partViolations.append((pair1Index + 1, pair2Index + 1)) return partViolations
[docs] def hiddenOctaves(possibA: PossibilityWithRests, possibB: PossibilityWithRests) -> list[PartPair]: ''' Returns a list with a (highestPart, lowestPart) pair which represents a hidden octave between shared outer parts of possibA and possibB. The outer parts here are the first and last elements of each possibility. If sopranoPitchA and bassPitchA in possibA move to a sopranoPitchB and bassPitchB in possibB in similar motion, and the simple interval between sopranoPitchB and bassPitchB is that of a perfect octave, then this constitutes a hidden octave between the two possibilities. >>> from music21.figuredBass import checker >>> C3 = pitch.Pitch('C3') >>> D3 = pitch.Pitch('D3') >>> E3 = pitch.Pitch('E3') >>> F3 = pitch.Pitch('F3') >>> A5 = pitch.Pitch('A5') >>> D6 = pitch.Pitch('D6') Here, the bass part moves up from C3 to D3 and the soprano part moves up from A5 to D6. The simple interval between D3 and D6 is a perfect octave. Therefore, there is a hidden octave between the two possibilities. >>> possibA1 = (A5, E3, C3) >>> possibB1 = (D6, F3, D3) # Perfect octave between soprano and bass. >>> checker.hiddenOctaves(possibA1, possibB1) [(1, 3)] Here, the bass part moves up from C3 to D3 but the soprano part moves down from A6 to D6. There is no hidden octave since the parts move in contrary motion. >>> A6 = pitch.Pitch('A6') >>> possibA2 = (A6, E3, C3) >>> possibB2 = (D6, F3, D3) >>> checker.hiddenOctaves(possibA2, possibB2) [] * Changed in v11: renamed from hiddenOctave (singular) to match parallelOctaves. ''' partViolations: list[PartPair] = [] pairsList = list(zip(possibA, possibB, strict=True)) (highestPitchA, highestPitchB) = pairsList[0] (lowestPitchA, lowestPitchB) = pairsList[-1] if not isinstance(highestPitchA, pitch.Pitch): return partViolations if not isinstance(highestPitchB, pitch.Pitch): return partViolations if not isinstance(lowestPitchA, pitch.Pitch): return partViolations if not isinstance(lowestPitchB, pitch.Pitch): return partViolations if abs(highestPitchB.ps - lowestPitchB.ps) % 12 == 0: # Very high probability of hidden octave, but still not certain. pitchQuartet = (lowestPitchA, lowestPitchB, highestPitchA, highestPitchB) if pitchQuartet in possibility.hiddenOctavesTable: hasHiddenOctave = possibility.hiddenOctavesTable[pitchQuartet] else: vlq = voiceLeading.VoiceLeadingQuartet(*pitchQuartet) hasHiddenOctave = vlq.hiddenOctave() possibility.hiddenOctavesTable[pitchQuartet] = hasHiddenOctave if hasHiddenOctave: partViolations.append((1, len(possibB))) return partViolations
# ----------------------------------------------------------------------------- # Helper Methods
[docs] def generalNoteToPitch(music21GeneralNote: note.GeneralNote) -> pitch.Pitch | t.Literal['RT']: ''' Takes a :class:`~music21.note.GeneralNote`. If it is a :class:`~music21.note.Note`, returns its pitch. Otherwise, returns the string "RT", a rest placeholder. >>> n1 = note.Note('G5') >>> c1 = chord.Chord(['C3', 'E3', 'G3']) >>> figuredBass.checker.generalNoteToPitch(n1) <music21.pitch.Pitch G5> >>> figuredBass.checker.generalNoteToPitch(c1) 'RT' ''' if isinstance(music21GeneralNote, note.Note): return music21GeneralNote.pitch else: return 'RT'
_DOC_ORDER = [extractHarmonies, getVoiceLeadingMoments, checkConsecutivePossibilities, checkSinglePossibilities] # ----------------------------------------------------------------------------- class Test(unittest.TestCase): def testParallelFifthsCachePopulatesAndIsShared(self): ''' The speedup table actually caches (it formerly stored False on every call and never hit), and the table is the one shared with figuredBass.possibility, so a quartet computed in one module is reused by the other. ''' # Bass C3->D3 and tenor G3->A3 form a perfect fifth moving in parallel. possibA = (pitch.Pitch('B4'), pitch.Pitch('G3'), pitch.Pitch('C3')) possibB = (pitch.Pitch('A4'), pitch.Pitch('A3'), pitch.Pitch('D3')) possibility.parallelFifthsTable.clear() self.assertEqual(parallelFifths(possibA, possibB), [(2, 3)]) # The quartet was stored as True, not recomputed-and-overwritten-as-False. self.assertEqual(len(possibility.parallelFifthsTable), 1) self.assertEqual(list(possibility.parallelFifthsTable.values()), [True]) # checker and possibility share one table: value-equal pitches (new objects) # hit the cached entry rather than adding a second one. possibA2 = (pitch.Pitch('B4'), pitch.Pitch('G3'), pitch.Pitch('C3')) possibB2 = (pitch.Pitch('A4'), pitch.Pitch('A3'), pitch.Pitch('D3')) self.assertTrue(possibility.parallelFifths(possibA2, possibB2)) self.assertEqual(len(possibility.parallelFifthsTable), 1) if __name__ == '__main__': import music21 music21.mainTest(Test)