Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 165 additions & 41 deletions music21/scale/intervalNetwork.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from collections import OrderedDict
from collections.abc import Sequence
import copy
import dataclasses
import enum
import typing as t

Expand Down Expand Up @@ -83,6 +84,34 @@ def __str__(self) -> str:
int|Terminus, str, str|None, str|None, bool, bool|None,
tuple[tuple[int, Direction, int, int|float], ...]]

# node id, start pitch, and (degree, direction, generic, semitones) per altered degree
type NetworkWalkKey = tuple[
Terminus|int,
str,
tuple[tuple[int, Direction, int, int|float], ...],
]


@dataclasses.dataclass
class NetworkWalkStep:
'''
One pitch of an ascending walk: unaltered, as realized, and its node id.
'''
unalteredPitch: pitch.Pitch
realizedPitch: pitch.Pitch
nodeId: Terminus|int


@dataclasses.dataclass
class NetworkWalkPath:
'''
An ascending walk from one start, kept so later calls can extend it.
'''
steps: list[NetworkWalkStep]
lastWalkedNode: Node
ended: bool = False


class AlteredDegree(t.TypedDict):
'''
The alteration spec for a single scale degree: a direction in which the
Expand Down Expand Up @@ -475,6 +504,7 @@ def __init__(self,
CacheKey,
tuple[list[pitch.Pitch], list[Terminus|int]]
] = OrderedDict()
self._ascendingWalks: dict[NetworkWalkKey, NetworkWalkPath] = {}

def clear(self) -> None:
'''
Expand All @@ -486,6 +516,7 @@ def clear(self) -> None:
self.nodes = OrderedDict()
self._ascendingCache = OrderedDict()
self._descendingCache = OrderedDict()
self._ascendingWalks = {}

def __eq__(self, other) -> bool:
'''
Expand Down Expand Up @@ -1322,7 +1353,8 @@ def nextPitch(
neighborIds = self.getNeighborNodeIds(pitchReference=pitchReference,
nodeName=nodeName,
pitchTarget=pitchOriginObj,
direction=direction) # must add direction
direction=direction,
alteredDegrees=alteredDegrees)
lowId, highId = t.cast(
'tuple[Terminus|int, Terminus|int]', neighborIds)

Expand All @@ -1339,46 +1371,15 @@ def nextPitch(
# at this point a valid node id is required to continue
foundNodeId = t.cast('Terminus|int', nodeId)

# realize the pitch from the found node degree
# we may be getting an altered
# tone, and we need to transpose an unaltered tone, thus
# leave out altered nodes argument
p = self.getPitchFromNodeDegree(
pitchReference=pitchReference,
nodeName=nodeName,
Comment on lines -1342 to -1348

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future, it would be a big help to my review if you put GH comments like "moved into the else: below" so I'm not wondering why all this is gone. Thanks!

nodeDegreeTarget=self.nodes[foundNodeId].degree,
direction=direction,
minPitch=None, # not using a range here to
maxPitch=None, # get natural expansion
alteredDegrees=None # need unaltered tone here, thus omitted
)
if p is None:
raise IntervalNetworkException(
'Could not find a pitch for the requested node degree')

# environLocal.printDebug(['nextPitch()', 'pitch obtained based on nodeName',
# nodeName, 'p', p, 'nodeId', nodeId, 'self.nodes[nodeId].degree',
# self.nodes[nodeId].degree])

# transfer octave from origin to new pitch derived from node
# note: this assumes octave equivalence and may be a problem
p.octave = pitchOriginObj.octave

# correct for derived pitch crossing octave boundary
# https://github.com/cuthbertLab/music21/issues/319
alterSemitones: int|float = 0
degree = self.nodeIdToDegree(foundNodeId)
if alteredDegrees and degree in alteredDegrees:
alterSemitones = alteredDegrees[degree]['interval'].semitones
alterSemitonesInt = t.cast('int', alterSemitones)
if not p.octaveIsImplicit:
if ((usedNeighbor and getNeighbor == Direction.DESCENDING)
or (not usedNeighbor and direction == Direction.ASCENDING)):
while p.transpose(alterSemitonesInt) > pitchOriginObj:
p.octave -= 1
else:
while p.transpose(alterSemitonesInt) < pitchOriginObj:
p.octave += 1
startBelowOrigin = ((usedNeighbor and getNeighbor == Direction.DESCENDING)
or (not usedNeighbor and direction == Direction.ASCENDING))
p = self._nodePitchNear(pitchReference,
nodeName,
foundNodeId,
pitchOriginObj,
direction=direction,
alteredDegrees=alteredDegrees,
atOrBelow=startBelowOrigin)

# pitchObj = p
n = self.nodes[foundNodeId]
Expand Down Expand Up @@ -1411,6 +1412,56 @@ def nextPitch(

return pCollect

def _nodePitchNear(
self,
pitchReference: pitch.Pitch|str,
nodeName: Node|int|Terminus|None,
nodeId: Terminus|int,
pitchOrigin: pitch.Pitch,
*,
direction: Direction,
alteredDegrees: AlteredDegrees|None,
atOrBelow: bool,
) -> pitch.Pitch:
'''
Return the unaltered pitch of `nodeId` nearest `pitchOrigin`: the highest
at or below it if `atOrBelow`, otherwise the lowest at or above it.

