subsequence.harmonic_state

  1import random
  2import typing
  3
  4import subsequence.chord_graphs.aeolian_minor
  5import subsequence.chord_graphs.chromatic_mediant
  6import subsequence.chord_graphs.diminished
  7import subsequence.chord_graphs.dorian_minor
  8import subsequence.chord_graphs.functional_major
  9import subsequence.chord_graphs.lydian_major
 10import subsequence.chord_graphs.mixolydian
 11import subsequence.chord_graphs.phrygian_minor
 12import subsequence.chord_graphs.suspended
 13import subsequence.chord_graphs.turnaround_global
 14import subsequence.chord_graphs.whole_tone
 15import subsequence.chords
 16import subsequence.weighted_graph
 17
 18
 19DEFAULT_ROOT_DIVERSITY: float = 0.4
 20
 21
 22
 23# ---------------------------------------------------------------------------
 24# Graph style registry — see _resolve_graph_style() below.
 25# To register a new chord graph, add it to _D7_STYLES or _SIMPLE_STYLES there.
 26# ---------------------------------------------------------------------------
 27
 28
 29def _resolve_graph_style (
 30	style: str,
 31	include_dominant_7th: bool,
 32	minor_turnaround_weight: float
 33) -> subsequence.chord_graphs.ChordGraph:
 34
 35	"""Create a ChordGraph instance from a string style name and legacy parameters."""
 36
 37	if style in ("diatonic_major", "functional_major"):
 38		return subsequence.chord_graphs.functional_major.DiatonicMajor(
 39			include_dominant_7th = include_dominant_7th
 40		)
 41
 42	if style in ("turnaround", "turnaround_global"):
 43		return subsequence.chord_graphs.turnaround_global.TurnaroundModulation(
 44			include_dominant_7th = include_dominant_7th,
 45			minor_turnaround_weight = minor_turnaround_weight
 46		)
 47
 48	# Styles with only an include_dominant_7th parameter.
 49	_D7_STYLES: typing.Dict[
 50		str,
 51		typing.Callable[[bool], subsequence.chord_graphs.ChordGraph]
 52	] = {
 53		"aeolian_minor": subsequence.chord_graphs.aeolian_minor.AeolianMinor,
 54		"lydian_major":  subsequence.chord_graphs.lydian_major.LydianMajor,
 55		"dorian_minor":  subsequence.chord_graphs.dorian_minor.DorianMinor,
 56	}
 57	if style in _D7_STYLES:
 58		return _D7_STYLES[style](include_dominant_7th)
 59
 60	# Styles that take no extra parameters.
 61	_SIMPLE_STYLES: typing.Dict[
 62		str,
 63		typing.Callable[[], subsequence.chord_graphs.ChordGraph]
 64	] = {
 65		"phrygian_minor":    subsequence.chord_graphs.phrygian_minor.PhrygianMinor,
 66		"chromatic_mediant": subsequence.chord_graphs.chromatic_mediant.ChromaticMediant,
 67		"suspended":         subsequence.chord_graphs.suspended.Suspended,
 68		"mixolydian":        subsequence.chord_graphs.mixolydian.Mixolydian,
 69		"whole_tone":        subsequence.chord_graphs.whole_tone.WholeTone,
 70		"diminished":        subsequence.chord_graphs.diminished.Diminished,
 71	}
 72	if style in _SIMPLE_STYLES:
 73		return _SIMPLE_STYLES[style]()
 74
 75	raise ValueError(f"Unknown graph style: {style!r}")
 76
 77
 78
 79class HarmonicState:
 80
 81	"""Holds the current chord and key context for the composition."""
 82
 83	def __init__ (
 84		self,
 85		key_name: str,
 86		graph_style: typing.Union[str, subsequence.chord_graphs.ChordGraph] = "functional_major",
 87		include_dominant_7th: bool = True,
 88		key_gravity_blend: float = 1.0,
 89		nir_strength: float = 0.5,
 90		minor_turnaround_weight: float = 0.0,
 91		root_diversity: float = DEFAULT_ROOT_DIVERSITY,
 92		rng: typing.Optional[random.Random] = None
 93	) -> None:
 94
 95		"""
 96		Initialize the harmonic state using a chord transition graph.
 97
 98		Parameters:
 99			key_name: Note name for the key (e.g., ``"C"``, ``"F#"``).
100			graph_style: Built-in style name or a custom ``ChordGraph`` instance.
101			include_dominant_7th: Include V7 chords in the graph (default True).
102			key_gravity_blend: Balance between functional and diatonic gravity
103				(0.0 = functional only, 1.0 = full diatonic). Default 1.0.
104			nir_strength: Melodic inertia from Narmour's Implication-Realization
105				model (0.0 = off, 1.0 = full). Default 0.5.
106			minor_turnaround_weight: For turnaround style, weight toward minor
107				turnarounds (0.0 to 1.0). Default 0.0.
108			root_diversity: Root-repetition damping factor (0.0 to 1.0). Each
109				recent chord sharing a candidate's root pitch class multiplies
110				the transition weight by this factor. At the default (0.4), one
111				recent same-root chord reduces the weight to 40%; two reduce it
112				to 16%. Set to 1.0 to disable the penalty entirely. Default 0.4.
113			rng: Optional seeded ``random.Random`` for deterministic playback.
114		"""
115
116		if key_gravity_blend < 0 or key_gravity_blend > 1:
117			raise ValueError("Key gravity blend must be between 0 and 1")
118
119		if nir_strength < 0 or nir_strength > 1:
120			raise ValueError("NIR strength must be between 0 and 1")
121
122		if minor_turnaround_weight < 0 or minor_turnaround_weight > 1:
123			raise ValueError("Minor turnaround weight must be between 0 and 1")
124
125		if root_diversity < 0 or root_diversity > 1:
126			raise ValueError("Root diversity must be between 0 and 1")
127
128		self.key_name = key_name
129		self.key_root_pc = subsequence.chords.NOTE_NAME_TO_PC[key_name]
130		self.key_gravity_blend = key_gravity_blend
131		self.nir_strength = nir_strength
132		self.root_diversity = root_diversity
133		self.minor_turnaround_weight = minor_turnaround_weight
134
135
136		if isinstance(graph_style, str):
137			chord_graph = _resolve_graph_style(graph_style, include_dominant_7th, minor_turnaround_weight)
138
139		else:
140			chord_graph = graph_style
141
142		self.graph, tonic = chord_graph.build(key_name)
143		self._diatonic_chords, self._function_chords = chord_graph.gravity_sets(key_name)
144
145		self.rng = rng or random.Random()
146		self.current_chord = tonic
147		self.history: typing.List[subsequence.chords.Chord] = []
148
149
150	def _calculate_nir_score (self, source: subsequence.chords.Chord, target: subsequence.chords.Chord) -> float:
151
152		"""
153		Calculate a Narmour Implication-Realization (NIR) score for a transition.
154		Returns a multiplier (default 1.0, >1.0 for boost).
155		"""
156
157		if not self.history:
158			return 1.0
159
160		prev = self.history[-1]
161
162		# Calculate interval from Prev -> Source (The "Implication" generator)
163		# Using shortest-path distance in Pitch Class space (-6 to +6)
164		prev_diff = (source.root_pc - prev.root_pc) % 12
165		if prev_diff > 6:
166			prev_diff -= 12
167
168		prev_interval = abs(prev_diff)
169		prev_direction = 1 if prev_diff > 0 else -1 if prev_diff < 0 else 0
170
171		# Calculate interval from Source -> Target (The "Realization")
172		target_diff = (target.root_pc - source.root_pc) % 12
173		if target_diff > 6:
174			target_diff -= 12
175
176		target_interval = abs(target_diff)
177		target_direction = 1 if target_diff > 0 else -1 if target_diff < 0 else 0
178
179		score = 1.0
180
181		# --- Rule A: Reversal (Gap Fill) ---
182		# If previous was a Large Leap (> 4 semitones like P4, P5, m6), expect direction change.
183		if prev_interval > 4:
184			# Expect change in direction
185			if target_direction != prev_direction and target_direction != 0:
186				score += 0.5
187
188			# Expect smaller interval (Gap Fill)
189			if target_interval < 4:
190				score += 0.3
191
192		# --- Rule B: Process (Continuation/Inertia) ---
193		# If previous was Small Step (< 3 semitones), expect similarity.
194		elif prev_interval > 0 and prev_interval < 3:
195			# Expect same direction
196			if target_direction == prev_direction:
197				score += 0.4
198
199			# Expect similar size
200			if abs(target_interval - prev_interval) <= 1:
201				score += 0.2
202
203		# --- Rule C: Closure ---
204		# Return to Tonic (Closure) is often implied after tension
205		if target.root_pc == self.key_root_pc:
206			score += 0.2
207
208		# --- Rule D: Proximity ---
209		# General preference for small intervals (≤ 3 semitones).
210		if target_interval > 0 and target_interval <= 3:
211			score += 0.3
212
213		# Scale the boost portion by nir_strength (score starts at 1.0, boost is the excess)
214		return 1.0 + (score - 1.0) * self.nir_strength
215
216	def step (self) -> subsequence.chords.Chord:
217
218		"""Advance to the next chord based on the transition graph."""
219
220		# Update history before choosing next (so structure tracks the path)
221		self.history.append(self.current_chord)
222		if len(self.history) > 4:
223			self.history.pop(0)
224
225		def weight_modifier (
226			source: subsequence.chords.Chord,
227			target: subsequence.chords.Chord,
228			weight: int
229		) -> float:
230
231			"""
232			Combine three forces that shape chord transition probabilities:
233
234			1. **Key gravity** — blends functional pull (tonic, dominant) with
235			   full diatonic pull, controlled by ``key_gravity_blend``.
236			2. **Melodic inertia (NIR)** — Narmour's cognitive expectation
237			   model favoring continuation after small steps and reversal
238			   after large leaps, controlled by ``nir_strength``.
239			3. **Root diversity** — exponential damping that discourages
240			   revisiting a root pitch class heard recently, controlled by
241			   ``root_diversity``. Each recent chord sharing the target's
242			   root multiplies the weight by ``root_diversity`` (default
243			   0.4), so the penalty grows stronger with each consecutive
244			   same-root step.
245
246			The final modifier is:
247
248				``(1 + gravity_boost) × nir_score × diversity``
249			"""
250
251			is_function = 1.0 if target in self._function_chords else 0.0
252			is_diatonic = 1.0 if target in self._diatonic_chords else 0.0
253
254			# Decision path: blend controls whether key gravity favors functional or full diatonic chords.
255			boost = (1.0 - self.key_gravity_blend) * is_function + self.key_gravity_blend * is_diatonic
256
257			# Apply NIR gravity
258			nir_score = self._calculate_nir_score(source, target)
259
260			# Root diversity: penalise transitions to a root heard recently.
261			recent_same_root = sum(
262				1 for h in self.history
263				if h.root_pc == target.root_pc
264			)
265			diversity = self.root_diversity ** recent_same_root
266
267			return (1.0 + boost) * nir_score * diversity
268
269		# Decision path: chord changes occur here; key changes are not automatic.
270		self.current_chord = self.graph.choose_next(self.current_chord, self.rng, weight_modifier=weight_modifier)
271
272		return self.current_chord
273
274
275	def get_current_chord (self) -> subsequence.chords.Chord:
276
277		"""Return the current chord."""
278
279		return self.current_chord
280
281
282	def get_key_name (self) -> str:
283
284		"""Return the current key name."""
285
286		return self.key_name
287
288
289	def get_chord_root_midi (self, base_midi: int, chord: subsequence.chords.Chord) -> int:
290
291		"""Calculate the MIDI root for a chord relative to the key root."""
292
293		offset = (chord.root_pc - self.key_root_pc) % 12
294
295		return base_midi + offset
DEFAULT_ROOT_DIVERSITY: float = 0.4
class HarmonicState:
 80class HarmonicState:
 81
 82	"""Holds the current chord and key context for the composition."""
 83
 84	def __init__ (
 85		self,
 86		key_name: str,
 87		graph_style: typing.Union[str, subsequence.chord_graphs.ChordGraph] = "functional_major",
 88		include_dominant_7th: bool = True,
 89		key_gravity_blend: float = 1.0,
 90		nir_strength: float = 0.5,
 91		minor_turnaround_weight: float = 0.0,
 92		root_diversity: float = DEFAULT_ROOT_DIVERSITY,
 93		rng: typing.Optional[random.Random] = None
 94	) -> None:
 95
 96		"""
 97		Initialize the harmonic state using a chord transition graph.
 98
 99		Parameters:
100			key_name: Note name for the key (e.g., ``"C"``, ``"F#"``).
101			graph_style: Built-in style name or a custom ``ChordGraph`` instance.
102			include_dominant_7th: Include V7 chords in the graph (default True).
103			key_gravity_blend: Balance between functional and diatonic gravity
104				(0.0 = functional only, 1.0 = full diatonic). Default 1.0.
105			nir_strength: Melodic inertia from Narmour's Implication-Realization
106				model (0.0 = off, 1.0 = full). Default 0.5.
107			minor_turnaround_weight: For turnaround style, weight toward minor
108				turnarounds (0.0 to 1.0). Default 0.0.
109			root_diversity: Root-repetition damping factor (0.0 to 1.0). Each
110				recent chord sharing a candidate's root pitch class multiplies
111				the transition weight by this factor. At the default (0.4), one
112				recent same-root chord reduces the weight to 40%; two reduce it
113				to 16%. Set to 1.0 to disable the penalty entirely. Default 0.4.
114			rng: Optional seeded ``random.Random`` for deterministic playback.
115		"""
116
117		if key_gravity_blend < 0 or key_gravity_blend > 1:
118			raise ValueError("Key gravity blend must be between 0 and 1")
119
120		if nir_strength < 0 or nir_strength > 1:
121			raise ValueError("NIR strength must be between 0 and 1")
122
123		if minor_turnaround_weight < 0 or minor_turnaround_weight > 1:
124			raise ValueError("Minor turnaround weight must be between 0 and 1")
125
126		if root_diversity < 0 or root_diversity > 1:
127			raise ValueError("Root diversity must be between 0 and 1")
128
129		self.key_name = key_name
130		self.key_root_pc = subsequence.chords.NOTE_NAME_TO_PC[key_name]
131		self.key_gravity_blend = key_gravity_blend
132		self.nir_strength = nir_strength
133		self.root_diversity = root_diversity
134		self.minor_turnaround_weight = minor_turnaround_weight
135
136
137		if isinstance(graph_style, str):
138			chord_graph = _resolve_graph_style(graph_style, include_dominant_7th, minor_turnaround_weight)
139
140		else:
141			chord_graph = graph_style
142
143		self.graph, tonic = chord_graph.build(key_name)
144		self._diatonic_chords, self._function_chords = chord_graph.gravity_sets(key_name)
145
146		self.rng = rng or random.Random()
147		self.current_chord = tonic
148		self.history: typing.List[subsequence.chords.Chord] = []
149
150
151	def _calculate_nir_score (self, source: subsequence.chords.Chord, target: subsequence.chords.Chord) -> float:
152
153		"""
154		Calculate a Narmour Implication-Realization (NIR) score for a transition.
155		Returns a multiplier (default 1.0, >1.0 for boost).
156		"""
157
158		if not self.history:
159			return 1.0
160
161		prev = self.history[-1]
162
163		# Calculate interval from Prev -> Source (The "Implication" generator)
164		# Using shortest-path distance in Pitch Class space (-6 to +6)
165		prev_diff = (source.root_pc - prev.root_pc) % 12
166		if prev_diff > 6:
167			prev_diff -= 12
168
169		prev_interval = abs(prev_diff)
170		prev_direction = 1 if prev_diff > 0 else -1 if prev_diff < 0 else 0
171
172		# Calculate interval from Source -> Target (The "Realization")
173		target_diff = (target.root_pc - source.root_pc) % 12
174		if target_diff > 6:
175			target_diff -= 12
176
177		target_interval = abs(target_diff)
178		target_direction = 1 if target_diff > 0 else -1 if target_diff < 0 else 0
179
180		score = 1.0
181
182		# --- Rule A: Reversal (Gap Fill) ---
183		# If previous was a Large Leap (> 4 semitones like P4, P5, m6), expect direction change.
184		if prev_interval > 4:
185			# Expect change in direction
186			if target_direction != prev_direction and target_direction != 0:
187				score += 0.5
188
189			# Expect smaller interval (Gap Fill)
190			if target_interval < 4:
191				score += 0.3
192
193		# --- Rule B: Process (Continuation/Inertia) ---
194		# If previous was Small Step (< 3 semitones), expect similarity.
195		elif prev_interval > 0 and prev_interval < 3:
196			# Expect same direction
197			if target_direction == prev_direction:
198				score += 0.4
199
200			# Expect similar size
201			if abs(target_interval - prev_interval) <= 1:
202				score += 0.2
203
204		# --- Rule C: Closure ---
205		# Return to Tonic (Closure) is often implied after tension
206		if target.root_pc == self.key_root_pc:
207			score += 0.2
208
209		# --- Rule D: Proximity ---
210		# General preference for small intervals (≤ 3 semitones).
211		if target_interval > 0 and target_interval <= 3:
212			score += 0.3
213
214		# Scale the boost portion by nir_strength (score starts at 1.0, boost is the excess)
215		return 1.0 + (score - 1.0) * self.nir_strength
216
217	def step (self) -> subsequence.chords.Chord:
218
219		"""Advance to the next chord based on the transition graph."""
220
221		# Update history before choosing next (so structure tracks the path)
222		self.history.append(self.current_chord)
223		if len(self.history) > 4:
224			self.history.pop(0)
225
226		def weight_modifier (
227			source: subsequence.chords.Chord,
228			target: subsequence.chords.Chord,
229			weight: int
230		) -> float:
231
232			"""
233			Combine three forces that shape chord transition probabilities:
234
235			1. **Key gravity** — blends functional pull (tonic, dominant) with
236			   full diatonic pull, controlled by ``key_gravity_blend``.
237			2. **Melodic inertia (NIR)** — Narmour's cognitive expectation
238			   model favoring continuation after small steps and reversal
239			   after large leaps, controlled by ``nir_strength``.
240			3. **Root diversity** — exponential damping that discourages
241			   revisiting a root pitch class heard recently, controlled by
242			   ``root_diversity``. Each recent chord sharing the target's
243			   root multiplies the weight by ``root_diversity`` (default
244			   0.4), so the penalty grows stronger with each consecutive
245			   same-root step.
246
247			The final modifier is:
248
249				``(1 + gravity_boost) × nir_score × diversity``
250			"""
251
252			is_function = 1.0 if target in self._function_chords else 0.0
253			is_diatonic = 1.0 if target in self._diatonic_chords else 0.0
254
255			# Decision path: blend controls whether key gravity favors functional or full diatonic chords.
256			boost = (1.0 - self.key_gravity_blend) * is_function + self.key_gravity_blend * is_diatonic
257
258			# Apply NIR gravity
259			nir_score = self._calculate_nir_score(source, target)
260
261			# Root diversity: penalise transitions to a root heard recently.
262			recent_same_root = sum(
263				1 for h in self.history
264				if h.root_pc == target.root_pc
265			)
266			diversity = self.root_diversity ** recent_same_root
267
268			return (1.0 + boost) * nir_score * diversity
269
270		# Decision path: chord changes occur here; key changes are not automatic.
271		self.current_chord = self.graph.choose_next(self.current_chord, self.rng, weight_modifier=weight_modifier)
272
273		return self.current_chord
274
275
276	def get_current_chord (self) -> subsequence.chords.Chord:
277
278		"""Return the current chord."""
279
280		return self.current_chord
281
282
283	def get_key_name (self) -> str:
284
285		"""Return the current key name."""
286
287		return self.key_name
288
289
290	def get_chord_root_midi (self, base_midi: int, chord: subsequence.chords.Chord) -> int:
291
292		"""Calculate the MIDI root for a chord relative to the key root."""
293
294		offset = (chord.root_pc - self.key_root_pc) % 12
295
296		return base_midi + offset

