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.key_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		# step() appends the current chord to history BEFORE choosing, so
158		# history[-1] is always the source itself; the implication interval
159		# needs the chord we arrived FROM, which is history[-2].  (Using
160		# history[-1] here was the pre-2026-06 bug that left the reversal
161		# and continuation rules permanently inert.)
162		if len(self.history) < 2:
163			return 1.0
164
165		prev = self.history[-2]
166
167		# Calculate interval from Prev -> Source (The "Implication" generator)
168		# Using shortest-path distance in Pitch Class space (-6 to +6)
169		prev_diff = (source.root_pc - prev.root_pc) % 12
170		if prev_diff > 6:
171			prev_diff -= 12
172
173		prev_interval = abs(prev_diff)
174		prev_direction = 1 if prev_diff > 0 else -1 if prev_diff < 0 else 0
175
176		# Calculate interval from Source -> Target (The "Realization")
177		target_diff = (target.root_pc - source.root_pc) % 12
178		if target_diff > 6:
179			target_diff -= 12
180
181		target_interval = abs(target_diff)
182		target_direction = 1 if target_diff > 0 else -1 if target_diff < 0 else 0
183
184		score = 1.0
185
186		# --- Rule A: Reversal (Gap Fill) ---
187		# If the previous step was a large leap (> 4 on the 0–6 pitch-class
188		# shortest-path scale, where a tritone is 6), expect a direction change.
189		if prev_interval > 4:
190			# Expect change in direction
191			if target_direction != prev_direction and target_direction != 0:
192				score += 0.5
193
194			# Expect smaller interval (Gap Fill)
195			if target_interval < 4:
196				score += 0.3
197
198		# --- Rule B: Process (Continuation/Inertia) ---
199		# If previous was Small Step (< 3 semitones), expect similarity.
200		elif prev_interval > 0 and prev_interval < 3:
201			# Expect same direction
202			if target_direction == prev_direction:
203				score += 0.4
204
205			# Expect similar size
206			if abs(target_interval - prev_interval) <= 1:
207				score += 0.2
208
209		# --- Rule C: Closure ---
210		# Return to Tonic (Closure) is often implied after tension
211		if target.root_pc == self.key_root_pc:
212			score += 0.2
213
214		# --- Rule D: Proximity ---
215		# General preference for small intervals (≤ 3 semitones).
216		if target_interval > 0 and target_interval <= 3:
217			score += 0.3
218
219		# Scale the boost portion by nir_strength (score starts at 1.0, boost is the excess)
220		return 1.0 + (score - 1.0) * self.nir_strength
221
222	def _transition_weight (
223		self,
224		source: subsequence.chords.Chord,
225		target: subsequence.chords.Chord,
226		weight: int
227	) -> float:
228
229		"""
230		Combine three forces that shape chord transition probabilities:
231
232		1. **Key gravity** — blends functional pull (tonic, dominant) with
233		   full diatonic pull, controlled by ``key_gravity_blend``.
234		2. **Melodic inertia (NIR)** — Narmour's cognitive expectation
235		   model favoring continuation after small steps and reversal
236		   after large leaps, controlled by ``nir_strength``.
237		3. **Root diversity** — exponential damping that discourages
238		   revisiting a root pitch class heard recently, controlled by
239		   ``root_diversity``. Each recent chord sharing the target's
240		   root multiplies the weight by ``root_diversity`` (default
241		   0.4), so the penalty grows stronger with each consecutive
242		   same-root step.
243
244		The final modifier is:
245
246			``(1 + gravity_boost) × nir_score × diversity``
247		"""
248
249		is_function = 1.0 if target in self._function_chords else 0.0
250		is_diatonic = 1.0 if target in self._diatonic_chords else 0.0
251
252		# Decision path: blend controls whether key gravity favors functional or full diatonic chords.
253		boost = (1.0 - self.key_gravity_blend) * is_function + self.key_gravity_blend * is_diatonic
254
255		# Apply NIR gravity
256		nir_score = self._calculate_nir_score(source, target)
257
258		# Root diversity: penalise transitions to a root heard recently.
259		recent_same_root = sum(
260			1 for h in self.history
261			if h.root_pc == target.root_pc
262		)
263		diversity = self.root_diversity ** recent_same_root
264
265		return (1.0 + boost) * nir_score * diversity
266
267	def _record_transition_source (self, chord: subsequence.chords.Chord) -> None:
268
269		"""History bookkeeping for one transition: the outgoing chord enters history.
270
271		The first half of :meth:`step` — exposed so a constrained walk can
272		interleave it with its own draws (``before_choice``) and the NIR
273		weighting sees exactly the context it would live.
274		"""
275
276		self.history.append(chord)
277		if len(self.history) > 4:
278			self.history.pop(0)
279
280	def step (self) -> subsequence.chords.Chord:
281
282		"""Advance to the next chord based on the transition graph."""
283
284		# Update history before choosing next (so structure tracks the path)
285		self._record_transition_source(self.current_chord)
286
287		# Decision path: chord changes occur here; key changes are not automatic.
288		self.current_chord = self.graph.choose_next(self.current_chord, self.rng, weight_modifier=self._transition_weight)
289
290		return self.current_chord
291
292	def plan_next (self) -> subsequence.chords.Chord:
293
294		"""Choose the next chord without committing it — the horizon's pre-step.
295
296		Draws from the RNG exactly as :meth:`step` would (the draw IS the
297		pre-commitment), but leaves ``current_chord`` and ``history``
298		untouched.  Pair with :meth:`commit_chord` when the planned chord
299		becomes the sounding one; ``commit_chord(plan_next())`` is draw-for-
300		draw equivalent to ``step()``.
301		"""
302
303		saved_history = list(self.history)
304
305		self._record_transition_source(self.current_chord)
306
307		try:
308			return self.graph.choose_next(self.current_chord, self.rng, weight_modifier=self._transition_weight)
309		finally:
310			self.history = saved_history
311
312	def commit_chord (self, chord: subsequence.chords.Chord) -> subsequence.chords.Chord:
313
314		"""Make *chord* current with step()'s history bookkeeping, no RNG draw.
315
316		Used by the harmonic clock to commit a planned chord or replay a
317		frozen progression span while keeping NIR context coherent (the
318		outgoing chord enters history as the transition source, exactly as
319		``step()`` records it).
320		"""
321
322		self._record_transition_source(self.current_chord)
323
324		self.current_chord = chord
325
326		return self.current_chord
327
328
329	def get_current_chord (self) -> subsequence.chords.Chord:
330
331		"""Return the current chord."""
332
333		return self.current_chord
334
335
336	def get_key_name (self) -> str:
337
338		"""Return the current key name."""
339
340		return self.key_name
341
342
343	def get_chord_root_midi (self, base_midi: int, chord: subsequence.chords.Chord) -> int:
344
345		"""Calculate the MIDI root for a chord relative to the key root."""
346
347		offset = (chord.root_pc - self.key_root_pc) % 12
348
349		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.key_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		# step() appends the current chord to history BEFORE choosing, so
159		# history[-1] is always the source itself; the implication interval
160		# needs the chord we arrived FROM, which is history[-2].  (Using
161		# history[-1] here was the pre-2026-06 bug that left the reversal
162		# and continuation rules permanently inert.)
163		if len(self.history) < 2:
164			return 1.0
165
166		prev = self.history[-2]
167
168		# Calculate interval from Prev -> Source (The "Implication" generator)
169		# Using shortest-path distance in Pitch Class space (-6 to +6)
170		prev_diff = (source.root_pc - prev.root_pc) % 12
171		if prev_diff > 6:
172			prev_diff -= 12
173
174		prev_interval = abs(prev_diff)
175		prev_direction = 1 if prev_diff > 0 else -1 if prev_diff < 0 else 0
176
177		# Calculate interval from Source -> Target (The "Realization")
178		target_diff = (target.root_pc - source.root_pc) % 12
179		if target_diff > 6:
180			target_diff -= 12
181
182		target_interval = abs(target_diff)
183		target_direction = 1 if target_diff > 0 else -1 if target_diff < 0 else 0
184
185		score = 1.0
186
187		# --- Rule A: Reversal (Gap Fill) ---
188		# If the previous step was a large leap (> 4 on the 0–6 pitch-class
189		# shortest-path scale, where a tritone is 6), expect a direction change.
190		if prev_interval > 4:
191			# Expect change in direction
192			if target_direction != prev_direction and target_direction != 0:
193				score += 0.5
194
195			# Expect smaller interval (Gap Fill)
196			if target_interval < 4:
197				score += 0.3
198
199		# --- Rule B: Process (Continuation/Inertia) ---
200		# If previous was Small Step (< 3 semitones), expect similarity.
201		elif prev_interval > 0 and prev_interval < 3:
202			# Expect same direction
203			if target_direction == prev_direction:
204				score += 0.4
205
206			# Expect similar size
207			if abs(target_interval - prev_interval) <= 1:
208				score += 0.2
209
210		# --- Rule C: Closure ---
211		# Return to Tonic (Closure) is often implied after tension
212		if target.root_pc == self.key_root_pc:
213			score += 0.2
214
215		# --- Rule D: Proximity ---
216		# General preference for small intervals (≤ 3 semitones).
217		if target_interval > 0 and target_interval <= 3:
218			score += 0.3
219
220		# Scale the boost portion by nir_strength (score starts at 1.0, boost is the excess)
221		return 1.0 + (score - 1.0) * self.nir_strength
222
223	def _transition_weight (
224		self,
225		source: subsequence.chords.Chord,
226		target: subsequence.chords.Chord,
227		weight: int
228	) -> float:
229
230		"""
231		Combine three forces that shape chord transition probabilities:
232
233		1. **Key gravity** — blends functional pull (tonic, dominant) with
234		   full diatonic pull, controlled by ``key_gravity_blend``.
235		2. **Melodic inertia (NIR)** — Narmour's cognitive expectation
236		   model favoring continuation after small steps and reversal
237		   after large leaps, controlled by ``nir_strength``.
238		3. **Root diversity** — exponential damping that discourages
239		   revisiting a root pitch class heard recently, controlled by
240		   ``root_diversity``. Each recent chord sharing the target's
241		   root multiplies the weight by ``root_diversity`` (default
242		   0.4), so the penalty grows stronger with each consecutive
243		   same-root step.
244
245		The final modifier is:
246
247			``(1 + gravity_boost) × nir_score × diversity``
248		"""
249
250		is_function = 1.0 if target in self._function_chords else 0.0
251		is_diatonic = 1.0 if target in self._diatonic_chords else 0.0
252
253		# Decision path: blend controls whether key gravity favors functional or full diatonic chords.
254		boost = (1.0 - self.key_gravity_blend) * is_function + self.key_gravity_blend * is_diatonic
255
256		# Apply NIR gravity
257		nir_score = self._calculate_nir_score(source, target)
258
259		# Root diversity: penalise transitions to a root heard recently.
260		recent_same_root = sum(
261			1 for h in self.history
262			if h.root_pc == target.root_pc
263		)
264		diversity = self.root_diversity ** recent_same_root
265
266		return (1.0 + boost) * nir_score * diversity
267
268	def _record_transition_source (self, chord: subsequence.chords.Chord) -> None:
269
270		"""History bookkeeping for one transition: the outgoing chord enters history.
271
272		The first half of :meth:`step` — exposed so a constrained walk can
273		interleave it with its own draws (``before_choice``) and the NIR
274		weighting sees exactly the context it would live.
275		"""
276
277		self.history.append(chord)
278		if len(self.history) > 4:
279			self.history.pop(0)
280
281	def step (self) -> subsequence.chords.Chord:
282
283		"""Advance to the next chord based on the transition graph."""
284
285		# Update history before choosing next (so structure tracks the path)
286		self._record_transition_source(self.current_chord)
287
288		# Decision path: chord changes occur here; key changes are not automatic.
289		self.current_chord = self.graph.choose_next(self.current_chord, self.rng, weight_modifier=self._transition_weight)
290
291		return self.current_chord
292
293	def plan_next (self) -> subsequence.chords.Chord:
294
295		"""Choose the next chord without committing it — the horizon's pre-step.
296
297		Draws from the RNG exactly as :meth:`step` would (the draw IS the
298		pre-commitment), but leaves ``current_chord`` and ``history``
299		untouched.  Pair with :meth:`commit_chord` when the planned chord
300		becomes the sounding one; ``commit_chord(plan_next())`` is draw-for-
301		draw equivalent to ``step()``.
302		"""
303
304		saved_history = list(self.history)
305
306		self._record_transition_source(self.current_chord)
307
308		try:
309			return self.graph.choose_next(self.current_chord, self.rng, weight_modifier=self._transition_weight)
310		finally:
311			self.history = saved_history
312
313	def commit_chord (self, chord: subsequence.chords.Chord) -> subsequence.chords.Chord:
314
315		"""Make *chord* current with step()'s history bookkeeping, no RNG draw.
316
317		Used by the harmonic clock to commit a planned chord or replay a
318		frozen progression span while keeping NIR context coherent (the
319		outgoing chord enters history as the transition source, exactly as
320		``step()`` records it).
321		"""
322
323		self._record_transition_source(self.current_chord)
324
325		self.current_chord = chord
326
327		return self.current_chord
328
329
330	def get_current_chord (self) -> subsequence.chords.Chord:
331
332		"""Return the current chord."""
333
334		return self.current_chord
335
336
337	def get_key_name (self) -> str:
338
339		"""Return the current key name."""
340
341		return self.key_name
342
343
344	def get_chord_root_midi (self, base_midi: int, chord: subsequence.chords.Chord) -> int:
345
346		"""Calculate the MIDI root for a chord relative to the key root."""
347
348		offset = (chord.root_pc - self.key_root_pc) % 12
349
350		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.key_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.Chord]
def step(self) -> subsequence.Chord:
281	def step (self) -> subsequence.chords.Chord:
282
283		"""Advance to the next chord based on the transition graph."""
284
285		# Update history before choosing next (so structure tracks the path)
286		self._record_transition_source(self.current_chord)
287
288		# Decision path: chord changes occur here; key changes are not automatic.
289		self.current_chord = self.graph.choose_next(self.current_chord, self.rng, weight_modifier=self._transition_weight)
290
291		return self.current_chord