One node can stand for several pitch classes (a cycle of major thirds is
one edge, so D, F# and A# after the first D are all its high terminus),
so read the pitch off a realization around the origin.

`pitchOrigin` is changed in place.

AI-assisted (Claude).
'''
pitchOrigin.octaveIsImplicit = False
realizedPitches, realizedNodes = self.realize(
pitchReference,
nodeName,
minPitch=pitchOrigin.transpose(-12, inPlace=False),
maxPitch=pitchOrigin.transpose(12, inPlace=False),
direction=direction,
alteredDegrees=alteredDegrees,
)
nodePitches = [realizedPitch
for realizedPitch, realizedNode in zip(realizedPitches, realizedNodes)
if realizedNode == nodeId]
if atOrBelow:
found = max((p for p in nodePitches if p.ps <= pitchOrigin.ps),
key=lambda p: p.ps, default=None)
else:
found = min((p for p in nodePitches if p.ps >= pitchOrigin.ps),
key=lambda p: p.ps, default=None)
if found is None:
raise IntervalNetworkException(
'Could not find a pitch for the requested node degree')

degree = self.nodeIdToDegree(nodeId)
if alteredDegrees and degree in alteredDegrees:
return found.transpose(alteredDegrees[degree]['interval'].reverse())
return found

# TODO: need to collect intervals as well

def _getCacheKey(
Expand Down Expand Up @@ -1538,6 +1589,19 @@ def realizeAscending(
if self.octaveDuplicating and minPitch is not None:
pitchReference.transposeBelowTarget(minPitch, minimize=True, inPlace=True)

if ck is not None and minPitch is not None and maxPitch is not None:
walked = self._walkAscending(nodeObj, pitchReference, maxPitch, alteredDegrees)
if walked is not None:
post = []
postNodeId = []
for step in walked:
if (_gte(step.realizedPitch.ps, minPitch.ps)
and _lte(step.realizedPitch.ps, maxPitch.ps)):
post.append(step.realizedPitch)
postNodeId.append(step.nodeId)
self._ascendingCache[ck] = post, postNodeId
return post, postNodeId

# first, go upward from this pitch to the high terminus
n = nodeObj
p = pitchReference # we start with the pitch that is the reference
Expand Down Expand Up @@ -1621,6 +1685,66 @@ def realizeAscending(

return post, postNodeId

def _walkAscending(
self,
nodeObj: Node,
start: pitch.Pitch,
maxPitch: pitch.Pitch,
alteredDegrees: AlteredDegrees|None,
) -> list[NetworkWalkStep]|None:
'''
Return the pitches and node ids an ascending realization from `start` passes
through, up to and including the first at or above `maxPitch`, or None if
the walk reaches a node with more than one way up.

The walk depends only on where it starts, so it is kept and extended rather
than repeated for every range starting there.

AI-assisted (Claude).
'''
key: NetworkWalkKey = (nodeObj.id, start.nameWithOctave, tuple(
(degree,
spec['direction'],
spec['interval'].generic.directed,
spec['interval'].semitones)
for degree, spec in (alteredDegrees or {}).items()))
walk = self._ascendingWalks.get(key)
if walk is None:
walk = NetworkWalkPath([NetworkWalkStep(start, start, nodeObj.id)], nodeObj)
self._ascendingWalks[key] = walk
while not walk.ended and not _gte(walk.steps[-1].unalteredPitch.ps, maxPitch.ps):
if len(walk.steps) >= 100:
break
n = walk.lastWalkedNode
if n.id == Terminus.HIGH:
n = self.terminusLowNodes[0]
nextBundle = self.getNext(n, Direction.ASCENDING)
if nextBundle is None:
walk.ended = True
break
postEdge, postNode = nextBundle
if len(postEdge) > 1:
return None
walk.lastWalkedNode = postNode[0]
p = self.transposePitchAndApplySimplification(postEdge[0].interval,
walk.steps[-1].unalteredPitch)
realizedPitch = self.processAlteredNodes(alteredDegrees=alteredDegrees,
n=walk.lastWalkedNode,
p=p,
direction=Direction.ASCENDING)
walk.steps.append(NetworkWalkStep(p, realizedPitch, walk.lastWalkedNode.id))

walked = []
for step in walk.steps:
walked.append(step)
if _gte(step.unalteredPitch.ps, maxPitch.ps):
break
if len(walked) >= 100:
raise IntervalNetworkException(
'Cannot realize these pitches; is the network '
+ "well-formed? (especially check if you're giving notes without octaves)")
return walked

def realizeDescending(
self,
pitchReference: pitch.Pitch|str,
Expand Down
46 changes: 46 additions & 0 deletions music21/scale/test_scale_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,52 @@ def testDeriveHarmonicMinor(self):
sc = scale.HarmonicMinorScale()
self.assertEqual(sc.derive(['C', 'D', 'E-', 'B']).name, 'C harmonic minor')

def testNextPitchOnCycle(self):
'''
AI-assisted (Claude).
'''
thirds = scale.CyclicalScale('d3', ['M3'])
self.assertEqual(str(thirds.nextPitch('c4')), 'D4')
self.assertEqual(str(thirds.nextPitch('g4')), 'A#4')
self.assertEqual(str(thirds.nextPitch('g4', Direction.DESCENDING)), 'F#4')
self.assertEqual(str(thirds.nextPitch('f#4')), 'A#4')

fifths = scale.CyclicalScale('c4', ['P5'])
self.assertEqual(str(fifths.nextPitch('g4')), 'D5')
self.assertEqual(str(fifths.nextPitch('g4', Direction.DESCENDING)), 'C4')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for doing this test too -- important to see non-octave repeated here.

Can we also add a test on multiple intervals that don't add up to an octave, like scale.CyclicalScale('d3', ['M3', 'm3']) so we can be sure that we're respecting the different intervals.

And also nextPitch tests on enharmonics (both higher DNN and lower DNN) still go to the correct pitches. thirds nextPitch on C##4 goes to F#4 not D4, etc.

Thanks!


thirdsAndMinorThirds = scale.CyclicalScale('d3', ['M3', 'm3'])
self.assertEqual(str(thirdsAndMinorThirds.nextPitch('d4')), 'E4')
self.assertEqual(str(thirdsAndMinorThirds.nextPitch('d4', Direction.DESCENDING)), 'C#4')
self.assertEqual(str(thirdsAndMinorThirds.nextPitch('a4')), 'B4')
self.assertEqual(str(thirdsAndMinorThirds.nextPitch('a4', Direction.DESCENDING)), 'G#4')

def testNextPitchFromEnharmonic(self):
thirds = scale.CyclicalScale('d3', ['M3'])
for enharmonicOfD in ('c##4', 'e--4'):
self.assertEqual(str(thirds.nextPitch(enharmonicOfD)), 'F#4')
self.assertEqual(str(thirds.nextPitch(enharmonicOfD, Direction.DESCENDING)), 'B-3')

sc = scale.MajorScale('c4')
self.assertEqual(str(sc.nextPitch('b#3')), 'D4')
self.assertEqual(str(sc.nextPitch('d-4', Direction.DESCENDING)), 'C4')

def testNextPitchNodeNearOrigin(self):
thirds = scale.CyclicalScale('d3', ['M3'])
self.assertEqual(str(thirds.nextPitch('d4', stepSize=2)), 'A#4')

fifths = scale.CyclicalScale('c4', ['P5'])
self.assertEqual(str(fifths.nextPitch('f4', Direction.DESCENDING)), 'C4')

aMinor = scale.HarmonicMinorScale('a4')
self.assertEqual(str(aMinor.nextPitch('f##4')), 'G#4')
self.assertEqual(str(aMinor.nextPitch('a4', Direction.DESCENDING)), 'G#4')
self.assertEqual(str(aMinor.nextPitch('g4')), 'G#4')
self.assertEqual(str(aMinor.nextPitch('g4', Direction.DESCENDING)), 'F4')

gMinor = scale.HarmonicMinorScale('g4')
gMinor.nextPitch('a4')
self.assertEqual(str(gMinor.pitches[6]), 'F#5')
def testHarmonicMinorKeepsItsSeventhAfterNextPitch(self):
sc = scale.HarmonicMinorScale('g4')
sc.nextPitch('a4')
Expand Down
Loading