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

Advance to the next chord based on the transition graph.

def plan_next(self) -> subsequence.Chord:
296	def plan_next (self) -> subsequence.chords.Chord:
297
298		"""Choose the next chord without committing it — the horizon's pre-step.
299
300		Draws from the RNG exactly as :meth:`step` would (the draw IS the
301		pre-commitment), but leaves ``current_chord`` and ``history``
302		untouched.  Pair with :meth:`commit_chord` when the planned chord
303		becomes the sounding one; ``commit_chord(plan_next())`` is draw-for-
304		draw equivalent to ``step()``.
305		"""
306
307		saved_history = list(self.history)
308
309		self._record_transition_source(self.current_chord)
310
311		try:
312			return self.graph.choose_next(self.current_chord, self.rng, weight_modifier=self._transition_weight)
313		finally:
314			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:
316	def commit_chord (self, chord: subsequence.chords.Chord) -> subsequence.chords.Chord:
317
318		"""Make *chord* current with step()'s history bookkeeping, no RNG draw.
319
320		Used by the harmonic clock to commit a planned chord or replay a
321		frozen progression span while keeping NIR context coherent (the
322		outgoing chord enters history as the transition source, exactly as
323		``step()`` records it).
324		"""
325
326		self._record_transition_source(self.current_chord)
327
328		self.current_chord = chord
329
330		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:
333	def get_current_chord (self) -> subsequence.chords.Chord:
334
335		"""Return the current chord."""
336
337		return self.current_chord

Return the current chord.

def get_key_name(self) -> str:
340	def get_key_name (self) -> str:
341
342		"""Return the current key name."""
343
344		return self.key_name

Return the current key name.

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

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