Advance to the next chord based on the transition graph.

def plan_next(self) -> subsequence.Chord:
293	def plan_next (self) -> subsequence.chords.Chord:
294
295		"""Choose the next chord without committing it — the horizon's pre-step.
296
297		Draws from the RNG exactly as :meth:`step` would (the draw IS the
298		pre-commitment), but leaves ``current_chord`` and ``history``
299		untouched.  Pair with :meth:`commit_chord` when the planned chord
300		becomes the sounding one; ``commit_chord(plan_next())`` is draw-for-
301		draw equivalent to ``step()``.
302		"""
303
304		saved_history = list(self.history)
305
306		self._record_transition_source(self.current_chord)
307
308		try:
309			return self.graph.choose_next(self.current_chord, self.rng, weight_modifier=self._transition_weight)
310		finally:
311			self.history = saved_history

Choose the next chord without committing it — the horizon's pre-step.

Draws from the RNG exactly as step() would (the draw IS the pre-commitment), but leaves current_chord and history untouched. Pair with commit_chord() when the planned chord becomes the sounding one; commit_chord(plan_next()) is draw-for- draw equivalent to step().

def commit_chord(self, chord: subsequence.Chord) -> subsequence.Chord:
313	def commit_chord (self, chord: subsequence.chords.Chord) -> subsequence.chords.Chord:
314
315		"""Make *chord* current with step()'s history bookkeeping, no RNG draw.
316
317		Used by the harmonic clock to commit a planned chord or replay a
318		frozen progression span while keeping NIR context coherent (the
319		outgoing chord enters history as the transition source, exactly as
320		``step()`` records it).
321		"""
322
323		self._record_transition_source(self.current_chord)
324
325		self.current_chord = chord
326
327		return self.current_chord

Make chord current with step()'s history bookkeeping, no RNG draw.

Used by the harmonic clock to commit a planned chord or replay a frozen progression span while keeping NIR context coherent (the outgoing chord enters history as the transition source, exactly as step() records it).

def get_current_chord(self) -> subsequence.Chord:
330	def get_current_chord (self) -> subsequence.chords.Chord:
331
332		"""Return the current chord."""
333
334		return self.current_chord

Return the current chord.

def get_key_name(self) -> str:
337	def get_key_name (self) -> str:
338
339		"""Return the current key name."""
340
341		return self.key_name

Return the current key name.

def get_chord_root_midi(self, base_midi: int, chord: subsequence.Chord) -> int:
344	def get_chord_root_midi (self, base_midi: int, chord: subsequence.chords.Chord) -> int:
345
346		"""Calculate the MIDI root for a chord relative to the key root."""
347
348		offset = (chord.root_pc - self.key_root_pc) % 12
349
350		return base_midi + offset

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