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]
class DrumGenerator:
 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.

DrumGenerator(config_path: pathlib.Path | None = None)
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.

plugin_manager
drum_kit
midi_engine
def create_song( self, genre: str, style: str = 'default', tempo: int = 120, structure: list[tuple[str, int]] | None = None, drum_kit: midi_drums.core.models.kit.DrumKit | None = None, **kwargs) -> Song:
 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

def generate_pattern( self, genre: str, section: str = 'verse', bars: int = 4, **kwargs) -> Pattern | None:
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 )

def apply_drummer_style( self, pattern: Pattern, drummer: str, intensity: float = 1.0) -> Pattern | None:
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.

def export_midi( self, song: Song, output_path: pathlib.Path) -> None:
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.

def export_pattern_midi( self, pattern: Pattern, output_path: pathlib.Path, tempo: int = 120, drum_kit: midi_drums.core.models.kit.DrumKit | None = None) -> None:
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.

def get_available_genres(self) -> list[str]:
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.

def get_available_drummers(self) -> list[str]:
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.

def get_styles_for_genre(self, genre: str) -> list[str]:
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.

def get_song_info(self, song: Song) -> dict:
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.

def set_drum_kit(self, kit: midi_drums.core.models.kit.DrumKit) -> None:
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.

def create_drum_kit(self, kit_type: str) -> midi_drums.core.models.kit.DrumKit:
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.

@classmethod
def quick_generate( cls, genre: str = 'metal', style: str = 'heavy', tempo: int = 155) -> Song:
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.

@dataclass
class Pattern:
 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.

Pattern( name: str, beats: list[Beat] = <factory>, time_signature: TimeSignature = <factory>, subdivision: int = 16, swing_ratio: float = 0.0, metadata: dict[str, typing.Any] = <factory>)
name: str
beats: list[Beat]
time_signature: TimeSignature
subdivision: int = 16
swing_ratio: float = 0.0
metadata: dict[str, typing.Any]
def add_beat( self, position: float, instrument: midi_drums.core.value_objects.drum_instrument.DrumInstrument, velocity: int = 100, **kwargs) -> Pattern:
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.

def get_beats_at_position( self, position: float, tolerance: float = 0.01) -> list[Beat]:
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.

def get_beats_by_instrument( self, instrument: midi_drums.core.value_objects.drum_instrument.DrumInstrument) -> list[Beat]:
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.

def duration_bars(self) -> float:
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.

def humanize( self, timing_variance: float = 0.02, velocity_variance: float = 10) -> Pattern:
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.

def copy(self) -> Pattern:
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.

@dataclass
class Beat:
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.

Beat( position: float, instrument: midi_drums.core.value_objects.drum_instrument.DrumInstrument, velocity: int = 100, duration: float = 0.25, ghost_note: bool = False, accent: bool = False, instrument_promoted: bool = False)
position: float
instrument: midi_drums.core.value_objects.drum_instrument.DrumInstrument
velocity: int = 100
duration: float = 0.25
ghost_note: bool = False
accent: bool = False
instrument_promoted: bool = False
@dataclass
class TimeSignature:
 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.

TimeSignature(numerator: int = 4, denominator: int = 4)
numerator: int = 4
denominator: int = 4
beats_per_bar: float
28    @property
29    def beats_per_bar(self) -> float:
30        return self.numerator * (4.0 / self.denominator)
@dataclass
class Song:
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.

Song( name: str, tempo: int = 120, time_signature: TimeSignature = <factory>, sections: list[Section] = <factory>, global_parameters: GenerationParameters | None = None, metadata: dict[str, typing.Any] = <factory>)
name: str
tempo: int = 120
time_signature: TimeSignature
sections: list[Section]
global_parameters: GenerationParameters | None = None
metadata: dict[str, typing.Any]
def add_section( self, section: Section) -> Song:
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.

def total_bars(self) -> int:
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.

def total_duration_seconds(self) -> float:
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.

def section_start_times(self) -> list[float]:
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.

def get_section_by_name(self, name: str) -> Section | None:
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.

