From db3589846d9289d46a915597a2f1bfb4c35bc784 Mon Sep 17 00:00:00 2001 From: hill Date: Sun, 4 Oct 2026 15:06:18 +0200 Subject: [PATCH 1/6] Find the next pitch of a cyclical scale where it is nextPitch spelled the found node's degree from the scale's own tonic and moved it into the origin's octave. That holds when the network repeats at the octave, but in a cyclical one a degree stands for more than one pitch class: every note of a cycle of major thirds is the same node. So the walk began on the wrong pitch. >>> sc = scale.CyclicalScale('d3', ['M3']) >>> sc.nextPitch('g4') >>> sc.nextPitch('c4') For a network that is not octave-duplicating, take the node's pitch from a realization around the origin instead. Octave-duplicating networks are unchanged. --- music21/scale/intervalNetwork.py | 129 ++++++++++++++++++++++--------- music21/scale/test_scale_main.py | 14 ++++ 2 files changed, 108 insertions(+), 35 deletions(-) diff --git a/music21/scale/intervalNetwork.py b/music21/scale/intervalNetwork.py index ee2ee0ef3..aa6577fcd 100644 --- a/music21/scale/intervalNetwork.py +++ b/music21/scale/intervalNetwork.py @@ -1338,46 +1338,56 @@ 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, - 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: pitch.Pitch|None + if not self.octaveDuplicating: + p = self._nodePitchNearOrigin(pitchReference, + nodeName, + foundNodeId, + pitchOriginObj, + direction=direction, + alteredDegrees=alteredDegrees, + atOrBelow=startBelowOrigin) + else: + # 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, + 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 + p.octave = pitchOriginObj.octave + + # correct for derived pitch crossing octave boundary + # https://github.com/cuthbertLab/music21/issues/319 + if not p.octaveIsImplicit: + if startBelowOrigin: + while p.transpose(alterSemitonesInt) > pitchOriginObj: + p.octave -= 1 + else: + while p.transpose(alterSemitonesInt) < pitchOriginObj: + p.octave += 1 # pitchObj = p n = self.nodes[foundNodeId] @@ -1410,6 +1420,55 @@ def nextPitch( return pCollect + def _nodePitchNearOrigin( + 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. + + Read off a realization around the origin, since in a network that does + not repeat at the octave one node can stand for several pitch classes: + every note of a cycle of major thirds is the same node. + + AI-assisted (Claude). + ''' + origin = copy.deepcopy(pitchOrigin) + origin.octaveIsImplicit = False + realizedPitches, realizedNodes = self.realize( + pitchReference, + nodeName, + minPitch=origin.transpose(-12, inPlace=False), + maxPitch=origin.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 <= origin.ps), + key=lambda p: p.ps, default=None) + else: + found = min((p for p in nodePitches if p.ps >= origin.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 copy.deepcopy(found) + # TODO: need to collect intervals as well def _getCacheKey( diff --git a/music21/scale/test_scale_main.py b/music21/scale/test_scale_main.py index 8c381814c..13307855f 100644 --- a/music21/scale/test_scale_main.py +++ b/music21/scale/test_scale_main.py @@ -320,6 +320,20 @@ 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') + def testDeriveByDegree(self): sc1 = scale.MajorScale() self.assertEqual(str(sc1.deriveByDegree(7, 'G#')), From 4de40a099fbde1edfdc70866e604fd2c76f92906 Mon Sep 17 00:00:00 2001 From: hill Date: Mon, 5 Oct 2026 11:11:45 +0200 Subject: [PATCH 2/6] Find the next pitch near the origin for every network nextPitch now takes the node's pitch from a realization around the origin for octave-duplicating networks too, which removes the second path. It also corrects enharmonics across an octave boundary there: MajorScale('c4').nextPitch('b#3') was D3 and is D4. Tests for a cycle of two intervals and for enharmonic origins, and a clearer docstring for _nodePitchNearOrigin. --- music21/scale/intervalNetwork.py | 61 ++++++-------------------------- music21/scale/test_scale_main.py | 16 +++++++++ 2 files changed, 26 insertions(+), 51 deletions(-) diff --git a/music21/scale/intervalNetwork.py b/music21/scale/intervalNetwork.py index aa6577fcd..0b9c17ea3 100644 --- a/music21/scale/intervalNetwork.py +++ b/music21/scale/intervalNetwork.py @@ -1338,56 +1338,15 @@ def nextPitch( # at this point a valid node id is required to continue foundNodeId = t.cast('Terminus|int', nodeId) - alterSemitones: int|float = 0 - degree = self.nodeIdToDegree(foundNodeId) - if alteredDegrees and degree in alteredDegrees: - alterSemitones = alteredDegrees[degree]['interval'].semitones - alterSemitonesInt = t.cast('int', alterSemitones) startBelowOrigin = ((usedNeighbor and getNeighbor == Direction.DESCENDING) or (not usedNeighbor and direction == Direction.ASCENDING)) - p: pitch.Pitch|None - if not self.octaveDuplicating: - p = self._nodePitchNearOrigin(pitchReference, - nodeName, - foundNodeId, - pitchOriginObj, - direction=direction, - alteredDegrees=alteredDegrees, - atOrBelow=startBelowOrigin) - else: - # 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, - 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 - p.octave = pitchOriginObj.octave - - # correct for derived pitch crossing octave boundary - # https://github.com/cuthbertLab/music21/issues/319 - if not p.octaveIsImplicit: - if startBelowOrigin: - while p.transpose(alterSemitonesInt) > pitchOriginObj: - p.octave -= 1 - else: - while p.transpose(alterSemitonesInt) < pitchOriginObj: - p.octave += 1 + p = self._nodePitchNearOrigin(pitchReference, + nodeName, + foundNodeId, + pitchOriginObj, + direction=direction, + alteredDegrees=alteredDegrees, + atOrBelow=startBelowOrigin) # pitchObj = p n = self.nodes[foundNodeId] @@ -1435,9 +1394,9 @@ def _nodePitchNearOrigin( Return the unaltered pitch of `nodeId` nearest `pitchOrigin`: the highest at or below it if `atOrBelow`, otherwise the lowest at or above it. - Read off a realization around the origin, since in a network that does - not repeat at the octave one node can stand for several pitch classes: - every note of a cycle of major thirds is the same node. + Read off a realization around the origin, since one node can stand for + several pitch classes: a cycle of major thirds has a single edge, so + every pitch after its first is the high terminus. AI-assisted (Claude). ''' diff --git a/music21/scale/test_scale_main.py b/music21/scale/test_scale_main.py index 13307855f..6dc115961 100644 --- a/music21/scale/test_scale_main.py +++ b/music21/scale/test_scale_main.py @@ -334,6 +334,22 @@ def testNextPitchOnCycle(self): self.assertEqual(str(fifths.nextPitch('g4')), 'D5') self.assertEqual(str(fifths.nextPitch('g4', Direction.DESCENDING)), 'C4') + 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 testDeriveByDegree(self): sc1 = scale.MajorScale() self.assertEqual(str(sc1.deriveByDegree(7, 'G#')), From 06aa2fe15e4548d5cc2830f5bca3dd943b785ab0 Mon Sep 17 00:00:00 2001 From: hill Date: Mon, 5 Oct 2026 13:12:56 +0200 Subject: [PATCH 3/6] Keep each ascending walk and filter it to the range asked for realizeAscending cached a realization under its exact range, so every new origin given to nextPitch walked its two octaves again. The walk depends only on its node, its starting pitch and the altered degrees, so it is now kept under those and extended as far as a range needs, and each range is filtered from it. Networks pickled before the walks existed make theirs on first use. nextPitch on a fresh scale over 36 origins, against master: MajorScale 1045 -> 393 us, HarmonicMinorScale 1141 -> 419 us, CyclicalScale M3 558 -> 348 us. A single getPitches is unchanged. --- music21/scale/intervalNetwork.py | 92 ++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/music21/scale/intervalNetwork.py b/music21/scale/intervalNetwork.py index 0b9c17ea3..9a46ed867 100644 --- a/music21/scale/intervalNetwork.py +++ b/music21/scale/intervalNetwork.py @@ -36,6 +36,7 @@ from collections import OrderedDict from collections.abc import Sequence import copy +import dataclasses import enum import typing as t @@ -82,6 +83,20 @@ def __str__(self) -> str: type CacheKey = tuple[ int|Terminus, str, str|None, str|None, bool, bool|None] +@dataclasses.dataclass +class _Step: + pitchObj: pitch.Pitch + collected: pitch.Pitch + nodeId: Terminus|int + + +@dataclasses.dataclass +class _Walk: + steps: list[_Step] + node: Node + ended: bool = False + + class AlteredDegree(t.TypedDict): ''' The alteration spec for a single scale degree: a direction in which the @@ -485,6 +500,7 @@ def clear(self) -> None: self.nodes = OrderedDict() self._ascendingCache = OrderedDict() self._descendingCache = OrderedDict() + vars(self).pop('_ascendingWalks', None) def __eq__(self, other) -> bool: ''' @@ -1542,6 +1558,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.collected.ps, minPitch.ps) + and _lte(step.collected.ps, maxPitch.ps)): + post.append(step.collected) + 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 @@ -1625,6 +1654,69 @@ def realizeAscending( return post, postNodeId + def _walkAscending( + self, + nodeObj: Node, + start: pitch.Pitch, + maxPitch: pitch.Pitch, + alteredDegrees: AlteredDegrees|None, + ) -> list[_Step]|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: tuple[t.Any, ...] = (nodeObj.id, start.nameWithOctave) + if alteredDegrees: + key += tuple((degree, + spec['direction'], + spec['interval'].generic.directed, + spec['interval'].semitones) + for degree, spec in alteredDegrees.items()) + # made on first use: networks pickled before it existed do not have it + walks: dict[tuple[t.Any, ...], _Walk] = vars(self).setdefault('_ascendingWalks', {}) + walk = walks.get(key) + if walk is None: + walk = _Walk([_Step(start, start, nodeObj.id)], nodeObj) + walks[key] = walk + while not walk.ended and not _gte(walk.steps[-1].pitchObj.ps, maxPitch.ps): + if len(walk.steps) >= 100: + break + n = walk.node + 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.node = postNode[0] + p = self.transposePitchAndApplySimplification(postEdge[0].interval, + walk.steps[-1].pitchObj) + collected = self.processAlteredNodes(alteredDegrees=alteredDegrees, + n=walk.node, + p=p, + direction=Direction.ASCENDING) + walk.steps.append(_Step(p, collected, walk.node.id)) + + walked = [] + for step in walk.steps: + walked.append(step) + if _gte(step.pitchObj.ps, maxPitch.ps): + break + if len(walked) >= 100: + raise IntervalNetworkException( + 'Cannot realize these pitches; is your scale ' + + "well-formed? (especially check if you're giving notes without octaves)") + return walked + def realizeDescending( self, pitchReference: pitch.Pitch|str, From d8600f45e169ef92a5ccbe35f9620cdb822b4485 Mon Sep 17 00:00:00 2001 From: hill Date: Wed, 7 Oct 2026 18:44:39 +0200 Subject: [PATCH 4/6] Tidy the ascending walk cache and test nextPitch near the origin Set up the walk cache in __init__ and clear(), type its key, and drop two deepcopies of pitches nextPitch already owns. --- music21/scale/intervalNetwork.py | 54 +++++++++++++++++++------------- music21/scale/test_scale_main.py | 15 +++++++++ 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/music21/scale/intervalNetwork.py b/music21/scale/intervalNetwork.py index 9a46ed867..4740fd492 100644 --- a/music21/scale/intervalNetwork.py +++ b/music21/scale/intervalNetwork.py @@ -83,8 +83,16 @@ def __str__(self) -> str: type CacheKey = tuple[ int|Terminus, str, str|None, str|None, bool, bool|None] +# node id, start pitch, and (degree, direction, generic, semitones) per altered degree +type WalkKey = tuple[ + Terminus|int, str, tuple[tuple[int, Direction, int, int|float], ...]] + + @dataclasses.dataclass class _Step: + ''' + One pitch of an ascending walk: as transposed, as altered, and its node id. + ''' pitchObj: pitch.Pitch collected: pitch.Pitch nodeId: Terminus|int @@ -92,6 +100,9 @@ class _Step: @dataclasses.dataclass class _Walk: + ''' + An ascending walk from one start, kept so later calls can extend it. + ''' steps: list[_Step] node: Node ended: bool = False @@ -489,6 +500,7 @@ def __init__(self, CacheKey, tuple[list[pitch.Pitch], list[Terminus|int]] ] = OrderedDict() + self._ascendingWalks: dict[WalkKey, _Walk] = {} def clear(self) -> None: ''' @@ -500,7 +512,7 @@ def clear(self) -> None: self.nodes = OrderedDict() self._ascendingCache = OrderedDict() self._descendingCache = OrderedDict() - vars(self).pop('_ascendingWalks', None) + self._ascendingWalks = {} def __eq__(self, other) -> bool: ''' @@ -1410,19 +1422,20 @@ def _nodePitchNearOrigin( Return the unaltered pitch of `nodeId` nearest `pitchOrigin`: the highest at or below it if `atOrBelow`, otherwise the lowest at or above it. - Read off a realization around the origin, since one node can stand for - several pitch classes: a cycle of major thirds has a single edge, so - every pitch after its first is the high terminus. + 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). ''' - origin = copy.deepcopy(pitchOrigin) - origin.octaveIsImplicit = False + pitchOrigin.octaveIsImplicit = False realizedPitches, realizedNodes = self.realize( pitchReference, nodeName, - minPitch=origin.transpose(-12, inPlace=False), - maxPitch=origin.transpose(12, inPlace=False), + minPitch=pitchOrigin.transpose(-12, inPlace=False), + maxPitch=pitchOrigin.transpose(12, inPlace=False), direction=direction, alteredDegrees=alteredDegrees, ) @@ -1430,10 +1443,10 @@ def _nodePitchNearOrigin( for realizedPitch, realizedNode in zip(realizedPitches, realizedNodes) if realizedNode == nodeId] if atOrBelow: - found = max((p for p in nodePitches if p.ps <= origin.ps), + 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 >= origin.ps), + 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( @@ -1442,7 +1455,7 @@ def _nodePitchNearOrigin( degree = self.nodeIdToDegree(nodeId) if alteredDegrees and degree in alteredDegrees: return found.transpose(alteredDegrees[degree]['interval'].reverse()) - return copy.deepcopy(found) + return found # TODO: need to collect intervals as well @@ -1671,19 +1684,16 @@ def _walkAscending( AI-assisted (Claude). ''' - key: tuple[t.Any, ...] = (nodeObj.id, start.nameWithOctave) - if alteredDegrees: - key += tuple((degree, - spec['direction'], - spec['interval'].generic.directed, - spec['interval'].semitones) - for degree, spec in alteredDegrees.items()) - # made on first use: networks pickled before it existed do not have it - walks: dict[tuple[t.Any, ...], _Walk] = vars(self).setdefault('_ascendingWalks', {}) - walk = walks.get(key) + key: WalkKey = (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 = _Walk([_Step(start, start, nodeObj.id)], nodeObj) - walks[key] = walk + self._ascendingWalks[key] = walk while not walk.ended and not _gte(walk.steps[-1].pitchObj.ps, maxPitch.ps): if len(walk.steps) >= 100: break diff --git a/music21/scale/test_scale_main.py b/music21/scale/test_scale_main.py index 6dc115961..77725adbc 100644 --- a/music21/scale/test_scale_main.py +++ b/music21/scale/test_scale_main.py @@ -350,6 +350,21 @@ def testNextPitchFromEnharmonic(self): 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') + + gMinor = scale.HarmonicMinorScale('g4') + gMinor.nextPitch('a4') + self.assertEqual(str(gMinor.pitches[6]), 'F#5') + def testDeriveByDegree(self): sc1 = scale.MajorScale() self.assertEqual(str(sc1.deriveByDegree(7, 'G#')), From 6623cd71ff70402f6b5c8c6ee0ec03d121de0602 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Wed, 7 Oct 2026 17:59:44 -1000 Subject: [PATCH 5/6] Rename some keys to be more specific --- music21/scale/intervalNetwork.py | 71 +++++++++++++++++--------------- 1 file changed, 37 insertions(+), 34 deletions(-) diff --git a/music21/scale/intervalNetwork.py b/music21/scale/intervalNetwork.py index a2b156d93..c8044f83a 100644 --- a/music21/scale/intervalNetwork.py +++ b/music21/scale/intervalNetwork.py @@ -85,27 +85,30 @@ def __str__(self) -> str: tuple[tuple[int, Direction, int, int|float], ...]] # node id, start pitch, and (degree, direction, generic, semitones) per altered degree -type WalkKey = tuple[ - Terminus|int, str, tuple[tuple[int, Direction, int, int|float], ...]] +type NetworkWalkKey = tuple[ + Terminus|int, + str, + tuple[tuple[int, Direction, int, int|float], ...], +] @dataclasses.dataclass -class _Step: +class NetworkWalkStep: ''' - One pitch of an ascending walk: as transposed, as altered, and its node id. + One pitch of an ascending walk: unaltered, as realized, and its node id. ''' - pitchObj: pitch.Pitch - collected: pitch.Pitch + unalteredPitch: pitch.Pitch + realizedPitch: pitch.Pitch nodeId: Terminus|int @dataclasses.dataclass -class _Walk: +class NetworkWalkPath: ''' An ascending walk from one start, kept so later calls can extend it. ''' - steps: list[_Step] - node: Node + steps: list[NetworkWalkStep] + lastWalkedNode: Node ended: bool = False @@ -501,7 +504,7 @@ def __init__(self, CacheKey, tuple[list[pitch.Pitch], list[Terminus|int]] ] = OrderedDict() - self._ascendingWalks: dict[WalkKey, _Walk] = {} + self._ascendingWalks: dict[NetworkWalkKey, NetworkWalkPath] = {} def clear(self) -> None: ''' @@ -1369,13 +1372,13 @@ def nextPitch( startBelowOrigin = ((usedNeighbor and getNeighbor == Direction.DESCENDING) or (not usedNeighbor and direction == Direction.ASCENDING)) - p = self._nodePitchNearOrigin(pitchReference, - nodeName, - foundNodeId, - pitchOriginObj, - direction=direction, - alteredDegrees=alteredDegrees, - atOrBelow=startBelowOrigin) + p = self._nodePitchNear(pitchReference, + nodeName, + foundNodeId, + pitchOriginObj, + direction=direction, + alteredDegrees=alteredDegrees, + atOrBelow=startBelowOrigin) # pitchObj = p n = self.nodes[foundNodeId] @@ -1408,7 +1411,7 @@ def nextPitch( return pCollect - def _nodePitchNearOrigin( + def _nodePitchNear( self, pitchReference: pitch.Pitch|str, nodeName: Node|int|Terminus|None, @@ -1591,9 +1594,9 @@ def realizeAscending( post = [] postNodeId = [] for step in walked: - if (_gte(step.collected.ps, minPitch.ps) - and _lte(step.collected.ps, maxPitch.ps)): - post.append(step.collected) + 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 @@ -1687,7 +1690,7 @@ def _walkAscending( start: pitch.Pitch, maxPitch: pitch.Pitch, alteredDegrees: AlteredDegrees|None, - ) -> list[_Step]|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 @@ -1698,7 +1701,7 @@ def _walkAscending( AI-assisted (Claude). ''' - key: WalkKey = (nodeObj.id, start.nameWithOctave, tuple( + key: NetworkWalkKey = (nodeObj.id, start.nameWithOctave, tuple( (degree, spec['direction'], spec['interval'].generic.directed, @@ -1706,12 +1709,12 @@ def _walkAscending( for degree, spec in (alteredDegrees or {}).items())) walk = self._ascendingWalks.get(key) if walk is None: - walk = _Walk([_Step(start, start, nodeObj.id)], nodeObj) + walk = NetworkWalkPath([NetworkWalkStep(start, start, nodeObj.id)], nodeObj) self._ascendingWalks[key] = walk - while not walk.ended and not _gte(walk.steps[-1].pitchObj.ps, maxPitch.ps): + while not walk.ended and not _gte(walk.steps[-1].unalteredPitch.ps, maxPitch.ps): if len(walk.steps) >= 100: break - n = walk.node + n = walk.lastWalkedNode if n.id == Terminus.HIGH: n = self.terminusLowNodes[0] nextBundle = self.getNext(n, Direction.ASCENDING) @@ -1721,19 +1724,19 @@ def _walkAscending( postEdge, postNode = nextBundle if len(postEdge) > 1: return None - walk.node = postNode[0] + walk.lastWalkedNode = postNode[0] p = self.transposePitchAndApplySimplification(postEdge[0].interval, - walk.steps[-1].pitchObj) - collected = self.processAlteredNodes(alteredDegrees=alteredDegrees, - n=walk.node, - p=p, - direction=Direction.ASCENDING) - walk.steps.append(_Step(p, collected, walk.node.id)) + 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.pitchObj.ps, maxPitch.ps): + if _gte(step.unalteredPitch.ps, maxPitch.ps): break if len(walked) >= 100: raise IntervalNetworkException( From 7085364936dc589b305e7700327d718d01724d1a Mon Sep 17 00:00:00 2001 From: hill Date: Thu, 8 Oct 2026 13:44:22 +0200 Subject: [PATCH 6/6] Find nextPitch neighbors among altered pitches nextPitch looked for the neighbors of an origin that is not in the network without the altered degrees, so in A harmonic minor G4 sat on the G node, whose realized pitch is G#, and ascending went to A3. --- music21/scale/intervalNetwork.py | 5 +++-- music21/scale/test_scale_main.py | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/music21/scale/intervalNetwork.py b/music21/scale/intervalNetwork.py index c8044f83a..f31c25348 100644 --- a/music21/scale/intervalNetwork.py +++ b/music21/scale/intervalNetwork.py @@ -1353,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) @@ -1740,7 +1741,7 @@ def _walkAscending( break if len(walked) >= 100: raise IntervalNetworkException( - 'Cannot realize these pitches; is your scale ' + 'Cannot realize these pitches; is the network ' + "well-formed? (especially check if you're giving notes without octaves)") return walked diff --git a/music21/scale/test_scale_main.py b/music21/scale/test_scale_main.py index 73d876f0e..6249641fb 100644 --- a/music21/scale/test_scale_main.py +++ b/music21/scale/test_scale_main.py @@ -360,6 +360,8 @@ def testNextPitchNodeNearOrigin(self): 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')