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
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.
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
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.
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.
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().
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).
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.
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.
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.