feat: pattern-based generators from real track analysis, RPP structure fixes, randomization

- Reverse-engineer drum patterns from 2 real reggaeton tracks with librosa
- Create patterns.py with extracted frequency data (kick/snare/hihat positions)
- Rewrite rhythm.py with pattern-bank generators (dembow, dense, trapico, offbeat)
- Rewrite melodic.py with section-aware generators and humanization
- Add weighted random sample selection in SampleSelector (top-5 pool)
- Add generate_structure() with randomized templates and energy variance
- Fix RPP structure: TEMPO arity (3→4 args), string quoting for empty strings
- Rewrite quick_drumloop_test.py with correct REAPER ground truth format
- Add scripts/analyze_examples.py for reverse engineering audio tracks
- Add --seed argument for reproducible generation
- 72 tests passing
This commit is contained in:
renato97
2026-05-03 16:08:07 -03:00
parent 32dafd94e0
commit 3444006411
10 changed files with 1664 additions and 285 deletions

View File

@@ -12,6 +12,7 @@ from __future__ import annotations
import argparse
import json
import random
import sys
from pathlib import Path
@@ -26,6 +27,7 @@ from src.core.schema import (
from src.composer.rhythm import get_notes, GENERATORS as RHYTHM_GENERATORS
from src.composer.melodic import bass_tresillo, lead_hook, chords_block, pad_sustain
from src.composer.converters import rhythm_to_midi, melodic_to_midi
from src.composer.patterns import generate_structure
from src.selector import SampleSelector
from src.reaper_builder import RPPBuilder
from src.reaper_builder.render import render_project
@@ -67,9 +69,9 @@ ROLE_MELODIC_GENERATORS = {
}
ROLE_RHYTHM_GENERATORS = {
"drums": "kick_main_notes",
"snare": "snare_verse_notes",
"hihat": "hihat_16th_notes",
"drums": "kick_pattern_bank_notes",
"snare": "snare_pattern_bank_notes",
"hihat": "hihat_pattern_bank_notes",
"perc": "perc_combo_notes",
}
@@ -197,6 +199,10 @@ def build_section_tracks(
selector: SampleSelector,
key: str,
bpm: float,
sections_data: list[dict] | None = None,
humanize: float = 0.3,
groove_strength: float = 0.3,
bank_weights: list[tuple[str, float]] | None = None,
) -> tuple[list[TrackDef], list[SectionDef]]:
"""Build all tracks from genre config sections.
@@ -208,17 +214,33 @@ def build_section_tracks(
selector: SampleSelector for sample queries
key: Musical key (e.g. "Am")
bpm: BPM for sample selection
sections_data: List of section dicts with 'name', 'bars', 'energy' keys.
If None, falls back to reading 'sections' from genre_config.
humanize: Humanization amount for melodic generators (0.0-1.0)
groove_strength: Groove amount for rhythm generators (0.0-1.0)
bank_weights: List of (bank_name, weight) tuples for weighted random bank selection
Returns:
(tracks, sections)
"""
structure = genre_config.get("structure", {})
sections_raw = structure.get("sections", [])
roles = genre_config.get("roles", {})
# Fall back to fixed sections from genre config for backward compatibility
if sections_data is None:
sections_data = genre_config.get("structure", {}).get("sections", [])
# Default bank weights for drums — weighted random selection
if bank_weights is None:
bank_weights = [
("dembow_classico", 3),
("dense", 3),
("perreo", 2),
("trapico", 1),
]
# Parse sections into SectionDef list
sections: list[SectionDef] = []
for s in sections_raw:
for s in sections_data:
sections.append(SectionDef(
name=s.get("name", "unknown"),
bars=s.get("bars", 4),
@@ -265,7 +287,17 @@ def build_section_tracks(
if role in ROLE_RHYTHM_GENERATORS:
gen_name = ROLE_RHYTHM_GENERATORS[role]
note_dict = get_notes(gen_name, section.bars, velocity_mult=vel_mult)
# Weighted random bank selection for variation
bank_names = [b[0] for b in bank_weights]
bank_weight_values = [b[1] for b in bank_weights]
bank = random.choices(bank_names, weights=bank_weight_values, k=1)[0]
note_dict = get_notes(
gen_name, section.bars,
velocity_mult=vel_mult,
bank=bank,
groove_strength=groove_strength,
)
# Audio roles: one clip per hit (one-shot samples placed at beat positions)
if role in AUDIO_ROLES:
@@ -291,7 +323,13 @@ def build_section_tracks(
section_clips.append(clip)
elif role in ROLE_MELODIC_GENERATORS:
gen_fn = ROLE_MELODIC_GENERATORS[role]
note_list = gen_fn(key=key, bars=section.bars, velocity_mult=vel_mult)
note_list = gen_fn(
key=key,
bars=section.bars,
velocity_mult=vel_mult,
section_type=section.name,
humanize=humanize,
)
midi_notes = melodic_to_midi(note_list)
# Melodic roles use MIDI instruments — no audio_path needed
clip = ClipDef(
@@ -377,6 +415,12 @@ def main() -> None:
default=None,
help="Output WAV path for rendering.",
)
parser.add_argument(
"--seed",
type=int,
default=None,
help="Random seed for reproducible output (default: unseeded for max variation).",
)
args = parser.parse_args()
# Validate BPM
@@ -404,8 +448,15 @@ def main() -> None:
selector = SampleSelector(str(index_path))
# Build tracks and sections from genre config
tracks, sections = build_section_tracks(genre_config, selector, args.key, args.bpm)
# Generate section structure from template with randomization
# Note: generate_structure reseeds random internally if seed is provided
sections_data = generate_structure(genre_config, args.bpm, args.key, seed=args.seed)
# Build tracks and sections
tracks, sections = build_section_tracks(
genre_config, selector, args.key, args.bpm, sections_data,
humanize=0.3, groove_strength=0.3,
)
# Create return tracks
return_tracks = create_return_tracks()
@@ -434,7 +485,7 @@ def main() -> None:
print(f" - {e}", file=sys.stderr)
# Write .rpp
builder = RPPBuilder(song)
builder = RPPBuilder(song, seed=args.seed)
builder.write(str(output_path))
# Render if requested