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