def get_sections_by_name(self, name: str) -> list[Section]:
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.

@classmethod
def create_simple_structure( cls, name: str, tempo: int = 120, genre: str = 'rock', style: str = 'default') -> Song:
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.

@dataclass
class Section:
 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.

Section( name: str, pattern: Pattern, bars: int = 4, variations: list[midi_drums.core.models.song.PatternVariation] = <factory>, fills: list[midi_drums.core.models.song.Fill] = <factory>, section_parameters: dict[str, typing.Any] = <factory>, segments: list[midi_drums.core.models.song.SongSegment] = <factory>)
name: str
pattern: Pattern
bars: int = 4
variations: list[midi_drums.core.models.song.PatternVariation]
fills: list[midi_drums.core.models.song.Fill]
section_parameters: dict[str, typing.Any]
segments: list[midi_drums.core.models.song.SongSegment]
def segment_for_bar(self, bar_number: int) -> midi_drums.core.models.song.SongSegment | None:
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."

def effective_tempo(self, bar_number: int, song_tempo: int) -> int:
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.

def effective_time_signature( self, bar_number: int, song_time_signature: TimeSignature) -> TimeSignature:
 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.

def resolved_bar_specs( self, song_tempo: int, song_time_signature: TimeSignature) -> list[tuple[int, int, TimeSignature]]:
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.

def get_effective_pattern(self, bar_number: int) -> Pattern:
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.

def should_add_fill( self, bar_number: int, fill_frequency: float) -> midi_drums.core.models.song.Fill | None:
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.

@dataclass
class GenerationParameters:
 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.

GenerationParameters( genre: str, style: str = 'default', drummer: str | None = None, drummer_intensity: float = 1.0, complexity: float = 0.5, dynamics: float = 0.5, humanization: float = 0.3, fill_frequency: float = 0.2, swing_ratio: float = 0.0, ride_threshold: float = 0.9, song_genre_context: str | None = None, context_blend: float = 0.0, riff_accents: midi_drums.core.value_objects.riff_accent.RiffAccentMap | None = None, riff_lock_strength: float = 1.0, riff_snare_mode: Literal['off', 'reinforce', 'stab'] = 'off', riff_snare_stab_threshold: float = 0.85, riff_hihat_mode: Literal['off', 'reinforce', 'stab'] = 'off', riff_hihat_stab_threshold: float = 0.85, riff_crash_mode: Literal['off', 'reinforce', 'stab'] = 'off', riff_crash_stab_threshold: float = 0.85, riff_ride_mode: Literal['off', 'reinforce', 'stab'] = 'off', riff_ride_stab_threshold: float = 0.85, riff_china_mode: Literal['off', 'reinforce', 'stab'] = 'off', riff_china_stab_threshold: float = 0.85, custom_parameters: dict[str, typing.Any] = <factory>)
genre: str
style: str = 'default'
drummer: str | None = None
drummer_intensity: float = 1.0
complexity: float = 0.5
dynamics: float = 0.5
humanization: float = 0.3
fill_frequency: float = 0.2
swing_ratio: float = 0.0
ride_threshold: float = 0.9
song_genre_context: str | None = None
context_blend: float = 0.0
riff_accents: midi_drums.core.value_objects.riff_accent.RiffAccentMap | None = None
riff_lock_strength: float = 1.0
riff_snare_mode: Literal['off', 'reinforce', 'stab'] = 'off'
riff_snare_stab_threshold: float = 0.85
riff_hihat_mode: Literal['off', 'reinforce', 'stab'] = 'off'
riff_hihat_stab_threshold: float = 0.85
riff_crash_mode: Literal['off', 'reinforce', 'stab'] = 'off'
riff_crash_stab_threshold: float = 0.85
riff_ride_mode: Literal['off', 'reinforce', 'stab'] = 'off'
riff_ride_stab_threshold: float = 0.85
riff_china_mode: Literal['off', 'reinforce', 'stab'] = 'off'
riff_china_stab_threshold: float = 0.85
custom_parameters: dict[str, typing.Any]