Holds the current chord and key context for the composition.

HarmonicState( key_name: str, graph_style: Union[str, subsequence.chord_graphs.ChordGraph] = 'functional_major', include_dominant_7th: bool = True, key_gravity_blend: float = 1.0, nir_strength: float = 0.5, minor_turnaround_weight: float = 0.0, root_diversity: float = 0.4, rng: Optional[random.Random] = None)
 84	def __init__ (
 85		self,
 86		key_name: str,
 87		graph_style: typing.Union[str, subsequence.chord_graphs.ChordGraph] = "functional_major",
 88		include_dominant_7th: bool = True,
 89		key_gravity_blend: float = 1.0,
 90		nir_strength: float = 0.5,
 91		minor_turnaround_weight: float = 0.0,
 92		root_diversity: float = DEFAULT_ROOT_DIVERSITY,
 93		rng: typing.Optional[random.Random] = None
 94	) -> None:
 95
 96		"""
 97		Initialize the harmonic state using a chord transition graph.
 98
 99		Parameters:
100			key_name: Note name for the key (e.g., ``"C"``, ``"F#"``).
101			graph_style: Built-in style name or a custom ``ChordGraph`` instance.
102			include_dominant_7th: Include V7 chords in the graph (default True).
103			key_gravity_blend: Balance between functional and diatonic gravity
104				(0.0 = functional only, 1.0 = full diatonic). Default 1.0.
105			nir_strength: Melodic inertia from Narmour's Implication-Realization
106				model (0.0 = off, 1.0 = full). Default 0.5.
107			minor_turnaround_weight: For turnaround style, weight toward minor
108				turnarounds (0.0 to 1.0). Default 0.0.
109			root_diversity: Root-repetition damping factor (0.0 to 1.0). Each
110				recent chord sharing a candidate's root pitch class multiplies
111				the transition weight by this factor. At the default (0.4), one
112				recent same-root chord reduces the weight to 40%; two reduce it
113				to 16%. Set to 1.0 to disable the penalty entirely. Default 0.4.
114			rng: Optional seeded ``random.Random`` for deterministic playback.
115		"""
116
117		if key_gravity_blend < 0 or key_gravity_blend > 1:
118			raise ValueError("Key gravity blend must be between 0 and 1")
119
120		if nir_strength < 0 or nir_strength > 1:
121			raise ValueError("NIR strength must be between 0 and 1")
122
123		if minor_turnaround_weight < 0 or minor_turnaround_weight > 1:
124			raise ValueError("Minor turnaround weight must be between 0 and 1")
125
126		if root_diversity < 0 or root_diversity > 1:
127			raise ValueError("Root diversity must be between 0 and 1")
128
129		self.key_name = key_name
130		self.key_root_pc = subsequence.chords.NOTE_NAME_TO_PC[key_name]
131		self.key_gravity_blend = key_gravity_blend
132		self.nir_strength = nir_strength
133		self.root_diversity = root_diversity
134		self.minor_turnaround_weight = minor_turnaround_weight
135
136
137		if isinstance(graph_style, str):
138			chord_graph = _resolve_graph_style(graph_style, include_dominant_7th, minor_turnaround_weight)
139
140		else:
141			chord_graph = graph_style
142
143		self.graph, tonic = chord_graph.build(key_name)
144		self._diatonic_chords, self._function_chords = chord_graph.gravity_sets(key_name)
145
146		self.rng = rng or random.Random()
147		self.current_chord = tonic
148		self.history: typing.List[subsequence.chords.Chord] = []

