midi_drums
MIDI Drums - Comprehensive drum track generation system.
1"""MIDI Drums - Comprehensive drum track generation system.""" 2 3from midi_drums.core.models.pattern import Beat, Pattern 4from midi_drums.core.models.song import Section, Song 5from midi_drums.core.value_objects.generation_parameters import ( 6 GenerationParameters, 7) 8from midi_drums.core.value_objects.time_signature import TimeSignature 9from midi_drums.generation.engines.drum_generator import DrumGenerator 10 11__version__ = "0.4.0-alpha.1" 12__all__ = [ 13 "DrumGenerator", 14 "Pattern", 15 "Beat", 16 "TimeSignature", 17 "Song", 18 "Section", 19 "GenerationParameters", 20]
19class DrumGenerator: 20 """Main drum generation engine.""" 21 22 def __init__(self, config_path: Path | None = None): 23 """Initialize drum generator with optional configuration.""" 24 self.plugin_manager = PluginManager() 25 self.drum_kit = DrumKit.create_ezdrummer3_kit() 26 self.midi_engine = MIDIEngine(self.drum_kit) 27 28 # Load plugins 29 self._load_plugins() 30 31 def _load_plugins(self) -> None: 32 """Load all available plugins.""" 33 try: 34 self.plugin_manager.discover_plugins() 35 logger.info( 36 f"Loaded genres: {self.plugin_manager.get_available_genres()}" 37 ) 38 logger.info( 39 f"Loaded drummers: " 40 f"{self.plugin_manager.get_available_drummers()}" 41 ) 42 except Exception as e: 43 logger.error(f"Failed to load plugins: {e}") 44 45 def create_song( 46 self, 47 genre: str, 48 style: str = "default", 49 tempo: int = 120, 50 structure: list[tuple[str, int]] | None = None, 51 drum_kit: DrumKit | None = None, 52 **kwargs, 53 ) -> Song: 54 """Create a complete song structure. 55 56 Args: 57 genre: Genre name (e.g., 'metal', 'rock', 'jazz') 58 style: Style within genre (e.g., 'death', 'power' for metal) 59 tempo: Tempo in BPM 60 structure: List of (section_name, bars) tuples. If None, uses 61 default structure. 62 drum_kit: Optional DrumKit for MIDI mapping. If None, uses 63 current kit. 64 **kwargs: Additional parameters for GenerationParameters 65 66 Returns: 67 Complete Song object with generated patterns 68 """ 69 # Update MIDI engine if new drum kit provided 70 if drum_kit: 71 self.midi_engine = MIDIEngine(drum_kit) 72 self.drum_kit = drum_kit 73 74 # Create generation parameters 75 params = GenerationParameters(genre=genre, style=style, **kwargs) 76 77 # Use default structure if none provided 78 if structure is None: 79 structure = [ 80 ("intro", 4), 81 ("verse", 8), 82 ("chorus", 8), 83 ("verse", 8), 84 ("chorus", 8), 85 ("bridge", 4), 86 ("chorus", 8), 87 ("outro", 4), 88 ] 89 90 # Create song with basic structure 91 song = Song( 92 name=f"{genre}_{style}_song", tempo=tempo, global_parameters=params 93 ) 94 95 # Generate patterns for each section 96 for section_name, bars in structure: 97 pattern = self.generate_pattern( 98 genre, section_name, bars, style=style, **kwargs 99 ) 100 if pattern: 101 section = Section(section_name, pattern, bars) 102 103 # Add variations and fills based on complexity 104 if params.complexity > 0.5: 105 variations = self._generate_variations(pattern, params) 106 section.variations.extend(variations) 107 108 fills = self._generate_fills(genre, params) 109 section.fills.extend(fills) 110 111 song.add_section(section) 112 else: 113 logger.warning( 114 f"Failed to generate pattern for {genre}/{section_name}" 115 ) 116 117 return song 118 119 def generate_pattern( 120 self, genre: str, section: str = "verse", bars: int = 4, **kwargs 121 ) -> Pattern | None: 122 """Generate a single pattern with optional genre context adaptation. 123 124 Args: 125 genre: Genre name 126 section: Section type 127 bars: Number of bars (for multi-bar patterns) 128 **kwargs: Additional generation parameters including: 129 - song_genre_context: Overall song genre for adaptation 130 - context_blend: Blend amount (0.0-1.0) 131 - drummer: Drummer style to apply 132 - humanization: Humanization amount 133 - etc. 134 135 Returns: 136 Generated Pattern or None if generation failed 137 138 Example: 139 # Generate progressive pattern adapted to metal context 140 pattern = generator.generate_pattern( 141 genre="metal", 142 style="progressive", 143 section="bridge", 144 song_genre_context="metal", 145 context_blend=0.3 146 ) 147 """ 148 # Create parameters 149 params = GenerationParameters(genre=genre, **kwargs) 150 151 # Generate base pattern 152 pattern = self.plugin_manager.generate_pattern(genre, section, params) 153 if not pattern: 154 return None 155 156 # Apply genre context blending if specified 157 if params.song_genre_context and params.context_blend > 0: 158 # Only blend if context genre is different from pattern genre 159 if params.song_genre_context != genre: 160 context_plugin = self.plugin_manager.get_genre_plugin( 161 params.song_genre_context 162 ) 163 genre_plugin = self.plugin_manager.get_genre_plugin(genre) 164 165 if context_plugin and genre_plugin: 166 context_profile = context_plugin.intensity_profile 167 pattern = genre_plugin.apply_context_blend( 168 pattern, context_profile, params.context_blend 169 ) 170 logger.debug( 171 f"Applied {params.song_genre_context} context " 172 f"(blend={params.context_blend}) to {genre} pattern" 173 ) 174 175 # Apply drummer style if specified 176 if params.drummer: 177 styled_pattern = self.plugin_manager.apply_drummer_style( 178 pattern, params.drummer, params.drummer_intensity 179 ) 180 if styled_pattern: 181 pattern = styled_pattern 182 183 # Apply riff-lock if riff accents were supplied - snaps/inserts 184 # kicks onto the riff's rhythmic accents (issue: audio-riff-driven 185 # beat generation). Runs after drummer styling so it operates on 186 # the already-styled kick pattern, before humanization so the 187 # subsequent humanize() call still re-jitters kick timing like it 188 # does for every other beat (a tight lock needs humanization=0 - 189 # this is documented, not special-cased). Routed through 190 # plugin_manager rather than importing 191 # midi_drums.modifications.riff_lock directly - the generation 192 # domain isn't allowed to depend on modifications (see 193 # tests/unit/generation/test_generation_domain_migration.py), 194 # the same reason apply_drummer_style() above is a plugin_manager 195 # call rather than a direct modifications import. 196 if params.riff_accents: 197 locked_pattern = self.plugin_manager.apply_riff_lock( 198 pattern, params.riff_accents, params.riff_lock_strength 199 ) 200 if locked_pattern: 201 pattern = locked_pattern 202 203 # Apply snare-accent-reaction if requested - reinforce or stab the 204 # snare against the same riff accents (see 205 # midi_drums.modifications.snare_accent_reaction.SnareAccentReaction). 206 # Runs after riff-lock so "stab" can unison-match against the kicks 207 # riff-lock just placed; gated on mode != "off" so nothing is 208 # constructed at all in the (default) off case. Same domain- 209 # boundary routing as riff-lock above. 210 if params.riff_accents and params.riff_snare_mode != "off": 211 reacted_pattern = self.plugin_manager.apply_riff_snare_accents( 212 pattern, 213 params.riff_accents, 214 params.riff_snare_mode, 215 params.riff_snare_stab_threshold, 216 ) 217 if reacted_pattern: 218 pattern = reacted_pattern 219 220 # Apply cymbal-accent-reaction if requested - reinforce or stab 221 # hi-hat/crash/ride/china against the same riff accents (see 222 # midi_drums.modifications.cymbal_accent_reaction.CymbalAccentReaction). 223 # Each kit piece is independently gated on its own mode != "off", 224 # so e.g. hi-hat can react while crash/ride/china stay off. Runs after 225 # riff-lock (same "stab" unison-match rationale as the snare block 226 # above) and independently of the snare block. Same domain- 227 # boundary routing as riff-lock/snare above. 228 if params.riff_accents: 229 for kit_piece, mode, stab_threshold in ( 230 ( 231 "hihat", 232 params.riff_hihat_mode, 233 params.riff_hihat_stab_threshold, 234 ), 235 ( 236 "crash", 237 params.riff_crash_mode, 238 params.riff_crash_stab_threshold, 239 ), 240 ( 241 "ride", 242 params.riff_ride_mode, 243 params.riff_ride_stab_threshold, 244 ), 245 ( 246 "china", 247 params.riff_china_mode, 248 params.riff_china_stab_threshold, 249 ), 250 ): 251 if mode == "off": 252 continue 253 reacted_pattern = self.plugin_manager.apply_riff_cymbal_accents( 254 pattern, 255 params.riff_accents, 256 kit_piece, 257 mode, 258 stab_threshold, 259 ) 260 if reacted_pattern: 261 pattern = reacted_pattern 262 263 # Apply humanization if requested 264 if params.humanization > 0: 265 timing_var = params.humanization * 0.05 # Scale to reasonable range 266 velocity_var = int(params.humanization * 20) 267 pattern = pattern.humanize(timing_var, velocity_var) 268 269 # Extend pattern for multiple bars if needed 270 if bars > 1: 271 pattern = self._extend_pattern_to_bars(pattern, bars) 272 273 return pattern 274 275 def apply_drummer_style( 276 self, pattern: Pattern, drummer: str, intensity: float = 1.0 277 ) -> Pattern | None: 278 """Apply drummer-specific style modifications to a pattern.""" 279 return self.plugin_manager.apply_drummer_style( 280 pattern, drummer, intensity 281 ) 282 283 def export_midi(self, song: Song, output_path: Path) -> None: 284 """Export song as MIDI file.""" 285 self.midi_engine.save_song_midi(song, output_path) 286 logger.info(f"Exported MIDI to: {output_path}") 287 288 def export_pattern_midi( 289 self, 290 pattern: Pattern, 291 output_path: Path, 292 tempo: int = 120, 293 drum_kit: DrumKit | None = None, 294 ) -> None: 295 """Export single pattern as MIDI file.""" 296 # Use provided drum kit or current one 297 engine = self.midi_engine 298 if drum_kit: 299 engine = MIDIEngine(drum_kit) 300 301 engine.save_pattern_midi(pattern, output_path, tempo) 302 logger.info(f"Exported pattern MIDI to: {output_path}") 303 304 def get_available_genres(self) -> list[str]: 305 """Get list of available genres.""" 306 return self.plugin_manager.get_available_genres() 307 308 def get_available_drummers(self) -> list[str]: 309 """Get list of available drummers.""" 310 return self.plugin_manager.get_available_drummers() 311 312 def get_styles_for_genre(self, genre: str) -> list[str]: 313 """Get available styles for a genre.""" 314 return self.plugin_manager.get_styles_for_genre(genre) 315 316 def get_song_info(self, song: Song) -> dict: 317 """Get comprehensive information about a song.""" 318 info = self.midi_engine.get_midi_info(song) 319 info.update( 320 { 321 "genre": ( 322 song.global_parameters.genre 323 if song.global_parameters 324 else "unknown" 325 ), 326 "style": ( 327 song.global_parameters.style 328 if song.global_parameters 329 else "default" 330 ), 331 "drummer": ( 332 song.global_parameters.drummer 333 if song.global_parameters 334 else None 335 ), 336 "sections_count": len(song.sections), 337 "unique_sections": list({s.name for s in song.sections}), 338 } 339 ) 340 return info 341 342 def set_drum_kit(self, kit: DrumKit) -> None: 343 """Set the drum kit configuration.""" 344 self.drum_kit = kit 345 self.midi_engine = MIDIEngine(kit) 346 347 def create_drum_kit(self, kit_type: str) -> DrumKit: 348 """Create a drum kit configuration by type.""" 349 kit_creators = { 350 "ezdrummer3": DrumKit.create_ezdrummer3_kit, 351 "metal": DrumKit.create_metal_kit, 352 "jazz": DrumKit.create_jazz_kit, 353 "standard": DrumKit.create_ezdrummer3_kit, # Alias 354 } 355 356 creator = kit_creators.get(kit_type.lower()) 357 if creator: 358 return creator() 359 else: 360 logger.warning(f"Unknown kit type: {kit_type}, using standard kit") 361 return DrumKit.create_ezdrummer3_kit() 362 363 # Private helper methods 364 def _generate_variations( 365 self, base_pattern: Pattern, params: GenerationParameters 366 ) -> list: 367 """Generate pattern variations based on complexity.""" 368 from midi_drums.core.models.song import PatternVariation 369 370 variations = [] 371 372 # Create a simplified variation 373 if params.complexity > 0.7: 374 simplified = base_pattern.copy() 375 simplified.name = f"{base_pattern.name}_simple" 376 377 # Remove some hi-hat hits for variation 378 simplified.beats = [ 379 beat 380 for beat in simplified.beats 381 if not ( 382 beat.instrument.name.endswith("HH") 383 and beat.position % 0.5 != 0 384 ) 385 ] 386 387 variations.append(PatternVariation(simplified, 0.3)) 388 389 return variations 390 391 def _generate_fills(self, genre: str, params: GenerationParameters) -> list: 392 """Generate fill patterns for the section. 393 394 When a drummer is set and has signature fills (see 395 DrummerPlugin.get_signature_fills()), the request is a 396 drummer-inspired performance: fills are drawn exclusively from 397 that drummer's candidates so the performance actually sounds 398 like them, rather than being diluted by the genre's stock fills. 399 400 Otherwise - no drummer set, or the drummer has no signature 401 fills of its own (true for every drummer plugin except Peart at 402 the time of writing) - fills fall back to the genre's common 403 fill pool. See issue #32. 404 """ 405 if params.drummer: 406 drummer_plugin = self.plugin_manager.registry.get_drummer_plugin( 407 params.drummer 408 ) 409 if drummer_plugin: 410 signature_fills = drummer_plugin.get_signature_fills() 411 if signature_fills: 412 return signature_fills 413 414 genre_plugin = self.plugin_manager.registry.get_genre_plugin(genre) 415 if genre_plugin: 416 return genre_plugin.get_common_fills() 417 return [] 418 419 def _extend_pattern_to_bars(self, pattern: Pattern, bars: int) -> Pattern: 420 """Extend a pattern to span multiple bars.""" 421 if bars <= 1: 422 return pattern 423 424 extended_pattern = pattern.copy() 425 extended_pattern.name = f"{pattern.name}_{bars}bars" 426 427 original_beats = pattern.beats.copy() 428 beats_per_bar = pattern.time_signature.beats_per_bar 429 430 # Repeat pattern for additional bars with slight variations 431 for bar in range(1, bars): 432 bar_offset = bar * beats_per_bar 433 for beat in original_beats: 434 import random 435 436 from midi_drums.core.models.pattern import Beat 437 438 new_beat = Beat( 439 position=beat.position + bar_offset, 440 instrument=beat.instrument, 441 velocity=max( 442 1, min(127, beat.velocity + random.randint(-5, 5)) 443 ), # Slight variation with clamping 444 duration=beat.duration, 445 ghost_note=beat.ghost_note, 446 accent=beat.accent, 447 instrument_promoted=beat.instrument_promoted, 448 ) 449 extended_pattern.beats.append(new_beat) 450 451 return extended_pattern 452 453 @classmethod 454 def quick_generate( 455 cls, genre: str = "metal", style: str = "heavy", tempo: int = 155 456 ) -> Song: 457 """Quick song generation with sensible defaults. 458 459 This replicates the functionality of the original script. 460 """ 461 generator = cls() 462 return generator.create_song( 463 genre=genre, 464 style=style, 465 tempo=tempo, 466 complexity=0.7, 467 dynamics=0.6, 468 humanization=0.3, 469 )
Main drum generation engine.
22 def __init__(self, config_path: Path | None = None): 23 """Initialize drum generator with optional configuration.""" 24 self.plugin_manager = PluginManager() 25 self.drum_kit = DrumKit.create_ezdrummer3_kit() 26 self.midi_engine = MIDIEngine(self.drum_kit) 27 28 # Load plugins 29 self._load_plugins()
Initialize drum generator with optional configuration.
45 def create_song( 46 self, 47 genre: str, 48 style: str = "default", 49 tempo: int = 120, 50 structure: list[tuple[str, int]] | None = None, 51 drum_kit: DrumKit | None = None, 52 **kwargs, 53 ) -> Song: 54 """Create a complete song structure. 55 56 Args: 57 genre: Genre name (e.g., 'metal', 'rock', 'jazz') 58 style: Style within genre (e.g., 'death', 'power' for metal) 59 tempo: Tempo in BPM 60 structure: List of (section_name, bars) tuples. If None, uses 61 default structure. 62 drum_kit: Optional DrumKit for MIDI mapping. If None, uses 63 current kit. 64 **kwargs: Additional parameters for GenerationParameters 65 66 Returns: 67 Complete Song object with generated patterns 68 """ 69 # Update MIDI engine if new drum kit provided 70 if drum_kit: 71 self.midi_engine = MIDIEngine(drum_kit) 72 self.drum_kit = drum_kit 73 74 # Create generation parameters 75 params = GenerationParameters(genre=genre, style=style, **kwargs) 76 77 # Use default structure if none provided 78 if structure is None: 79 structure = [ 80 ("intro", 4), 81 ("verse", 8), 82 ("chorus", 8), 83 ("verse", 8), 84 ("chorus", 8), 85 ("bridge", 4), 86 ("chorus", 8), 87 ("outro", 4), 88 ] 89 90 # Create song with basic structure 91 song = Song( 92 name=f"{genre}_{style}_song", tempo=tempo, global_parameters=params 93 ) 94 95 # Generate patterns for each section 96 for section_name, bars in structure: 97 pattern = self.generate_pattern( 98 genre, section_name, bars, style=style, **kwargs 99 ) 100 if pattern: 101 section = Section(section_name, pattern, bars) 102 103 # Add variations and fills based on complexity 104 if params.complexity > 0.5: 105 variations = self._generate_variations(pattern, params) 106 section.variations.extend(variations) 107 108 fills = self._generate_fills(genre, params) 109 section.fills.extend(fills) 110 111 song.add_section(section) 112 else: 113 logger.warning( 114 f"Failed to generate pattern for {genre}/{section_name}" 115 ) 116 117 return song
Create a complete song structure.
Arguments:
- genre: Genre name (e.g., 'metal', 'rock', 'jazz')
- style: Style within genre (e.g., 'death', 'power' for metal)
- tempo: Tempo in BPM
- structure: List of (section_name, bars) tuples. If None, uses default structure.
- drum_kit: Optional DrumKit for MIDI mapping. If None, uses current kit.
- **kwargs: Additional parameters for GenerationParameters
Returns:
Complete Song object with generated patterns
119 def generate_pattern( 120 self, genre: str, section: str = "verse", bars: int = 4, **kwargs 121 ) -> Pattern | None: 122 """Generate a single pattern with optional genre context adaptation. 123 124 Args: 125 genre: Genre name 126 section: Section type 127 bars: Number of bars (for multi-bar patterns) 128 **kwargs: Additional generation parameters including: 129 - song_genre_context: Overall song genre for adaptation 130 - context_blend: Blend amount (0.0-1.0) 131 - drummer: Drummer style to apply 132 - humanization: Humanization amount 133 - etc. 134 135 Returns: 136 Generated Pattern or None if generation failed 137 138 Example: 139 # Generate progressive pattern adapted to metal context 140 pattern = generator.generate_pattern( 141 genre="metal", 142 style="progressive", 143 section="bridge", 144 song_genre_context="metal", 145 context_blend=0.3 146 ) 147 """ 148 # Create parameters 149 params = GenerationParameters(genre=genre, **kwargs) 150 151 # Generate base pattern 152 pattern = self.plugin_manager.generate_pattern(genre, section, params) 153 if not pattern: 154 return None 155 156 # Apply genre context blending if specified 157 if params.song_genre_context and params.context_blend > 0: 158 # Only blend if context genre is different from pattern genre 159 if params.song_genre_context != genre: 160 context_plugin = self.plugin_manager.get_genre_plugin( 161 params.song_genre_context 162 ) 163 genre_plugin = self.plugin_manager.get_genre_plugin(genre) 164 165 if context_plugin and genre_plugin: 166 context_profile = context_plugin.intensity_profile 167 pattern = genre_plugin.apply_context_blend( 168 pattern, context_profile, params.context_blend 169 ) 170 logger.debug( 171 f"Applied {params.song_genre_context} context " 172 f"(blend={params.context_blend}) to {genre} pattern" 173 ) 174 175 # Apply drummer style if specified 176 if params.drummer: 177 styled_pattern = self.plugin_manager.apply_drummer_style( 178 pattern, params.drummer, params.drummer_intensity 179 ) 180 if styled_pattern: 181 pattern = styled_pattern 182 183 # Apply riff-lock if riff accents were supplied - snaps/inserts 184 # kicks onto the riff's rhythmic accents (issue: audio-riff-driven 185 # beat generation). Runs after drummer styling so it operates on 186 # the already-styled kick pattern, before humanization so the 187 # subsequent humanize() call still re-jitters kick timing like it 188 # does for every other beat (a tight lock needs humanization=0 - 189 # this is documented, not special-cased). Routed through 190 # plugin_manager rather than importing 191 # midi_drums.modifications.riff_lock directly - the generation 192 # domain isn't allowed to depend on modifications (see 193 # tests/unit/generation/test_generation_domain_migration.py), 194 # the same reason apply_drummer_style() above is a plugin_manager 195 # call rather than a direct modifications import. 196 if params.riff_accents: 197 locked_pattern = self.plugin_manager.apply_riff_lock( 198 pattern, params.riff_accents, params.riff_lock_strength 199 ) 200 if locked_pattern: 201 pattern = locked_pattern 202 203 # Apply snare-accent-reaction if requested - reinforce or stab the 204 # snare against the same riff accents (see 205 # midi_drums.modifications.snare_accent_reaction.SnareAccentReaction). 206 # Runs after riff-lock so "stab" can unison-match against the kicks 207 # riff-lock just placed; gated on mode != "off" so nothing is 208 # constructed at all in the (default) off case. Same domain- 209 # boundary routing as riff-lock above. 210 if params.riff_accents and params.riff_snare_mode != "off": 211 reacted_pattern = self.plugin_manager.apply_riff_snare_accents( 212 pattern, 213 params.riff_accents, 214 params.riff_snare_mode, 215 params.riff_snare_stab_threshold, 216 ) 217 if reacted_pattern: 218 pattern = reacted_pattern 219 220 # Apply cymbal-accent-reaction if requested - reinforce or stab 221 # hi-hat/crash/ride/china against the same riff accents (see 222 # midi_drums.modifications.cymbal_accent_reaction.CymbalAccentReaction). 223 # Each kit piece is independently gated on its own mode != "off", 224 # so e.g. hi-hat can react while crash/ride/china stay off. Runs after 225 # riff-lock (same "stab" unison-match rationale as the snare block 226 # above) and independently of the snare block. Same domain- 227 # boundary routing as riff-lock/snare above. 228 if params.riff_accents: 229 for kit_piece, mode, stab_threshold in ( 230 ( 231 "hihat", 232 params.riff_hihat_mode, 233 params.riff_hihat_stab_threshold, 234 ), 235 ( 236 "crash", 237 params.riff_crash_mode, 238 params.riff_crash_stab_threshold, 239 ), 240 ( 241 "ride", 242 params.riff_ride_mode, 243 params.riff_ride_stab_threshold, 244 ), 245 ( 246 "china", 247 params.riff_china_mode, 248 params.riff_china_stab_threshold, 249 ), 250 ): 251 if mode == "off": 252 continue 253 reacted_pattern = self.plugin_manager.apply_riff_cymbal_accents( 254 pattern, 255 params.riff_accents, 256 kit_piece, 257 mode, 258 stab_threshold, 259 ) 260 if reacted_pattern: 261 pattern = reacted_pattern 262 263 # Apply humanization if requested 264 if params.humanization > 0: 265 timing_var = params.humanization * 0.05 # Scale to reasonable range 266 velocity_var = int(params.humanization * 20) 267 pattern = pattern.humanize(timing_var, velocity_var) 268 269 # Extend pattern for multiple bars if needed 270 if bars > 1: 271 pattern = self._extend_pattern_to_bars(pattern, bars) 272 273 return pattern
Generate a single pattern with optional genre context adaptation.
Arguments:
- genre: Genre name
- section: Section type
- bars: Number of bars (for multi-bar patterns)
- **kwargs: Additional generation parameters including:
- song_genre_context: Overall song genre for adaptation
- context_blend: Blend amount (0.0-1.0)
- drummer: Drummer style to apply
- humanization: Humanization amount
- etc.
Returns:
Generated Pattern or None if generation failed
Example:
Generate progressive pattern adapted to metal context
pattern = generator.generate_pattern( genre="metal", style="progressive", section="bridge", song_genre_context="metal", context_blend=0.3 )
275 def apply_drummer_style( 276 self, pattern: Pattern, drummer: str, intensity: float = 1.0 277 ) -> Pattern | None: 278 """Apply drummer-specific style modifications to a pattern.""" 279 return self.plugin_manager.apply_drummer_style( 280 pattern, drummer, intensity 281 )
Apply drummer-specific style modifications to a pattern.
283 def export_midi(self, song: Song, output_path: Path) -> None: 284 """Export song as MIDI file.""" 285 self.midi_engine.save_song_midi(song, output_path) 286 logger.info(f"Exported MIDI to: {output_path}")
Export song as MIDI file.
288 def export_pattern_midi( 289 self, 290 pattern: Pattern, 291 output_path: Path, 292 tempo: int = 120, 293 drum_kit: DrumKit | None = None, 294 ) -> None: 295 """Export single pattern as MIDI file.""" 296 # Use provided drum kit or current one 297 engine = self.midi_engine 298 if drum_kit: 299 engine = MIDIEngine(drum_kit) 300 301 engine.save_pattern_midi(pattern, output_path, tempo) 302 logger.info(f"Exported pattern MIDI to: {output_path}")
Export single pattern as MIDI file.
304 def get_available_genres(self) -> list[str]: 305 """Get list of available genres.""" 306 return self.plugin_manager.get_available_genres()
Get list of available genres.
308 def get_available_drummers(self) -> list[str]: 309 """Get list of available drummers.""" 310 return self.plugin_manager.get_available_drummers()
Get list of available drummers.
312 def get_styles_for_genre(self, genre: str) -> list[str]: 313 """Get available styles for a genre.""" 314 return self.plugin_manager.get_styles_for_genre(genre)
Get available styles for a genre.
316 def get_song_info(self, song: Song) -> dict: 317 """Get comprehensive information about a song.""" 318 info = self.midi_engine.get_midi_info(song) 319 info.update( 320 { 321 "genre": ( 322 song.global_parameters.genre 323 if song.global_parameters 324 else "unknown" 325 ), 326 "style": ( 327 song.global_parameters.style 328 if song.global_parameters 329 else "default" 330 ), 331 "drummer": ( 332 song.global_parameters.drummer 333 if song.global_parameters 334 else None 335 ), 336 "sections_count": len(song.sections), 337 "unique_sections": list({s.name for s in song.sections}), 338 } 339 ) 340 return info
Get comprehensive information about a song.
342 def set_drum_kit(self, kit: DrumKit) -> None: 343 """Set the drum kit configuration.""" 344 self.drum_kit = kit 345 self.midi_engine = MIDIEngine(kit)
Set the drum kit configuration.
347 def create_drum_kit(self, kit_type: str) -> DrumKit: 348 """Create a drum kit configuration by type.""" 349 kit_creators = { 350 "ezdrummer3": DrumKit.create_ezdrummer3_kit, 351 "metal": DrumKit.create_metal_kit, 352 "jazz": DrumKit.create_jazz_kit, 353 "standard": DrumKit.create_ezdrummer3_kit, # Alias 354 } 355 356 creator = kit_creators.get(kit_type.lower()) 357 if creator: 358 return creator() 359 else: 360 logger.warning(f"Unknown kit type: {kit_type}, using standard kit") 361 return DrumKit.create_ezdrummer3_kit()
Create a drum kit configuration by type.
453 @classmethod 454 def quick_generate( 455 cls, genre: str = "metal", style: str = "heavy", tempo: int = 155 456 ) -> Song: 457 """Quick song generation with sensible defaults. 458 459 This replicates the functionality of the original script. 460 """ 461 generator = cls() 462 return generator.create_song( 463 genre=genre, 464 style=style, 465 tempo=tempo, 466 complexity=0.7, 467 dynamics=0.6, 468 humanization=0.3, 469 )
Quick song generation with sensible defaults.
This replicates the functionality of the original script.
58@dataclass 59class Pattern: 60 """Complete drum pattern with timing and metadata.""" 61 62 name: str 63 beats: list[Beat] = field(default_factory=list) 64 time_signature: TimeSignature = field(default_factory=TimeSignature) 65 subdivision: int = 16 # 16th note resolution 66 swing_ratio: float = 0.0 # 0.0 = straight, 0.5 = triplet swing 67 metadata: dict[str, Any] = field(default_factory=dict) 68 69 def add_beat( 70 self, 71 position: float, 72 instrument: DrumInstrument, 73 velocity: int = 100, 74 **kwargs, 75 ) -> "Pattern": 76 """Add a beat to the pattern.""" 77 beat = Beat( 78 position=position, 79 instrument=instrument, 80 velocity=velocity, 81 **kwargs, 82 ) 83 self.beats.append(beat) 84 return self 85 86 def get_beats_at_position( 87 self, position: float, tolerance: float = 0.01 88 ) -> list[Beat]: 89 """Get all beats at a specific position.""" 90 return [ 91 beat 92 for beat in self.beats 93 if abs(beat.position - position) <= tolerance 94 ] 95 96 def get_beats_by_instrument(self, instrument: DrumInstrument) -> list[Beat]: 97 """Get all beats for a specific instrument.""" 98 return [beat for beat in self.beats if beat.instrument == instrument] 99 100 def duration_bars(self) -> float: 101 """Calculate pattern duration in bars.""" 102 if not self.beats: 103 return 1.0 104 max_position = max(beat.position for beat in self.beats) 105 return max( 106 1.0, (max_position + 1.0) / self.time_signature.beats_per_bar 107 ) 108 109 def humanize( 110 self, timing_variance: float = 0.02, velocity_variance: float = 10 111 ) -> "Pattern": 112 """Apply humanization to timing and velocity.""" 113 humanized_beats = [] 114 for beat in self.beats: 115 # Add slight timing variations 116 timing_offset = random.uniform(-timing_variance, timing_variance) 117 new_position = max(0, beat.position + timing_offset) 118 119 # Add velocity variations 120 velocity_offset = random.randint( 121 -velocity_variance, velocity_variance 122 ) 123 new_velocity = max(1, min(127, beat.velocity + velocity_offset)) 124 125 humanized_beat = Beat( 126 position=new_position, 127 instrument=beat.instrument, 128 velocity=new_velocity, 129 duration=beat.duration, 130 ghost_note=beat.ghost_note, 131 accent=beat.accent, 132 instrument_promoted=beat.instrument_promoted, 133 ) 134 humanized_beats.append(humanized_beat) 135 136 return Pattern( 137 name=f"{self.name}_humanized", 138 beats=humanized_beats, 139 time_signature=self.time_signature, 140 subdivision=self.subdivision, 141 swing_ratio=self.swing_ratio, 142 metadata={**self.metadata, "humanized": True}, 143 ) 144 145 def copy(self) -> "Pattern": 146 """Create a deep copy of the pattern. 147 148 Logs warning if pattern has no beats to aid debugging. 149 """ 150 if not self.beats: 151 logger.warning( 152 f"Pattern '{self.name}' has no beats - copying empty pattern. " 153 "This may cause issues with drummer plugins." 154 ) 155 156 return Pattern( 157 name=self.name, 158 beats=[ 159 Beat( 160 position=beat.position, 161 instrument=beat.instrument, 162 velocity=beat.velocity, 163 duration=beat.duration, 164 ghost_note=beat.ghost_note, 165 accent=beat.accent, 166 instrument_promoted=beat.instrument_promoted, 167 ) 168 for beat in self.beats 169 ], 170 time_signature=TimeSignature( 171 self.time_signature.numerator, self.time_signature.denominator 172 ), 173 subdivision=self.subdivision, 174 swing_ratio=self.swing_ratio, 175 metadata=self.metadata.copy(), 176 )
Complete drum pattern with timing and metadata.
69 def add_beat( 70 self, 71 position: float, 72 instrument: DrumInstrument, 73 velocity: int = 100, 74 **kwargs, 75 ) -> "Pattern": 76 """Add a beat to the pattern.""" 77 beat = Beat( 78 position=position, 79 instrument=instrument, 80 velocity=velocity, 81 **kwargs, 82 ) 83 self.beats.append(beat) 84 return self
Add a beat to the pattern.
86 def get_beats_at_position( 87 self, position: float, tolerance: float = 0.01 88 ) -> list[Beat]: 89 """Get all beats at a specific position.""" 90 return [ 91 beat 92 for beat in self.beats 93 if abs(beat.position - position) <= tolerance 94 ]
Get all beats at a specific position.
96 def get_beats_by_instrument(self, instrument: DrumInstrument) -> list[Beat]: 97 """Get all beats for a specific instrument.""" 98 return [beat for beat in self.beats if beat.instrument == instrument]
Get all beats for a specific instrument.
100 def duration_bars(self) -> float: 101 """Calculate pattern duration in bars.""" 102 if not self.beats: 103 return 1.0 104 max_position = max(beat.position for beat in self.beats) 105 return max( 106 1.0, (max_position + 1.0) / self.time_signature.beats_per_bar 107 )
Calculate pattern duration in bars.
109 def humanize( 110 self, timing_variance: float = 0.02, velocity_variance: float = 10 111 ) -> "Pattern": 112 """Apply humanization to timing and velocity.""" 113 humanized_beats = [] 114 for beat in self.beats: 115 # Add slight timing variations 116 timing_offset = random.uniform(-timing_variance, timing_variance) 117 new_position = max(0, beat.position + timing_offset) 118 119 # Add velocity variations 120 velocity_offset = random.randint( 121 -velocity_variance, velocity_variance 122 ) 123 new_velocity = max(1, min(127, beat.velocity + velocity_offset)) 124 125 humanized_beat = Beat( 126 position=new_position, 127 instrument=beat.instrument, 128 velocity=new_velocity, 129 duration=beat.duration, 130 ghost_note=beat.ghost_note, 131 accent=beat.accent, 132 instrument_promoted=beat.instrument_promoted, 133 ) 134 humanized_beats.append(humanized_beat) 135 136 return Pattern( 137 name=f"{self.name}_humanized", 138 beats=humanized_beats, 139 time_signature=self.time_signature, 140 subdivision=self.subdivision, 141 swing_ratio=self.swing_ratio, 142 metadata={**self.metadata, "humanized": True}, 143 )
Apply humanization to timing and velocity.
145 def copy(self) -> "Pattern": 146 """Create a deep copy of the pattern. 147 148 Logs warning if pattern has no beats to aid debugging. 149 """ 150 if not self.beats: 151 logger.warning( 152 f"Pattern '{self.name}' has no beats - copying empty pattern. " 153 "This may cause issues with drummer plugins." 154 ) 155 156 return Pattern( 157 name=self.name, 158 beats=[ 159 Beat( 160 position=beat.position, 161 instrument=beat.instrument, 162 velocity=beat.velocity, 163 duration=beat.duration, 164 ghost_note=beat.ghost_note, 165 accent=beat.accent, 166 instrument_promoted=beat.instrument_promoted, 167 ) 168 for beat in self.beats 169 ], 170 time_signature=TimeSignature( 171 self.time_signature.numerator, self.time_signature.denominator 172 ), 173 subdivision=self.subdivision, 174 swing_ratio=self.swing_ratio, 175 metadata=self.metadata.copy(), 176 )
Create a deep copy of the pattern.
Logs warning if pattern has no beats to aid debugging.
15@dataclass 16class Beat: 17 """Individual drum hit within a pattern. 18 19 ``instrument_promoted`` is provenance, not a playing instruction: it is 20 True only when this beat's ``instrument`` was changed in place by 21 ``GenrePlugin._apply_ride_hihat_logic`` promoting an existing hi-hat 22 beat to a higher-energy cymbal for a high-energy section (issue #18). 23 A cymbal beat placed directly by a pattern template/genre style (e.g. 24 ``CrashAccents``) is never promoted and always carries the default 25 False, even though its ``instrument`` may be the exact same cymbal a 26 promotion would have chosen. This lets drummer modifications that only 27 care about "the timekeeping cymbal" - PocketStretching, 28 MinimalCreativity, SpeedPrecision in 29 ``midi_drums.modifications.drummer_mods`` - tell a promoted timekeeping 30 beat apart from a genuinely-placed accent of the same instrument 31 (issue #36 item 1), instead of matching on instrument type alone. 32 33 Every call site that reconstructs a Beat from an existing one ( 34 ``Pattern.copy()``, ``Pattern.humanize()``, and the drummer 35 modification/humanization pipelines) must carry this field forward 36 explicitly - dataclass field-by-field reconstruction does not do this 37 automatically, and a dropped flag silently falls back to False. 38 """ 39 40 position: float # Beat position (0.0-4.0 for 4/4) 41 instrument: DrumInstrument 42 velocity: int = 100 # MIDI velocity 0-127 43 duration: float = 0.25 # Note duration in beats 44 ghost_note: bool = False # Quiet accent note 45 accent: bool = False # Emphasized note 46 instrument_promoted: bool = False # True if instrument was promoted 47 48 def __post_init__(self): 49 """Validate beat parameters.""" 50 if not 0 <= self.velocity <= 127: 51 raise ValueError(f"Velocity must be 0-127, got {self.velocity}") 52 if self.position < 0: 53 raise ValueError( 54 f"Position cannot be negative, got {self.position}" 55 )
Individual drum hit within a pattern.
instrument_promoted is provenance, not a playing instruction: it is
True only when this beat's instrument was changed in place by
GenrePlugin._apply_ride_hihat_logic promoting an existing hi-hat
beat to a higher-energy cymbal for a high-energy section (issue #18).
A cymbal beat placed directly by a pattern template/genre style (e.g.
CrashAccents) is never promoted and always carries the default
False, even though its instrument may be the exact same cymbal a
promotion would have chosen. This lets drummer modifications that only
care about "the timekeeping cymbal" - PocketStretching,
MinimalCreativity, SpeedPrecision in
midi_drums.modifications.drummer_mods - tell a promoted timekeeping
beat apart from a genuinely-placed accent of the same instrument
(issue #36 item 1), instead of matching on instrument type alone.
Every call site that reconstructs a Beat from an existing one (
Pattern.copy(), Pattern.humanize(), and the drummer
modification/humanization pipelines) must carry this field forward
explicitly - dataclass field-by-field reconstruction does not do this
automatically, and a dropped flag silently falls back to False.
7@dataclass 8class TimeSignature: 9 """Time signature representation.""" 10 11 numerator: int = 4 12 denominator: int = 4 13 14 def __post_init__(self) -> None: 15 if self.numerator <= 0: 16 raise ValueError( 17 f"Time signature numerator must be positive, got " 18 f"{self.numerator}" 19 ) 20 if self.denominator <= 0 or ( 21 self.denominator & (self.denominator - 1) != 0 22 ): 23 raise ValueError( 24 f"Time signature denominator must be a positive power of " 25 f"two (1, 2, 4, 8, 16, ...), got {self.denominator}" 26 ) 27 28 @property 29 def beats_per_bar(self) -> float: 30 return self.numerator * (4.0 / self.denominator) 31 32 def __str__(self) -> str: 33 return f"{self.numerator}/{self.denominator}"
Time signature representation.
164@dataclass 165class Song: 166 """Complete song structure with sections and global parameters.""" 167 168 name: str 169 tempo: int = 120 # BPM 170 time_signature: TimeSignature = field(default_factory=TimeSignature) 171 sections: list[Section] = field(default_factory=list) 172 global_parameters: GenerationParameters | None = None 173 metadata: dict[str, Any] = field(default_factory=dict) 174 175 def __post_init__(self): 176 """Validate song parameters.""" 177 if not 60 <= self.tempo <= 300: 178 raise ValueError( 179 f"Tempo must be between 60-300 BPM, got {self.tempo}" 180 ) 181 182 def add_section(self, section: Section) -> "Song": 183 """Add a section to the song.""" 184 self.sections.append(section) 185 return self 186 187 def total_bars(self) -> int: 188 """Calculate total number of bars in the song.""" 189 return sum(section.bars for section in self.sections) 190 191 def total_duration_seconds(self) -> float: 192 """Calculate total song duration in seconds. 193 194 Accounts for per-segment tempo/time-signature overrides (see 195 SongSegment) - a section with no segments contributes 196 ``bars * time_signature.beats_per_bar / (tempo / 60)`` using the 197 song's global values, identical to the pre-segment calculation. 198 """ 199 total_seconds = 0.0 200 for section in self.sections: 201 for bars, tempo, time_sig in section.resolved_bar_specs( 202 self.tempo, self.time_signature 203 ): 204 beats = bars * time_sig.beats_per_bar 205 total_seconds += beats / (tempo / 60.0) 206 return total_seconds 207 208 def section_start_times(self) -> list[float]: 209 """Return each section's start time in seconds. 210 211 Resolves per-segment tempo/time-signature overrides (see 212 SongSegment) the same way :meth:`total_duration_seconds` does, so 213 callers that need per-section positions (e.g. REAPER markers) 214 stay in sync with segmented songs instead of assuming a single 215 global tempo/time signature for the whole song. 216 """ 217 times = [] 218 elapsed = 0.0 219 for section in self.sections: 220 times.append(elapsed) 221 for bars, tempo, time_sig in section.resolved_bar_specs( 222 self.tempo, self.time_signature 223 ): 224 beats = bars * time_sig.beats_per_bar 225 elapsed += beats / (tempo / 60.0) 226 return times 227 228 def get_section_by_name(self, name: str) -> Section | None: 229 """Find first section with the given name.""" 230 for section in self.sections: 231 if section.name == name: 232 return section 233 return None 234 235 def get_sections_by_name(self, name: str) -> list[Section]: 236 """Find all sections with the given name.""" 237 return [section for section in self.sections if section.name == name] 238 239 @classmethod 240 def create_simple_structure( 241 cls, 242 name: str, 243 tempo: int = 120, 244 genre: str = "rock", 245 style: str = "default", 246 ) -> "Song": 247 """Create a song with basic verse-chorus structure.""" 248 # Create placeholder patterns (will be generated by plugins) 249 verse_pattern = Pattern(f"{genre}_{style}_verse") 250 chorus_pattern = Pattern(f"{genre}_{style}_chorus") 251 252 song = cls(name=name, tempo=tempo) 253 song.global_parameters = GenerationParameters(genre=genre, style=style) 254 255 # Standard pop/rock structure 256 song.add_section(Section("intro", verse_pattern, bars=4)) 257 song.add_section(Section("verse", verse_pattern, bars=8)) 258 song.add_section(Section("chorus", chorus_pattern, bars=8)) 259 song.add_section(Section("verse", verse_pattern, bars=8)) 260 song.add_section(Section("chorus", chorus_pattern, bars=8)) 261 song.add_section(Section("bridge", verse_pattern, bars=4)) 262 song.add_section(Section("chorus", chorus_pattern, bars=8)) 263 song.add_section(Section("outro", chorus_pattern, bars=4)) 264 265 return song
Complete song structure with sections and global parameters.
182 def add_section(self, section: Section) -> "Song": 183 """Add a section to the song.""" 184 self.sections.append(section) 185 return self
Add a section to the song.
187 def total_bars(self) -> int: 188 """Calculate total number of bars in the song.""" 189 return sum(section.bars for section in self.sections)
Calculate total number of bars in the song.
191 def total_duration_seconds(self) -> float: 192 """Calculate total song duration in seconds. 193 194 Accounts for per-segment tempo/time-signature overrides (see 195 SongSegment) - a section with no segments contributes 196 ``bars * time_signature.beats_per_bar / (tempo / 60)`` using the 197 song's global values, identical to the pre-segment calculation. 198 """ 199 total_seconds = 0.0 200 for section in self.sections: 201 for bars, tempo, time_sig in section.resolved_bar_specs( 202 self.tempo, self.time_signature 203 ): 204 beats = bars * time_sig.beats_per_bar 205 total_seconds += beats / (tempo / 60.0) 206 return total_seconds
Calculate total song duration in seconds.
Accounts for per-segment tempo/time-signature overrides (see
SongSegment) - a section with no segments contributes
bars * time_signature.beats_per_bar / (tempo / 60) using the
song's global values, identical to the pre-segment calculation.
208 def section_start_times(self) -> list[float]: 209 """Return each section's start time in seconds. 210 211 Resolves per-segment tempo/time-signature overrides (see 212 SongSegment) the same way :meth:`total_duration_seconds` does, so 213 callers that need per-section positions (e.g. REAPER markers) 214 stay in sync with segmented songs instead of assuming a single 215 global tempo/time signature for the whole song. 216 """ 217 times = [] 218 elapsed = 0.0 219 for section in self.sections: 220 times.append(elapsed) 221 for bars, tempo, time_sig in section.resolved_bar_specs( 222 self.tempo, self.time_signature 223 ): 224 beats = bars * time_sig.beats_per_bar 225 elapsed += beats / (tempo / 60.0) 226 return times
Return each section's start time in seconds.
Resolves per-segment tempo/time-signature overrides (see
SongSegment) the same way total_duration_seconds() does, so
callers that need per-section positions (e.g. REAPER markers)
stay in sync with segmented songs instead of assuming a single
global tempo/time signature for the whole song.
228 def get_section_by_name(self, name: str) -> Section | None: 229 """Find first section with the given name.""" 230 for section in self.sections: 231 if section.name == name: 232 return section 233 return None
Find first section with the given name.
235 def get_sections_by_name(self, name: str) -> list[Section]: 236 """Find all sections with the given name.""" 237 return [section for section in self.sections if section.name == name]
Find all sections with the given name.
239 @classmethod 240 def create_simple_structure( 241 cls, 242 name: str, 243 tempo: int = 120, 244 genre: str = "rock", 245 style: str = "default", 246 ) -> "Song": 247 """Create a song with basic verse-chorus structure.""" 248 # Create placeholder patterns (will be generated by plugins) 249 verse_pattern = Pattern(f"{genre}_{style}_verse") 250 chorus_pattern = Pattern(f"{genre}_{style}_chorus") 251 252 song = cls(name=name, tempo=tempo) 253 song.global_parameters = GenerationParameters(genre=genre, style=style) 254 255 # Standard pop/rock structure 256 song.add_section(Section("intro", verse_pattern, bars=4)) 257 song.add_section(Section("verse", verse_pattern, bars=8)) 258 song.add_section(Section("chorus", chorus_pattern, bars=8)) 259 song.add_section(Section("verse", verse_pattern, bars=8)) 260 song.add_section(Section("chorus", chorus_pattern, bars=8)) 261 song.add_section(Section("bridge", verse_pattern, bars=4)) 262 song.add_section(Section("chorus", chorus_pattern, bars=8)) 263 song.add_section(Section("outro", chorus_pattern, bars=4)) 264 265 return song
Create a song with basic verse-chorus structure.
55@dataclass 56class Section: 57 """Song section (verse, chorus, etc.) with pattern and variations.""" 58 59 name: str # "verse", "chorus", "bridge", "breakdown", "intro", "outro" 60 pattern: Pattern 61 bars: int = 4 62 variations: list[PatternVariation] = field(default_factory=list) 63 fills: list[Fill] = field(default_factory=list) 64 section_parameters: dict[str, Any] = field(default_factory=dict) 65 segments: list[SongSegment] = field(default_factory=list) 66 67 def __post_init__(self): 68 """Validate that segment bars (if any) account for the whole section.""" 69 if self.segments: 70 segment_bars = sum(segment.bars for segment in self.segments) 71 if segment_bars != self.bars: 72 raise ValueError( 73 f"Section '{self.name}' segments sum to {segment_bars} " 74 f"bars but Section.bars is {self.bars}" 75 ) 76 77 def segment_for_bar(self, bar_number: int) -> SongSegment | None: 78 """Return the segment covering local ``bar_number`` (0-indexed). 79 80 Returns None when this section has no segments - callers should 81 treat that as "use the song's global tempo/time signature." 82 """ 83 cursor = 0 84 for segment in self.segments: 85 if cursor <= bar_number < cursor + segment.bars: 86 return segment 87 cursor += segment.bars 88 return None 89 90 def effective_tempo(self, bar_number: int, song_tempo: int) -> int: 91 """Resolve the tempo that applies at ``bar_number``, inheriting 92 ``song_tempo`` when this section has no segments or the covering 93 segment doesn't override tempo.""" 94 segment = self.segment_for_bar(bar_number) 95 if segment is None or segment.tempo is None: 96 return song_tempo 97 return segment.tempo 98 99 def effective_time_signature( 100 self, bar_number: int, song_time_signature: TimeSignature 101 ) -> TimeSignature: 102 """Resolve the time signature that applies at ``bar_number``, 103 inheriting ``song_time_signature`` when this section has no 104 segments or the covering segment doesn't override it.""" 105 segment = self.segment_for_bar(bar_number) 106 if segment is None or segment.time_signature is None: 107 return song_time_signature 108 return segment.time_signature 109 110 def resolved_bar_specs( 111 self, song_tempo: int, song_time_signature: TimeSignature 112 ) -> list[tuple[int, int, TimeSignature]]: 113 """Return (bars, tempo, time_signature) triples for this section. 114 115 One triple per segment when this section has segments, resolving 116 each segment's ``tempo``/``time_signature`` override against the 117 given song-level defaults; otherwise a single triple for the 118 whole section using those defaults directly. Shared by every 119 caller that needs to walk a section's bars accounting for 120 per-segment overrides (duration, timeline export, song-map 121 export) so segment-resolution semantics live in one place. 122 """ 123 if self.segments: 124 return [ 125 ( 126 segment.bars, 127 segment.tempo or song_tempo, 128 segment.time_signature or song_time_signature, 129 ) 130 for segment in self.segments 131 ] 132 return [(self.bars, song_tempo, song_time_signature)] 133 134 def get_effective_pattern(self, bar_number: int) -> Pattern: 135 """Get the pattern for a specific bar, considering variations.""" 136 # Check if any variations should apply to this bar 137 for variation in self.variations: 138 if variation.bars is None or bar_number in variation.bars: 139 import random 140 141 if random.random() < variation.probability: 142 return variation.pattern 143 return self.pattern 144 145 def should_add_fill( 146 self, bar_number: int, fill_frequency: float 147 ) -> Fill | None: 148 """Determine if a fill should be added at this bar.""" 149 import random 150 151 if random.random() < fill_frequency and self.fills: 152 # Choose fill based on probabilities 153 total_prob = sum(fill.trigger_probability for fill in self.fills) 154 if total_prob > 0: 155 rand_val = random.random() * total_prob 156 current_sum = 0 157 for fill in self.fills: 158 current_sum += fill.trigger_probability 159 if rand_val <= current_sum: 160 return fill 161 return None
Song section (verse, chorus, etc.) with pattern and variations.
77 def segment_for_bar(self, bar_number: int) -> SongSegment | None: 78 """Return the segment covering local ``bar_number`` (0-indexed). 79 80 Returns None when this section has no segments - callers should 81 treat that as "use the song's global tempo/time signature." 82 """ 83 cursor = 0 84 for segment in self.segments: 85 if cursor <= bar_number < cursor + segment.bars: 86 return segment 87 cursor += segment.bars 88 return None
Return the segment covering local bar_number (0-indexed).
Returns None when this section has no segments - callers should treat that as "use the song's global tempo/time signature."
90 def effective_tempo(self, bar_number: int, song_tempo: int) -> int: 91 """Resolve the tempo that applies at ``bar_number``, inheriting 92 ``song_tempo`` when this section has no segments or the covering 93 segment doesn't override tempo.""" 94 segment = self.segment_for_bar(bar_number) 95 if segment is None or segment.tempo is None: 96 return song_tempo 97 return segment.tempo
Resolve the tempo that applies at bar_number, inheriting
song_tempo when this section has no segments or the covering
segment doesn't override tempo.
99 def effective_time_signature( 100 self, bar_number: int, song_time_signature: TimeSignature 101 ) -> TimeSignature: 102 """Resolve the time signature that applies at ``bar_number``, 103 inheriting ``song_time_signature`` when this section has no 104 segments or the covering segment doesn't override it.""" 105 segment = self.segment_for_bar(bar_number) 106 if segment is None or segment.time_signature is None: 107 return song_time_signature 108 return segment.time_signature
Resolve the time signature that applies at bar_number,
inheriting song_time_signature when this section has no
segments or the covering segment doesn't override it.
110 def resolved_bar_specs( 111 self, song_tempo: int, song_time_signature: TimeSignature 112 ) -> list[tuple[int, int, TimeSignature]]: 113 """Return (bars, tempo, time_signature) triples for this section. 114 115 One triple per segment when this section has segments, resolving 116 each segment's ``tempo``/``time_signature`` override against the 117 given song-level defaults; otherwise a single triple for the 118 whole section using those defaults directly. Shared by every 119 caller that needs to walk a section's bars accounting for 120 per-segment overrides (duration, timeline export, song-map 121 export) so segment-resolution semantics live in one place. 122 """ 123 if self.segments: 124 return [ 125 ( 126 segment.bars, 127 segment.tempo or song_tempo, 128 segment.time_signature or song_time_signature, 129 ) 130 for segment in self.segments 131 ] 132 return [(self.bars, song_tempo, song_time_signature)]
Return (bars, tempo, time_signature) triples for this section.
One triple per segment when this section has segments, resolving
each segment's tempo/time_signature override against the
given song-level defaults; otherwise a single triple for the
whole section using those defaults directly. Shared by every
caller that needs to walk a section's bars accounting for
per-segment overrides (duration, timeline export, song-map
export) so segment-resolution semantics live in one place.
134 def get_effective_pattern(self, bar_number: int) -> Pattern: 135 """Get the pattern for a specific bar, considering variations.""" 136 # Check if any variations should apply to this bar 137 for variation in self.variations: 138 if variation.bars is None or bar_number in variation.bars: 139 import random 140 141 if random.random() < variation.probability: 142 return variation.pattern 143 return self.pattern
Get the pattern for a specific bar, considering variations.
145 def should_add_fill( 146 self, bar_number: int, fill_frequency: float 147 ) -> Fill | None: 148 """Determine if a fill should be added at this bar.""" 149 import random 150 151 if random.random() < fill_frequency and self.fills: 152 # Choose fill based on probabilities 153 total_prob = sum(fill.trigger_probability for fill in self.fills) 154 if total_prob > 0: 155 rand_val = random.random() * total_prob 156 current_sum = 0 157 for fill in self.fills: 158 current_sum += fill.trigger_probability 159 if rand_val <= current_sum: 160 return fill 161 return None
Determine if a fill should be added at this bar.
11@dataclass 12class GenerationParameters: 13 """Parameters controlling pattern generation.""" 14 15 genre: str 16 style: str = "default" 17 drummer: str | None = None 18 drummer_intensity: float = 1.0 # 0.0-1.0, how strongly the drummer's 19 # signature modifications override the genre plugin's base pattern. 20 # 1.0 = full drummer character (default, matches prior behavior); 21 # 0.0 = genre pattern untouched by drummer styling. Lets a user ask 22 # for e.g. "Porcaro tracking a Death Metal pattern" without the 23 # drummer's feel fully overwriting the genre's identity. 24 complexity: float = 0.5 # 0.0-1.0, affects fill density and variation 25 dynamics: float = 0.5 # 0.0-1.0, affects volume variation 26 humanization: float = 0.3 # 0.0-1.0, affects timing/velocity variation 27 fill_frequency: float = 0.2 # 0.0-1.0, how often fills occur 28 swing_ratio: float = 0.0 # 0.0-1.0, swing feel 29 ride_threshold: float = 0.9 # 0.0-1.0, complexity above which a 30 # section switches from hi-hat to ride cymbal timekeeping regardless 31 # of section name. High by default so section name (chorus/bridge) 32 # stays the primary trigger; existing patterns commonly run 33 # complexity 0.7-0.8 for busy-but-still-hihat verses, so this only 34 # fires as a deliberate high-complexity override. 35 36 # Genre context adaptation (NEW) 37 song_genre_context: str | None = None # Overall song genre for adaptation 38 context_blend: float = 0.0 # 0.0-1.0, how much to blend with context 39 40 # Riff-lock (audio riff -> kick-locked drum pattern, see 41 # midi_drums.modifications.riff_lock.RiffLockTransform). Applies to a 42 # single generate_pattern() call for exactly one bar - passing this 43 # through create_song()'s **kwargs would apply the same one-bar lock to 44 # every section, which is not what create_song callers want, so callers 45 # that need riff-lock must call generate_pattern() directly per section 46 # (see midi_drums.api.cli's `riff` subcommand). 47 riff_accents: "RiffAccentMap | None" = None 48 riff_lock_strength: float = 1.0 # 0.0-1.0, blend toward riff accents 49 50 # Snare reaction to the same riff accents (see 51 # midi_drums.modifications.snare_accent_reaction.SnareAccentReaction). 52 # Applied after riff-lock, same single-generate_pattern()-call scope as 53 # riff_accents above. "off" means the pipeline hook never constructs 54 # SnareAccentReaction at all. 55 riff_snare_mode: Literal["off", "reinforce", "stab"] = "off" 56 riff_snare_stab_threshold: float = 0.85 57 58 # Cymbal reactions to the same riff accents (see 59 # midi_drums.modifications.cymbal_accent_reaction.CymbalAccentReaction). 60 # Same scope/semantics as riff_snare_mode above, one independent 61 # mode/threshold pair per kit piece - hi-hat, crash, ride, and china 62 # can each be off/reinforce/stab independently. "off" means the 63 # pipeline hook never constructs CymbalAccentReaction for that kit 64 # piece at all. 65 riff_hihat_mode: Literal["off", "reinforce", "stab"] = "off" 66 riff_hihat_stab_threshold: float = 0.85 67 riff_crash_mode: Literal["off", "reinforce", "stab"] = "off" 68 riff_crash_stab_threshold: float = 0.85 69 riff_ride_mode: Literal["off", "reinforce", "stab"] = "off" 70 riff_ride_stab_threshold: float = 0.85 71 riff_china_mode: Literal["off", "reinforce", "stab"] = "off" 72 riff_china_stab_threshold: float = 0.85 73 74 custom_parameters: dict[str, Any] = field(default_factory=dict) 75 76 def __post_init__(self): 77 """Validate parameters.""" 78 if self.riff_snare_mode not in ("off", "reinforce", "stab"): 79 raise ValueError( 80 "riff_snare_mode must be 'off', 'reinforce', or 'stab', " 81 f"got {self.riff_snare_mode!r}" 82 ) 83 for param_name, value in [ 84 ("complexity", self.complexity), 85 ("dynamics", self.dynamics), 86 ("humanization", self.humanization), 87 ("fill_frequency", self.fill_frequency), 88 ("swing_ratio", self.swing_ratio), 89 ("ride_threshold", self.ride_threshold), 90 ("context_blend", self.context_blend), 91 ("drummer_intensity", self.drummer_intensity), 92 ("riff_lock_strength", self.riff_lock_strength), 93 ("riff_snare_stab_threshold", self.riff_snare_stab_threshold), 94 ("riff_hihat_stab_threshold", self.riff_hihat_stab_threshold), 95 ("riff_crash_stab_threshold", self.riff_crash_stab_threshold), 96 ("riff_ride_stab_threshold", self.riff_ride_stab_threshold), 97 ("riff_china_stab_threshold", self.riff_china_stab_threshold), 98 ]: 99 if not 0.0 <= value <= 1.0: 100 raise ValueError( 101 f"{param_name} must be between 0.0 and 1.0, got {value}" 102 )
Parameters controlling pattern generation.