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
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.
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
ChordGraphinstance. - 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.Randomfor deterministic playback.
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.
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().
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).
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.
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.
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.