Initialize the harmonic state using a chord transition graph.

Arguments:
  • key_name: Note name for the key (e.g., "C", "F#").
  • graph_style: Built-in style name or a custom ChordGraph instance.
  • include_dominant_7th: Include V7 chords in the graph (default True).
  • key_gravity_blend: Balance between functional and diatonic gravity (0.0 = functional only, 1.0 = full diatonic). Default 1.0.
  • nir_strength: Melodic inertia from Narmour's Implication-Realization model (0.0 = off, 1.0 = full). Default 0.5.
  • minor_turnaround_weight: For turnaround style, weight toward minor turnarounds (0.0 to 1.0). Default 0.0.
  • root_diversity: Root-repetition damping factor (0.0 to 1.0). Each recent chord sharing a candidate's root pitch class multiplies the transition weight by this factor. At the default (0.4), one recent same-root chord reduces the weight to 40%; two reduce it to 16%. Set to 1.0 to disable the penalty entirely. Default 0.4.
  • rng: Optional seeded random.Random for deterministic playback.
key_name
key_root_pc
key_gravity_blend
nir_strength
root_diversity
minor_turnaround_weight
rng
current_chord
history: List[subsequence.chords.Chord]
def step(self) -> subsequence.chords.Chord:
217	def step (self) -> subsequence.chords.Chord:
218
219		"""Advance to the next chord based on the transition graph."""
220
221		# Update history before choosing next (so structure tracks the path)
222		self.history.append(self.current_chord)
223		if len(self.history) > 4:
224			self.history.pop(0)
225
226		def weight_modifier (
227			source: subsequence.chords.Chord,
228			target: subsequence.chords.Chord,
229			weight: int
230		) -> float:
231
232			"""
233			Combine three forces that shape chord transition probabilities:
234
235			1. **Key gravity** — blends functional pull (tonic, dominant) with
236			   full diatonic pull, controlled by ``key_gravity_blend``.
237			2. **Melodic inertia (NIR)** — Narmour's cognitive expectation
238			   model favoring continuation after small steps and reversal
239			   after large leaps, controlled by ``nir_strength``.
240			3. **Root diversity** — exponential damping that discourages
241			   revisiting a root pitch class heard recently, controlled by
242			   ``root_diversity``. Each recent chord sharing the target's
243			   root multiplies the weight by ``root_diversity`` (default
244			   0.4), so the penalty grows stronger with each consecutive
245			   same-root step.
246
247			The final modifier is:
248
249				``(1 + gravity_boost) × nir_score × diversity``
250			"""
251
252			is_function = 1.0 if target in self._function_chords else 0.0
253			is_diatonic = 1.0 if target in self._diatonic_chords else 0.0
254
255			# Decision path: blend controls whether key gravity favors functional or full diatonic chords.
256			boost = (1.0 - self.key_gravity_blend) * is_function + self.key_gravity_blend * is_diatonic
257
258			# Apply NIR gravity
259			nir_score = self._calculate_nir_score(source, target)
260
261			# Root diversity: penalise transitions to a root heard recently.
262			recent_same_root = sum(
263				1 for h in self.history
264				if h.root_pc == target.root_pc
265			)
266			diversity = self.root_diversity ** recent_same_root
267
268			return (1.0 + boost) * nir_score * diversity
269
270		# Decision path: chord changes occur here; key changes are not automatic.
271		self.current_chord = self.graph.choose_next(self.current_chord, self.rng, weight_modifier=weight_modifier)
272
273		return self.current_chord

Advance to the next chord based on the transition graph.

def get_current_chord(self) -> subsequence.chords.Chord:
276	def get_current_chord (self) -> subsequence.chords.Chord:
277
278		"""Return the current chord."""
279
280		return self.current_chord

Return the current chord.

def get_key_name(self) -> str:
283	def get_key_name (self) -> str:
284
285		"""Return the current key name."""
286
287		return self.key_name

Return the current key name.

def get_chord_root_midi(self, base_midi: int, chord: subsequence.chords.Chord) -> int:
290	def get_chord_root_midi (self, base_midi: int, chord: subsequence.chords.Chord) -> int:
291
292		"""Calculate the MIDI root for a chord relative to the key root."""
293
294		offset = (chord.root_pc - self.key_root_pc) % 12
295
296		return base_midi + offset

Calculate the MIDI root for a chord relative to the key root.