From 3444006411cdbe5fc42cbc4ed826faf8966dd372 Mon Sep 17 00:00:00 2001 From: renato97 Date: Sun, 3 May 2026 16:08:07 -0300 Subject: [PATCH] feat: pattern-based generators from real track analysis, RPP structure fixes, randomization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- knowledge/genres/reggaeton_2009.json | 82 +++-- scripts/analyze_examples.py | 227 ++++++++++++++ scripts/compose.py | 73 ++++- scripts/quick_drumloop_test.py | 242 +++++++++++++++ src/composer/melodic.py | 435 +++++++++++++++++---------- src/composer/patterns.py | 425 ++++++++++++++++++++++++++ src/composer/rhythm.py | 390 +++++++++++++++++++----- src/reaper_builder/__init__.py | 38 ++- src/selector/__init__.py | 26 +- tests/test_compose_integration.py | 11 +- 10 files changed, 1664 insertions(+), 285 deletions(-) create mode 100644 scripts/analyze_examples.py create mode 100644 scripts/quick_drumloop_test.py create mode 100644 src/composer/patterns.py diff --git a/knowledge/genres/reggaeton_2009.json b/knowledge/genres/reggaeton_2009.json index b35f650..e04ec5a 100644 --- a/knowledge/genres/reggaeton_2009.json +++ b/knowledge/genres/reggaeton_2009.json @@ -2,64 +2,90 @@ "genre": "reggaeton", "era": "2009", "display_name": "Reggaeton 2009 (Era de Oro)", - "description": "Reggaeton comercial 2006-2010. Daddy Yankee, Wisin y Yandel, Don Omar, Tito El Bambino, Hector El Father. Beat dembow con 808, piano stabs, brass hits.", + "description": "Reggaeton comercial 2006-2010. Daddy Yankee, Wisin y Yandel, Don Omar, Tito El Bambino, Hector El Father. Beat dembow con 808, piano stabs, brass hits. AGGRESSIVE groove, dense drum loops, GAP/BREAK section technique.", "bpm": { "min": 88, "max": 102, - "default": 96 + "default": 99 }, "keys": ["Am", "Dm", "Gm", "Cm", "Em", "Fm", "Bbm"], "time_signature": [4, 4], "ppq": 96, "structure": { - "template": "intro-verse-chorus-verse-chorus-outro", - "sections": [ - {"name": "intro", "bars": 4, "energy": 0.3}, - {"name": "verse", "bars": 8, "energy": 0.6}, - {"name": "chorus", "bars": 8, "energy": 0.9}, - {"name": "verse2", "bars": 8, "energy": 0.6}, - {"name": "chorus2", "bars": 8, "energy": 1.0}, - {"name": "bridge", "bars": 4, "energy": 0.5}, - {"name": "chorus3", "bars": 8, "energy": 1.0}, - {"name": "outro", "bars": 4, "energy": 0.4} - ] + "template": "extracted_real_tracks", + "templates": { + "extracted_real_tracks": [ + {"name": "build", "bars": 16, "energy": 0.6}, + {"name": "verse", "bars": 2, "energy": 0.35}, + {"name": "build", "bars": 47, "energy": 0.65}, + {"name": "verse", "bars": 1, "energy": 0.35}, + {"name": "gap", "bars": 1, "energy": 0.05}, + {"name": "drop", "bars": 2, "energy": 1.0}, + {"name": "build", "bars": 5, "energy": 0.7}, + {"name": "break", "bars": 2, "energy": 0.05}, + {"name": "drop", "bars": 1, "energy": 0.95}, + {"name": "build", "bars": 12, "energy": 0.65}, + {"name": "verse", "bars": 2, "energy": 0.35}, + {"name": "build", "bars": 8, "energy": 0.6} + ], + "standard": [ + {"name": "intro", "bars": 4, "energy": 0.3}, + {"name": "verse", "bars": 8, "energy": 0.35}, + {"name": "chorus", "bars": 8, "energy": 0.95}, + {"name": "verse", "bars": 8, "energy": 0.35}, + {"name": "chorus", "bars": 8, "energy": 0.95}, + {"name": "bridge", "bars": 8, "energy": 0.4}, + {"name": "chorus", "bars": 8, "energy": 0.95}, + {"name": "outro", "bars": 8, "energy": 0.35} + ] + }, + "gap_break_energy_contrast": 50.0, + "dembow_positions": [0, 4, 8, 11, 12], + "note": "GAP/BREAK technique: 1-2 bars near-silence (-50dB) followed by loud DROP (+6dB) creates massive contrast. Real tracks use this extensively." + }, + "pattern_banks": { + "dembow_classico": "extracted from 99.4 BPM track (Ejemplo 1)", + "perreo": "aggressive variant with extra kicks at beat 1.5& and 2", + "trapico": "half-time feel, kicks at beats 1 and 3 only", + "dense": "full drum loop density, highest hit frequency positions" }, "roles": { "drums": { - "description": "Patron dembow - kick en 1 y 2.5, snare en 2 y 4, hi-hats en corcheas", - "pattern_type": "dembow", + "description": "DENSE drum loop (NOT sparse kick-snare). Kicks at positions 4, 11, 8, 12, 9 — the dembow IS there but buried in a full loop. Pattern bank: kick_pattern_bank_notes", + "pattern_type": "drum_loop", "preferred_plugins": ["FPC", "Fruity DrumSynth Live", "DirectWave", "Kontakt 7"], "midi_channel": 0, "mixer_slot": 0, - "notes_template": "dembow" + "notes_template": "drum_loop_dembow", + "bank": "dembow_classico" }, "bass": { - "description": "808 sub bass que sigue al kick. Sostenido, octave 2.", + "description": "808 sub bass. Section-aware: follows section energy. Tresillo grouping in drop/chorus, sparse in break/gap.", "pattern_type": "808_follow_kick", "preferred_plugins": ["Serum 2", "Transistor Bass", "Sytrus", "3x Osc", "ravity(S)"], "midi_channel": 1, "mixer_slot": 1, "octave": 2, - "notes_template": "bass_808" + "notes_template": "bass_tresillo_section_aware" }, "harmony": { - "description": "Piano stabs en offbeats. Closed triads.", + "description": "Piano stabs in offbeats. Closed triads. Section-aware velocity.", "pattern_type": "piano_stabs", "preferred_plugins": ["FL Keys", "Nexus2", "Kontakt 7", "Sakura", "Pigments"], "midi_channel": 2, "mixer_slot": 2, - "notes_template": "piano_stabs" + "notes_template": "piano_stabs_section_aware" }, "lead": { - "description": "Brass hit o melodia sintetizada. Hook del coro.", + "description": "Brass hit o melodia sintetizada. Hook syncopation on dembow positions (4, 11, 12). Section-aware density.", "pattern_type": "brass_hook", "preferred_plugins": ["Serum 2", "Omnisphere", "Harmor", "Electra", "ravity(S)"], "midi_channel": 3, "mixer_slot": 3, - "notes_template": "lead_hook" + "notes_template": "lead_hook_section_aware" }, "pad": { - "description": "Pad atmosferico sutil para llenar el fondo.", + "description": "Pad atmosferico sutil para llenar el fondo. Sustained chord tones.", "pattern_type": "sustained_pad", "preferred_plugins": ["Harmor", "Serum 2", "Omnisphere", "FLEX", "Pigments"], "midi_channel": 4, @@ -83,7 +109,7 @@ "popularity": 0.9 }, { - "name": "tensión", + "name": "tension", "chords": ["Am", "F", "C", "G"], "beats_per_chord": 4, "popularity": 0.7 @@ -152,5 +178,11 @@ "Rumor de Guerra - Hector El Father", "Pose - Daddy Yankee", "Llamé Pa Verte - Wisin y Yandel" - ] + ], + "analysis_notes": { + "key_finding": "The dembow IS present (positions 0, 4, 8, 11, 12 high across all instruments) but it's buried in a DENSE drum loop, NOT the textbook sparse kick-snare pattern.", + "kick_insight": "Position 4 (beat 2) is the DENSEST kick hit — not beat 1. Real tracks have kicks on almost EVERY 16th note position.", + "gap_break_technique": "1-2 bars near-silence (-50dB) followed by loud DROP (+6dB over baseline) creates massive contrast. This is a signature reggaetón technique.", + "filter_sweeps": "Spectral centroid drops detected in breakdowns, confirming HPF filtering in intro/filtered sections." + } } diff --git a/scripts/analyze_examples.py b/scripts/analyze_examples.py new file mode 100644 index 0000000..6341c49 --- /dev/null +++ b/scripts/analyze_examples.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python +"""Reverse-engineer drum patterns from example reggaetón tracks.""" +from __future__ import annotations +import librosa +import numpy as np +from pathlib import Path + +ROOT = Path(__file__).parent.parent + + +def analyze_track(path: str, track_name: str) -> dict: + y, sr = librosa.load(path, sr=44100, mono=True) + duration = len(y) / sr + + # Tempo and beats + tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr) + if isinstance(tempo, np.ndarray): + tempo = float(tempo[0]) if tempo.ndim > 0 else float(tempo) + beat_times = librosa.frames_to_time(beat_frames, sr=sr) + bar_duration = 4 * 60.0 / tempo + sixteenth = 60.0 / tempo / 4 + + print(f"\n{'='*60}") + print(f" {track_name}") + print(f" Duration: {duration:.1f}s | Tempo: {tempo:.1f} BPM") + print(f" Bar: {bar_duration:.3f}s | 16th: {sixteenth:.4f}s") + print(f"{'='*60}\n") + + # ---- KICK DETECTION (low frequency onsets) ---- + onset_env_low = librosa.onset.onset_strength( + y=y, sr=sr, + feature=librosa.feature.melspectrogram, + fmin=20, fmax=300, + ) + kick_onsets = librosa.onset.onset_detect( + onset_envelope=onset_env_low, sr=sr, + units="time", backtrack=False, + ) + + # ---- SNARE DETECTION (mid-high frequency onsets) ---- + onset_env_high = librosa.onset.onset_strength( + y=y, sr=sr, + feature=librosa.feature.melspectrogram, + fmin=800, fmax=8000, + ) + snare_onsets = librosa.onset.onset_detect( + onset_envelope=onset_env_high, sr=sr, + units="time", backtrack=False, + ) + + # ---- HIHAT DETECTION (very high frequency) ---- + onset_env_hh = librosa.onset.onset_strength( + y=y, sr=sr, + feature=librosa.feature.melspectrogram, + fmin=5000, fmax=16000, + ) + hihat_onsets = librosa.onset.onset_detect( + onset_envelope=onset_env_hh, sr=sr, + units="time", backtrack=False, + ) + + first_beat = beat_times[0] + + # Analyze first 16 bars + n_bars = min(16, int(len(beat_times) / 4)) + print("KICK PATTERN (first 16 bars, 16th note grid):") + print(" Grid: 1 & 2 & 3 & 4 & 1e &a 2e &a 3e &a 4e &a\n") + kick_pattern_counts: dict[int, int] = {} + + for bar in range(n_bars): + bar_start = first_beat + bar * bar_duration + bar_end = bar_start + bar_duration + bar_kicks = kick_onsets[(kick_onsets >= bar_start) & (kick_onsets < bar_end)] + positions = [] + for k in bar_kicks: + offset = k - bar_start + sixteenth_pos = round(offset / sixteenth) + if 0 <= sixteenth_pos < 16: + positions.append(sixteenth_pos) + kick_pattern_counts[sixteenth_pos] = kick_pattern_counts.get(sixteenth_pos, 0) + 1 + + pattern = ["."] * 16 + for p in positions: + pattern[p] = "K" + line = " ".join(pattern) + print(f" Bar {bar+1:2d}: {line}") + + # Most common kick positions + print(f"\n Kick frequency: {dict(sorted(kick_pattern_counts.items()))}") + + print(f"\nSNARE PATTERN (first 16 bars, 16th note grid):\n") + snare_pattern_counts: dict[int, int] = {} + + for bar in range(n_bars): + bar_start = first_beat + bar * bar_duration + bar_end = bar_start + bar_duration + bar_snares = snare_onsets[(snare_onsets >= bar_start) & (snare_onsets < bar_end)] + positions = [] + for s in bar_snares: + offset = s - bar_start + sixteenth_pos = round(offset / sixteenth) + if 0 <= sixteenth_pos < 16: + positions.append(sixteenth_pos) + snare_pattern_counts[sixteenth_pos] = snare_pattern_counts.get(sixteenth_pos, 0) + 1 + + pattern = ["."] * 16 + for p in positions: + pattern[p] = "S" + line = " ".join(pattern) + print(f" Bar {bar+1:2d}: {line}") + + print(f"\n Snare frequency: {dict(sorted(snare_pattern_counts.items()))}") + + print(f"\nHIHAT PATTERN (first 16 bars, 16th note grid):\n") + hihat_pattern_counts: dict[int, int] = {} + + for bar in range(n_bars): + bar_start = first_beat + bar * bar_duration + bar_end = bar_start + bar_duration + bar_hh = hihat_onsets[(hihat_onsets >= bar_start) & (hihat_onsets < bar_end)] + positions = [] + for h in bar_hh: + offset = h - bar_start + sixteenth_pos = round(offset / sixteenth) + if 0 <= sixteenth_pos < 16: + positions.append(sixteenth_pos) + hihat_pattern_counts[sixteenth_pos] = hihat_pattern_counts.get(sixteenth_pos, 0) + 1 + + pattern = ["."] * 16 + for p in positions: + pattern[p] = "H" + line = " ".join(pattern) + print(f" Bar {bar+1:2d}: {line}") + + print(f"\n Hihat frequency: {dict(sorted(hihat_pattern_counts.items()))}") + + # ---- SECTION ANALYSIS (full track) ---- + hop = 2048 + rms = librosa.feature.rms(y=y, hop_length=hop, frame_length=4096)[0] + rms_times = librosa.times_like(rms, sr=sr, hop_length=hop) + rms_db = librosa.amplitude_to_db(rms, ref=np.max) + + # Energy per bar + total_bars = int(len(beat_times) / 4) + bar_energies = [] + for b in range(total_bars): + start = beat_times[b * 4] + end = beat_times[min((b + 1) * 4, len(beat_times) - 1)] + mask = (rms_times >= start) & (rms_times <= end) + if np.any(mask): + bar_energies.append(float(np.mean(rms_db[mask]))) + else: + bar_energies.append(-60.0) + + # Detect sections by energy clustering + from scipy.ndimage import uniform_filter1d + smooth = uniform_filter1d(np.array(bar_energies), size=2) + + # Find section boundaries (>6dB change) + diff = np.diff(smooth) + boundaries = [0] + for idx, d in enumerate(diff): + if abs(d) > 6: + boundaries.append(idx + 1) + boundaries.append(total_bars) + + print(f"\n\nDETECTED SECTIONS ({len(boundaries)-1} sections):\n") + section_labels = [] + for s in range(len(boundaries) - 1): + start_bar = boundaries[s] + end_bar = boundaries[s + 1] + start_time = beat_times[start_bar * 4] if start_bar * 4 < len(beat_times) else 0 + n_section_bars = end_bar - start_bar + avg_energy = np.mean(bar_energies[start_bar:end_bar]) + + # Classify section by energy + if avg_energy < -30: + label = "SILENCE/BREAK" + elif avg_energy < -20: + label = "INTRO/FILTER" + elif avg_energy < -12: + label = "VERSE/BRIDGE" + elif avg_energy < -6: + label = "BUILD/PRE-CHORUS" + else: + label = "CHORUS/DROP" + + section_labels.append(label) + print(f" {start_time:6.1f}s | Bars {start_bar+1:3d}-{end_bar:3d} ({n_section_bars:2d} bars) | {avg_energy:+6.1f} dB | {label}") + + # ---- SPECTRAL ANALYSIS (filter sweeps) ---- + spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr)[0] + sc_times = librosa.times_like(spectral_centroid, sr=sr) + + # Smooth and find big changes + sc_smooth = uniform_filter1d(spectral_centroid, size=50) + sc_diff = np.diff(sc_smooth) + big_drops = np.where(sc_diff < -500)[0] + big_rises = np.where(sc_diff > 500)[0] + + if len(big_drops) > 0: + print(f"\n\nFILTER SWEEPS (spectral centroid drops):\n") + for d in big_drops[:10]: + t = sc_times[d] + print(f" {t:.1f}s - centroid dropped {sc_diff[d]:.0f} Hz (HPF engaging)") + + if len(big_rises) > 0: + print(f"\nFILTER OPENS (spectral centroid rises):\n") + for r in big_rises[:10]: + t = sc_times[r] + print(f" {t:.1f}s - centroid rose {sc_diff[r]:.0f} Hz (filter opening)") + + return { + "tempo": tempo, + "duration": duration, + "n_bars": total_bars, + "kick_pattern": kick_pattern_counts, + "snare_pattern": snare_pattern_counts, + "hihat_pattern": hihat_pattern_counts, + "sections": section_labels, + } + + +if __name__ == "__main__": + for i in [1, 2]: + path = ROOT / "ejemplos" / f"ejemplo{i}.mp3" + result = analyze_track(str(path), f"ejemplo{i}.mp3") diff --git a/scripts/compose.py b/scripts/compose.py index 7ee1660..d57339e 100644 --- a/scripts/compose.py +++ b/scripts/compose.py @@ -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 diff --git a/scripts/quick_drumloop_test.py b/scripts/quick_drumloop_test.py new file mode 100644 index 0000000..bc91230 --- /dev/null +++ b/scripts/quick_drumloop_test.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python +"""Quick test: one drum loop repeated 4 times in track 2. + +This script demonstrates the CORRECT REAPER .rpp format by matching +the ground truth from output/all_plugins.rpp and output/test_vst3.rpp. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(ROOT)) + +from rpp import Element +import rpp + + +DRUM_LOOP = r"C:\Users\Administrator\Documents\fl_control\libreria\samples\drumloop\drumloop_D2_099_boomy_f8b5a5.wav" +OUTPUT = str(ROOT / "output" / "drumloop_test.rpp") + + +def build(): + """Build a valid REAPER .rpp with drum loop items.""" + # Project root — matching ground truth format from all_plugins.rpp + project = Element("REAPER_PROJECT", ["0.1", "7.65/win64", "12345678901234567890", "0"]) + + # Project header from ground truth — tokens match exactly what REAPER expects + project.append(Element("NOTES", ["0", "2"])) + project.append([]) + project.append(["RIPPLE", "0", "0"]) + project.append(["GROUPOVERRIDE", "0", "0", "0", "0"]) + project.append(["AUTOXFADE", "129"]) + project.append(["ENVATTACH", "3"]) + project.append(["POOLEDENVATTACH", "0"]) + project.append(["TCPUIFLAGS", "0"]) + project.append(["MIXERUIFLAGS", "11", "48"]) + project.append(["ENVFADESZ10", "40"]) + project.append(["PEAKGAIN", "1"]) + project.append(["FEEDBACK", "0"]) + project.append(["PANLAW", "1"]) + project.append(["PROJOFFS", "0", "0", "0"]) + project.append(["MAXPROJLEN", "0", "0"]) + project.append(["GRID", "3199", "8", "1", "8", "1", "0", "0", "0"]) + project.append(["TIMEMODE", "1", "5", "-1", "30", "0", "0", "-1", "0"]) + project.append(["VIDEO_CONFIG", "0", "0", "65792"]) + project.append(["PANMODE", "3"]) + project.append(["PANLAWFLAGS", "3"]) + project.append(["CURSOR", "0"]) + project.append(["ZOOM", "100", "0", "0"]) + project.append(["VZOOMEX", "6", "0"]) + project.append(["USE_REC_CFG", "0"]) + project.append(["RECMODE", "1"]) + project.append(["SMPTESYNC", "0", "30", "100", "40", "1000", "300", "0", "0", "1", "0", "0"]) + project.append(["LOOP", "0"]) + project.append(["LOOPGRAN", "0", "4"]) + project.append(["RECORD_PATH", "Media", ""]) + project.append(Element("RECORD_CFG", [], children=["ZXZhdxgAAQ=="])) + project.append([]) + project.append(Element("APPLYFX_CFG", [], children=[])) + project.append([]) + project.append(["RENDER_FILE", ""]) + project.append(["RENDER_PATTERN", ""]) + project.append(["RENDER_FMT", "0", "2", "0"]) + project.append(["RENDER_1X", "0"]) + project.append(["RENDER_RANGE", "1", "0", "0", "0", "1000"]) + project.append(["RENDER_RESAMPLE", "3", "0", "1"]) + project.append(["RENDER_ADDTOPROJ", "0"]) + project.append(["RENDER_STEMS", "0"]) + project.append(["RENDER_DITHER", "0"]) + project.append(["RENDER_TRIM", "0.000001", "0.000001", "0", "0"]) + project.append(["TIMELOCKMODE", "1"]) + project.append(["TEMPOENVLOCKMODE", "1"]) + project.append(["ITEMMIX", "1"]) + project.append(["DEFPITCHMODE", "589824", "0"]) + project.append(["TAKELANE", "1"]) + project.append(["SAMPLERATE", "44100", "0", "0"]) + project.append([]) + project.append(["LOCK", "1"]) + project.append(Element("METRONOME", ["6", "2"], children=[ + ["VOL", "0.25", "0.125"], + ["BEATLEN", "4"], + ["FREQ", "1760", "880", "1"], + ["SAMPLES", "", "", "", ""], + ["SPLIGNORE", "0", "0"], + ["SPLDEF", "2", "660", "", "0", ""], + ["SPLDEF", "3", "440", "", "0", ""], + ["PATTERN", "0", "169"], + ["PATTERNSTR", "ABBB"], + ["MULT", "1"], + ])) + project.append([]) + project.append(["GLOBAL_AUTO", "-1"]) + # TEMPO with 4 args: bpm, time_sig_num, time_sig_den, flag + project.append(["TEMPO", "99", "4", "4", "0"]) + project.append(["PLAYRATE", "1", "0", "0.25", "4"]) + project.append(["SELECTION", "0", "0"]) + project.append(["SELECTION2", "0", "0"]) + project.append(["MASTERAUTOMODE", "0"]) + project.append(["MASTERTRACKHEIGHT", "0", "0"]) + project.append(["MASTERPEAKCOL", "16576"]) + project.append(["MASTERMUTESOLO", "0"]) + project.append(["MASTERTRACKVIEW", "0", "0.6667", "0.5", "0.5", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0"]) + project.append(["MASTERHWOUT", "0", "0", "1", "0", "0", "0", "0", "-1"]) + project.append(["MASTER_NCH", "2", "2"]) + project.append(["MASTER_VOLUME", "1", "0", "-1", "-1", "1"]) + project.append(["MASTER_PANMODE", "3"]) + project.append(["MASTER_PANLAWFLAGS", "3"]) + project.append(["MASTER_FX", "1"]) + project.append(["MASTER_SEL", "0"]) + project.append(Element("MASTERPLAYSPEEDENV", [], children=[ + ["EGUID", "{DEF87440-E07C-4B72-B9F8-D2AC60A0D0AC}"], + ["ACT", "0", "-1"], + ["VIS", "0", "1", "1"], + ["LANEHEIGHT", "0", "0"], + ["ARM", "0"], + ["DEFSHAPE", "0", "-1", "-1"], + ])) + project.append([]) + project.append(Element("TEMPOENVEX", [], children=[ + ["EGUID", "{15E58A72-7149-4783-9A04-838503786012}"], + ["ACT", "1", "-1"], + ["VIS", "1", "0", "1"], + ["LANEHEIGHT", "0", "0"], + ["ARM", "0"], + ["DEFSHAPE", "1", "-1", "-1"], + ])) + project.append([]) + project.append(["RULERHEIGHT", "86", "86"]) + project.append(["RULERLANE", "1", "4", "", "0", "-1"]) + project.append(["RULERLANE", "2", "8", "", "0", "-1"]) + project.append([]) + + # Master track + master_guid = "{00000000-0000-0000-0000-000000000001}" + master = Element("TRACK", [master_guid]) + master.append(["NAME", "master"]) + master.append(["VOLPAN", "1", "0", "-1", "-1", "1"]) + master.append(["PEAKCOL", "16576"]) + master.append(["BEAT", "-1"]) + master.append(["AUTOMODE", "0"]) + master.append(["PANLAWFLAGS", "3"]) + master.append(["MUTESOLO", "0", "0", "0"]) + master.append(["IPHASE", "0"]) + master.append(["PLAYOFFS", "0", "1"]) + master.append(["ISBUS", "0", "0"]) + master.append(["BUSCOMP", "0", "0", "0", "0", "0"]) + master.append(["SHOWINMIX", "1", "0.6667", "0.5", "1", "0.5", "0", "0", "0", "0"]) + master.append(["FIXEDLANES", "9", "0", "0", "0", "0"]) + master.append(["SEL", "0"]) + master.append(["REC", "0", "0", "1", "0", "0", "0", "0", "0"]) + master.append(["VU", "64"]) + master.append(["TRACKHEIGHT", "0", "0", "0", "0", "0", "0", "0"]) + master.append(["INQ", "0", "0", "0", "0.5", "100", "0", "0", "100"]) + master.append(["NCHAN", "2"]) + master.append(["FX", "1"]) + master.append(["TRACKID", f"{{{master_guid}}}"]) + master.append(["PERF", "0"]) + master.append(["MIDIOUT", "-1"]) + master.append(["MAINSEND", "1", "0"]) + # Master FXCHAIN + master_fxchain = Element("FXCHAIN", []) + master_fxchain.append(["WNDRECT", "24", "52", "655", "408"]) + master_fxchain.append(["SHOW", "0"]) + master_fxchain.append(["LASTSEL", "0"]) + master_fxchain.append(["DOCKED", "0"]) + master_fxchain.append(["BYPASS", "0", "0", "0"]) + master_fxchain.append(["PRESETNAME", "Program 1"]) + master_fxchain.append(["FLOATPOS", "0", "0", "0", "0"]) + master_fxchain.append(["FXID", "{A0F6CA8C-99E7-4B1A-8411-CA7201811EAD}"]) + master.append(master_fxchain) + project.append(master) + + # Track 2: Drum Loop + track_guid = "{00000000-0000-0000-0000-000000000002}" + track = Element("TRACK", [track_guid]) + track.append(["NAME", "Drum Loop"]) + track.append(["VOLPAN", "0.85", "0", "-1", "-1", "1"]) + track.append(["PEAKCOL", "16576"]) + track.append(["BEAT", "-1"]) + track.append(["AUTOMODE", "0"]) + track.append(["PANLAWFLAGS", "3"]) + track.append(["MUTESOLO", "0", "0", "0"]) + track.append(["IPHASE", "0"]) + track.append(["PLAYOFFS", "0", "1"]) + track.append(["ISBUS", "0", "0"]) + track.append(["BUSCOMP", "0", "0", "0", "0", "0"]) + track.append(["SHOWINMIX", "1", "0.6667", "0.5", "1", "0.5", "0", "0", "0", "0"]) + track.append(["FIXEDLANES", "9", "0", "0", "0", "0"]) + track.append(["SEL", "1"]) + track.append(["REC", "0", "0", "1", "0", "0", "0", "0", "0"]) + track.append(["VU", "64"]) + track.append(["TRACKHEIGHT", "0", "0", "0", "0", "0", "0", "0"]) + track.append(["INQ", "0", "0", "0", "0.5", "100", "0", "0", "100"]) + track.append(["NCHAN", "2"]) + track.append(["FX", "1"]) + track.append(["TRACKID", f"{{{track_guid}}}"]) + track.append(["PERF", "0"]) + track.append(["MIDIOUT", "-1"]) + track.append(["MAINSEND", "1", "0"]) + + # 4 clips of the drum loop, each 16 beats (4 bars) + loop_duration_beats = 16.0 + + for i in range(4): + position = i * loop_duration_beats + item = Element("ITEM", []) + item.append(["POSITION", f"{position:.6f}"]) + item.append(["LENGTH", f"{loop_duration_beats:.6f}"]) + item.append(["NAME", f"Drum Loop {i+1}"]) + item.append(["SOFFS", "0.0"]) + item.append(["PLAYRATE", "1", "0", "0.25", "4"]) + item.append(["CHANMODE", "0"]) + item.append(["GUID", f"{{00000000-0000-0000-0000-0000000000{i+10:02X}}}"]) + item.append(["MUTE", "0", "0"]) + item.append(["LOOP", "1"]) + item.append(["COLOR", "0"]) + + # Audio source — uses correct format + source = Element("SOURCE", ["WAVE"]) + source.append(["FILE", DRUM_LOOP]) + item.append(source) + + track.append(item) + + project.append(track) + + # Write + output_str = rpp.dumps(project) + # Quote the version string in the header + output_str = output_str.replace( + " tuple[int, str]: else: root_str = key_str scale_name = "major" - root = ROOT_SEMITONE.get(root_str) if root is None: raise ValueError(f"Unknown root: {root_str}") @@ -54,7 +86,7 @@ def _clamp_vel(v: int) -> int: return max(1, min(127, v)) -def _apply_humanize(notes, humanize): +def _apply_humanize(notes: list[dict], humanize: float) -> list[dict]: """Apply humanization (velocity jitter + position nudge) to note list.""" if humanize <= 0: return notes @@ -67,7 +99,7 @@ def _apply_humanize(notes, humanize): # --------------------------------------------------------------------------- -# Bass: tresillo +# Bass: tresillo 3-3-2 grouping — NOW section-aware # --------------------------------------------------------------------------- def bass_tresillo( @@ -76,42 +108,173 @@ def bass_tresillo( octave: int = 3, velocity_mult: float = 1.0, humanize: float = 0.0, + section_type: str = "verse", ) -> list[dict]: - """Reggaeton tresillo bass pattern. + """Reggaetón tresillo bass pattern — section-aware version. - 6 notes per bar at positions: 0.0, 0.75, 1.5, 2.25, 3.0, 3.75 - Root note on downbeats (0.0, 1.5, 3.0), fifth (7 semitones) on upbeats. - Velocity: 110 for downbeats, 85 for upbeats. - Default octave=3 gives root in MIDI range 45-52 (A3-E4), within 36-55. + The 3-3-2 grouping is the BASS DNA of reggaetón: + - 3 notes in first group (positions 0.0, 0.75, 1.5) + - 3 notes in second group (positions 2.25, 3.0, 3.75) + - 2 notes would complete the next bar... + + But for 6/8 feel within 4/4, we do 6 notes per bar: + Positions: [0.0, 0.75, 1.5, 2.25, 3.0, 3.75] + ↑ ↑ ↑ ↑ ↑ ↑ + 3 3 2 (groups) + + Root note on downbeats (0.0, 1.5, 3.0) + Fifth on upbeats (0.75, 2.25, 3.75) + Velocity: Root=110, Fifth=90 + + Default octave=3 gives root in MIDI range 45-52 (A3-E4), good for 808. + + Section-aware behavior: + - chorus/drop: full velocity, root on beat 1 + - verse/bridge: slightly softer (0.85x), stripped + - break/gap: minimal (0.3x), few notes + - intro: building (0.5x then ramp) """ root, scale = _parse_key(key) - scale_notes = _get_scale_notes(root, scale, octave) - root_note = scale_notes[0] # degree 0 - fifth_note = root_note + 7 # up a perfect fifth + root_note = root + (octave - 1) * 12 # octave 3 → MIDI 45 for A + fifth_note = root_note + 7 # perfect fifth above root + + # Section-aware velocity and density adjustments + section = section_type.lower() + if section in ("chorus", "drop"): + vel_root = 115 + vel_fifth = 95 + density_mult = 1.0 + elif section in ("verse", "bridge"): + vel_root = 95 + vel_fifth = 75 + density_mult = 1.0 + elif section in ("break", "gap"): + vel_root = 40 + vel_fifth = 30 + density_mult = 0.3 # sparse + elif section == "intro": + vel_root = 70 + vel_fifth = 55 + density_mult = 0.6 # building up + else: + vel_root = 100 + vel_fifth = 80 + density_mult = 0.9 notes: list[dict] = [] for b in range(bars): o = b * 4.0 - # Positions within the bar + # 6 positions per bar for the 3-3-2 tresillo feel positions = [0.0, 0.75, 1.5, 2.25, 3.0, 3.75] for idx, pos in enumerate(positions): - if idx % 2 == 0: # downbeats: root + if idx % 2 == 0: # downbeats: root (positions 0, 2, 4) key_note = root_note - vel = 110 - else: # upbeats: fifth + vel = vel_root + else: # upbeats: fifth (positions 1, 3, 5) key_note = fifth_note - vel = 85 + vel = vel_fifth + + # Apply density — skip some notes when density_mult < 1 + if random.random() > density_mult: + continue vel = _clamp_vel(int(vel * velocity_mult)) - notes.append({"pos": o + pos, "len": 0.25, "key": key_note, "vel": vel}) + notes.append({"pos": o + pos, "len": 0.5, "key": key_note, "vel": vel}) return _apply_humanize(notes, humanize) # --------------------------------------------------------------------------- -# Lead: hook +# Chords: block chords following i-VI-III-VII — section-aware # --------------------------------------------------------------------------- +def chords_block( + key: str, + bars: int, + octave: int = 4, + beats_per_chord: int = 4, + velocity_mult: float = 1.0, + humanize: float = 0.0, + section_type: str = "verse", +) -> list[dict]: + """Block chords following the i-VI-III-VII progression — section-aware. + + Default progression: Am - F - C - G (classic_minor) + Chord positions: every beats_per_chord beats + Each chord: root + third + fifth (3 notes stacked at same position) + + Section-aware behavior: + - chorus/drop: full velocity, tight voicings + - verse/bridge: softer (0.7x), wider voicings + - break/gap: near-silence + - intro: soft, building + """ + root, scale = _parse_key(key) + + # Use classic_minor progression for minor keys + progression_key = "classic_minor" + if scale != "minor": + progression_key = "major" + + prog_data = _CHORD_INTERVALLS.get(progression_key, _CHORD_INTERVALLS["classic_minor"]) + chord_intervals = prog_data["intervals"] + + # Section-aware velocity adjustments + section = section_type.lower() + if section in ("chorus", "drop"): + base_vel = 90 + voicing_spread = 0.0 # tight + elif section in ("verse", "bridge"): + base_vel = 65 + voicing_spread = 0.3 # wider, more open + elif section in ("break", "gap"): + base_vel = 30 + voicing_spread = 0.5 + elif section == "intro": + base_vel = 55 + voicing_spread = 0.2 + else: + base_vel = 75 + voicing_spread = 0.1 + + notes: list[dict] = [] + total_beats = bars * 4.0 + pos = 0.0 + + while pos < total_beats: + chord_idx = int(pos / beats_per_chord) % len(chord_intervals) + intervals = chord_intervals[chord_idx] + + # Add root, 3rd, 5th for this chord + for interval in intervals: + midi_note = root + octave * 12 + interval + # Spread 3rd and 5th slightly for wider voicings + if interval == intervals[1]: # third + midi_note += round(voicing_spread * 12) + elif interval == intervals[2]: # fifth + midi_note += round(voicing_spread * 6) + + vel = _clamp_vel(int(base_vel * velocity_mult)) + notes.append({ + "pos": pos, + "len": beats_per_chord - 0.25, + "key": midi_note, + "vel": vel, + }) + + pos += beats_per_chord + + return _apply_humanize(notes, humanize) + + +# --------------------------------------------------------------------------- +# Lead: hook with dembow syncopation — now section-aware +# --------------------------------------------------------------------------- + +# Scale degrees for reggaetón hook melody (pentatonic-based for catchiness) +_HOOK_DEGREES = [0, 2, 4, 2, 3, 1, 0, 2, 4, 5, 4, 2, 0] # 13-note motif + + def lead_hook( key: str, bars: int, @@ -119,54 +282,89 @@ def lead_hook( density: float = 0.6, velocity_mult: float = 1.0, humanize: float = 0.0, + section_type: str = "verse", ) -> list[dict]: - """Simple melodic hook over 4-8 bars. + """Lead hook with dembow syncopation — section-aware version. - Uses scalar degrees: [0, 2, 4, 2, 3, 1, 0, 2, 4, 5, 4, 2, 0] - Note durations: 0.5 or 1.0 beats. - density=1.0 → every slot filled; density=0.5 → half filled. + The hook emphasizes the DEMBOW syncopation — notes on beats 2, 3&, 4 + (positions 4.0, 11.0, 12.0 from the real-track analysis). + + Scale: Pentatonic or minor for reggaetón feel + Density: 0.6 means some slots are empty for groove + + Section-aware behavior: + - chorus/drop: higher density, longer notes on dembow positions + - verse/bridge: sparser, softer + - break/gap: near-silence (sparse, very soft) + - intro: building up from sparse """ root, scale = _parse_key(key) intervals = SCALES.get(scale, SCALES["major"]) - # Map scale degrees to MIDI notes (extend to cover octave 5 and 6 for melody) - scale_notes_oct5 = _get_scale_notes(root, scale, octave) # 7 notes + # Build scale notes for octave 5 and 6 + scale_notes_oct5 = _get_scale_notes(root, scale, octave) # 7 notes scale_notes_oct6 = _get_scale_notes(root, scale, octave + 1) - # Degree pattern (0-indexed scale degrees) - degrees = [0, 2, 4, 2, 3, 1, 0, 2, 4, 5, 4, 2, 0] + # Section-aware adjustments + section = section_type.lower() + if section in ("chorus", "drop"): + base_density = 0.8 + base_vel = 110 + length_factor = 1.0 + elif section in ("verse", "bridge"): + base_density = 0.5 + base_vel = 85 + length_factor = 0.8 + elif section in ("break", "gap"): + base_density = 0.15 + base_vel = 40 + length_factor = 0.5 + elif section == "intro": + base_density = 0.4 + base_vel = 70 + length_factor = 0.7 + else: + base_density = 0.6 + base_vel = 95 + length_factor = 0.9 + + # Combine function param density with section density + effective_density = density * base_density notes: list[dict] = [] - # Step through the pattern at half-beat intervals - # density controls whether we actually place a note - step = max(1, round(1.0 / density)) if density > 0 else 1 + # Dembow accent positions from real track analysis: 4, 8, 11, 12 + # These are beats 2, 3, 3&, 4 in 16th-note positions + dembow_positions = [4.0, 8.0, 11.0, 12.0] pos = 0.0 degree_idx = 0 + step = max(1, round(1.0 / effective_density)) if effective_density > 0 else 1 + + slot = 0 while pos < bars * 4.0: - slot = int(pos * 2) # 0.5-beat slots - if slot % step == 0: - # Pick note alternating between octave 5 and 6 for contour - use_oct6 = (degree_idx // 2) % 3 == 0 # every few notes go higher - midi_note = scale_notes_oct6[degrees[degree_idx] % 7] \ - if use_oct6 else scale_notes_oct5[degrees[degree_idx] % 7] + slot_half = int(pos * 2) # 0.5-beat slots - # Duration: 1.0 beat on strong beats (quarter), 0.5 elsewhere - is_strong = (slot % 4 == 0) - length = 1.0 if is_strong else 0.5 + if slot_half % step == 0: + # Check if this position is a dembow accent position + is_dembow = any(abs(pos - dp) < 0.01 for dp in dembow_positions) - vel = 100 if is_strong else 80 + # Alternate between octave 5 and 6 for contour + use_oct6 = (degree_idx // 2) % 3 == 0 + scale_notes = scale_notes_oct6 if use_oct6 else scale_notes_oct5 + midi_note = scale_notes[_HOOK_DEGREES[degree_idx % len(_HOOK_DEGREES)] % 7] + + # Duration: longer on dembow positions for emphasis + length = 1.0 * length_factor if is_dembow else 0.5 * length_factor + vel = base_vel if is_dembow else int(base_vel * 0.75) vel = _clamp_vel(int(vel * velocity_mult)) notes.append({"pos": pos, "len": length, "key": midi_note, "vel": vel}) - # Advance degree index - degree_idx = (degree_idx + 1) % len(degrees) - if is_strong: - pos += 1.0 - else: - pos += 0.5 + degree_idx = (degree_idx + 1) % len(_HOOK_DEGREES) + + # Advance by length + pos += 0.5 # fixed 8th-note step else: pos += 0.5 @@ -174,85 +372,7 @@ def lead_hook( # --------------------------------------------------------------------------- -# Chords: block chords -# --------------------------------------------------------------------------- - -def chords_block( - key: str, - bars: int, - octave: int = 4, - velocity_mult: float = 1.0, - humanize: float = 0.0, -) -> list[dict]: - """Blocked chords every 2 beats (half-bar). - - Minor progression: i - VII - VI - VII (degrees 0, 6, 5, 6 in natural minor) - Major progression: I - V - vi - IV (degrees 0, 4, 5, 3 in major) - Each chord: root + third + fifth (3 notes stacked at same position). - """ - root, scale = _parse_key(key) - scale_notes_oct4 = _get_scale_notes(root, scale, octave) - - if scale == "minor": - # i - VII - VI - VII (natural minor) - # VII = degree 6 (raised 7th = 10 semitones from root in minor) - # In natural minor: degrees 0,6,5,6 - # We need to build chords: root, 3rd, 5th - chord_degrees = [ - [0, 2, 4], # i — degrees 0, 2, 4 in minor - [6, 1, 3], # VII — degree 6 wraps to next octave; 1=2nd, 3=4th - [5, 0, 2], # VI — degree 5 wraps; 0=root of next octave - [6, 1, 3], # VII (repeat) - ] - # For proper stacking, use only the first 7 scale degrees - # Chord VII in minor: root is degree 6 (10 semitones above) - # Build using absolute semitones: i = root+0,root+3,root+7 - # VII = root+10, root+12 (=0 of next), root+15 (=3 of next) - pass # We'll rebuild below - - # Simpler approach: build chords using semitone intervals from root - if scale == "minor": - # i (0,3,7), VIIb (10,1,5), VI (8,11,2), VII (10,1,5) - chord_intervals = [ - (0, 3, 7), # i - (10, 1, 5), # VII (raised 7th in harmonic minor: 10 semitones) - (8, 0, 4), # VI - (10, 1, 5), # VII - ] - else: - # I (0,4,7), V (7,11,2), vi (9,0,4), IV (5,9,0) - chord_intervals = [ - (0, 4, 7), # I - (7, 11, 2), # V - (9, 0, 4), # vi (9 = root+9) - (5, 9, 0), # IV (5 = root+5) - ] - - notes: list[dict] = [] - for b in range(bars): - o = b * 4.0 - chord_idx = b % 4 - intervals = chord_intervals[chord_idx] - - # Chord positions at half-bar: 0.0 and 2.0 - chord_positions = [0.0, 2.0] - for cpos in chord_positions: - for interval in intervals: - midi_note = root + octave * 12 + interval - vel = 90 - vel = _clamp_vel(int(vel * velocity_mult)) - notes.append({ - "pos": o + cpos, - "len": 1.75, # almost 2 beats (leave gap) - "key": midi_note, - "vel": vel, - }) - - return _apply_humanize(notes, humanize) - - -# --------------------------------------------------------------------------- -# Pad: sustain +# Pad: sustain following chord progression # --------------------------------------------------------------------------- def pad_sustain( @@ -261,46 +381,53 @@ def pad_sustain( octave: int = 4, velocity_mult: float = 1.0, humanize: float = 0.0, + section_type: str = "verse", ) -> list[dict]: - """Long sustained pad notes, one per bar. + """Sustained pad notes following the i-VI-III-VII chord progression. + One note per bar (root of each chord) + Duration: 3.5 beats (leaves gap for next bar) + Velocity: 65-75 (soft, atmospheric) Follows chord progression from chords_block. - Notes last 3.5 beats to avoid collision with next bar's note. - Soft velocity (65-75). + + Section-aware behavior: + - chorus/drop: full velocity (75) + - verse/bridge: softer (55) + - break/gap: near-silence (25) + - intro: building (45) """ root, scale = _parse_key(key) - if scale == "minor": - chord_intervals = [ - (0, 3, 7), - (10, 1, 5), - (8, 0, 4), - (10, 1, 5), - ] - root_notes_per_bar = [0, 10, 8, 10] # root semitone offsets per bar + # Section-aware velocity + section = section_type.lower() + if section in ("chorus", "drop"): + base_vel = 75 + elif section in ("verse", "bridge"): + base_vel = 55 + elif section in ("break", "gap"): + base_vel = 25 + elif section == "intro": + base_vel = 45 else: - chord_intervals = [ - (0, 4, 7), - (7, 11, 2), - (9, 0, 4), - (5, 9, 0), - ] - root_notes_per_bar = [0, 7, 9, 5] + base_vel = 65 + + # Use classic_minor progression + prog_data = _CHORD_INTERVALLS.get("classic_minor", _CHORD_INTERVALLS["classic_minor"]) + root_offsets = prog_data["root_offsets"] # [0, 8, 4, 10] notes: list[dict] = [] for b in range(bars): o = b * 4.0 - cycle = b % 4 - root_interval = root_notes_per_bar[cycle] + chord_idx = b % 4 + root_interval = root_offsets[chord_idx] midi_note = root + octave * 12 + root_interval - vel = 70 - vel = _clamp_vel(int(vel * velocity_mult)) + vel = _clamp_vel(int(base_vel * velocity_mult)) notes.append({ "pos": o, - "len": 3.5, + "len": 3.5, # sustained for most of the bar "key": midi_note, "vel": vel, }) - return notes \ No newline at end of file + return _apply_humanize(notes, humanize) \ No newline at end of file diff --git a/src/composer/patterns.py b/src/composer/patterns.py new file mode 100644 index 0000000..a531d90 --- /dev/null +++ b/src/composer/patterns.py @@ -0,0 +1,425 @@ +"""Pattern Knowledge Base — extracted from real reggaetón track analysis. + +Data sourced from: + - Ejemplo 1: 99.4 BPM, 4:07 min, 99 bars + - Ejemplo 2: 132.5 BPM, 2:30 min, 61 bars + +These patterns are NOT textbook dembow — they are DENSE DRUM LOOPS with +multiple hits per bar. The " textbook 1, 3, 4&" is a simplification. +Real tracks have kicks on almost EVERY 16th note position at some point. +""" + +from __future__ import annotations +import random +from dataclasses import dataclass +from typing import TypedDict + + +# --------------------------------------------------------------------------- +# Weight tables — extracted from 16th-note position frequency counts +# --------------------------------------------------------------------------- + +# Ejemplo 1 kick frequency (99.4 BPM, 16 bars observed) +# Position: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 +# Count: 9 6 2 10 17 10 7 8 11 12 4 14 12 9 11 8 +# +# Key insight: position 4 (beat 2) is the densest hit — NOT beat 1. +# The dembow IS there: positions 0, 4, 8, 11, 12 are consistently high. +# But the PATTERN IS NOT sparse kick-snare — it's a full drum loop. + +# Normalized weights (0.0-1.0) from frequency counts +KICK_WEIGHTS_99: list[float] = [ + 9/17, 6/17, 2/17, 10/17, 17/17, 10/17, 7/17, 8/17, + 11/17, 12/17, 4/17, 14/17, 12/17, 9/17, 11/17, 8/17, +] +# Most frequent positions: 4, 11, 8, 12, 9 +# These are beats: 2, 3&, 3, 4, 2.5& — NOT the textbook 1, 3, 4& + +# Ejemplo 2 kick frequency (132.5 BPM, 61 bars) +# Position: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 +# Count: 7 6 3 5 8 9 9 2 3 9 6 11 5 2 6 8 +KICK_WEIGHTS_132: list[float] = [ + 7/11, 6/11, 3/11, 5/11, 8/11, 9/11, 9/11, 2/11, + 3/11, 9/11, 6/11, 11/11, 5/11, 2/11, 6/11, 8/11, +] +# Most frequent: position 11 (beat 3&), 5 (beat 1.5&), 6 (beat 2), 9 (beat 2.5&) + +# Snare weights from Ejemplo 1 +# Position: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 +# Count: 8 6 8 13 16 5 11 5 11 5 7 13 14 6 10 7 +SNARE_WEIGHTS_99: list[float] = [ + 8/16, 6/16, 8/16, 13/16, 16/16, 5/16, 11/16, 5/16, + 11/16, 5/16, 7/16, 13/16, 14/16, 6/16, 10/16, 7/16, +] +# Most frequent: position 4 (beat 2), 12 (beat 4), 3 (beat 1&), 11 (beat 3&) + +# Hihat weights from Ejemplo 1 +# Position: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 +# Count: 12 7 14 11 17 4 12 6 11 6 12 15 14 5 13 9 +HIHAT_WEIGHTS_99: list[float] = [ + 12/17, 7/17, 14/17, 11/17, 17/17, 4/17, 12/17, 6/17, + 11/17, 6/17, 12/17, 15/17, 14/17, 5/17, 13/17, 9/17, +] +# Most frequent: position 4 (beat 2), 11 (beat 3&), 2 (beat 1&), 12 (beat 4) + + +# --------------------------------------------------------------------------- +# Section energy levels — extracted from amplitude analysis +# --------------------------------------------------------------------------- + +# From Ejemplo 1 amplitude data: +# - BUILD/PRE-CHORUS: -10.6 dB to -6.6 dB → energy 0.5-0.7 +# - VERSE/BRIDGE: -13.2 dB to -14.1 dB → energy 0.3-0.4 +# - CHORUS/DROP: -2.0 dB to -4.4 dB → energy 0.9-1.0 +# - GAP/BREAK (bar 75-76): -49.4 dB to -53.5 dB → energy 0.0-0.05 +# - INTRO/FILTER: -27.8 dB → filtered, building up + +SECTION_ENERGY: dict[str, float] = { + "intro": 0.3, # filtered, building + "verse": 0.35, # low energy, stripped back + "build": 0.6, # medium energy, all elements + "pre_chorus": 0.65, # slightly higher than verse + "chorus": 0.95, # maximum energy + "drop": 1.0, # peak loudness + "break": 0.05, # near silence (gap technique) + "bridge": 0.4, # transition, moderate + "outro": 0.35, # wind down +} + + +# --------------------------------------------------------------------------- +# Section template — real structure from Ejemplo 1 +# --------------------------------------------------------------------------- +# 16 bars BUILD → 2 bars VERSE → 47 bars BUILD → 1 bar VERSE → +# 1 bar GAP (-27.8dB) → 1 bar DROP (-2.6dB) → 1 bar DROP (-2.0dB) → +# 5 bars BUILD → 1 bar BREAK (-49.4dB) → 1 bar BREAK (-53.5dB) → +# 1 bar DROP (-4.4dB) → 12 bars BUILD → 2 bars VERSE → 8 bars BUILD +# +# Key insight: The GAP/BREAK followed by LOUD DROP is a signature technique. +# 1-2 bars of near-silence creates huge contrast for the drop. + +SECTION_TEMPLATE: list[dict] = [ + {"name": "build", "bars": 16, "energy": 0.6}, + {"name": "verse", "bars": 2, "energy": 0.35}, + {"name": "build", "bars": 47, "energy": 0.65}, + {"name": "verse", "bars": 1, "energy": 0.35}, + {"name": "gap", "bars": 1, "energy": 0.05}, + {"name": "drop", "bars": 2, "energy": 1.0}, + {"name": "build", "bars": 5, "energy": 0.7}, + {"name": "break", "bars": 2, "energy": 0.05}, + {"name": "drop", "bars": 1, "energy": 0.95}, + {"name": "build", "bars": 12, "energy": 0.65}, + {"name": "verse", "bars": 2, "energy": 0.35}, + {"name": "build", "bars": 8, "energy": 0.6}, +] + + +# --------------------------------------------------------------------------- +# Pattern bank entries — per-style variants +# --------------------------------------------------------------------------- + +@dataclass +class PatternEntry: + """A single note entry in a pattern.""" + pos: float # 16th-note position (0.0-3.75 for one bar) + vel: int # base velocity + len: float = 0.25 # note length in beats + + +# Classic dembow — the canonical reggaetón pattern +# NOT the textbook 1, 3, 4& — this is a DENSE drum loop pattern +# Extracted from frequency analysis: kicks at 0, 3, 4, 5, 7, 8, 9, 11, 12 +DEMBOW_CLASSICO: list[PatternEntry] = [ + # Beat 1 area (positions 0, 1, 2, 3) + PatternEntry(pos=0.0, vel=110), + PatternEntry(pos=3.0, vel=95), # beat 3 + PatternEntry(pos=4.0, vel=100), # beat 2 — THE densest position + PatternEntry(pos=5.0, vel=85), + PatternEntry(pos=7.0, vel=90), + # Beat 3 area (positions 8, 9, 10, 11) + PatternEntry(pos=8.0, vel=100), # beat 3 + PatternEntry(pos=9.0, vel=90), + PatternEntry(pos=11.0, vel=105), # beat 3& — THE signature dembow + PatternEntry(pos=12.0, vel=95), # beat 4 +] + + +# Perreo style — more aggressive, denser kicks at positions 5, 6 (beat 1.5&, 2) +PERREO_STYLE: list[PatternEntry] = [ + PatternEntry(pos=0.0, vel=115), + PatternEntry(pos=3.0, vel=100), + PatternEntry(pos=4.0, vel=105), # beat 2 — denser + PatternEntry(pos=5.0, vel=95), # beat 2& — perreo accent + PatternEntry(pos=6.0, vel=90), # beat 2 + PatternEntry(pos=7.0, vel=85), + PatternEntry(pos=8.0, vel=105), + PatternEntry(pos=9.0, vel=90), + PatternEntry(pos=11.0, vel=110), # beat 3& — strong + PatternEntry(pos=12.0, vel=100), + PatternEntry(pos=13.0, vel=85), +] + + +# Trápico — half-time feel, fewer kicks +TRAPICO_STYLE: list[PatternEntry] = [ + PatternEntry(pos=0.0, vel=110), + PatternEntry(pos=4.0, vel=100), # beat 2 only + PatternEntry(pos=8.0, vel=105), + PatternEntry(pos=12.0, vel=95), +] + + +# Denser variant — closer to real track analysis +# Uses positions with highest frequency counts: 4, 11, 8, 12, 9 +DENSE_VARIANT: list[PatternEntry] = [ + PatternEntry(pos=0.0, vel=100), + PatternEntry(pos=3.0, vel=90), + PatternEntry(pos=4.0, vel=115), # beat 2 — densest hit + PatternEntry(pos=5.0, vel=90), + PatternEntry(pos=6.0, vel=85), + PatternEntry(pos=7.0, vel=80), + PatternEntry(pos=8.0, vel=105), # beat 3 + PatternEntry(pos=9.0, vel=90), + PatternEntry(pos=10.0, vel=80), + PatternEntry(pos=11.0, vel=110), # beat 3& + PatternEntry(pos=12.0, vel=100), # beat 4 + PatternEntry(pos=13.0, vel=85), + PatternEntry(pos=14.0, vel=90), +] + + +PATTERN_BANKS: dict[str, list[PatternEntry]] = { + "dembow_classico": DEMBOW_CLASSICO, + "perreo": PERREO_STYLE, + "trapico": TRAPICO_STYLE, + "dense": DENSE_VARIANT, +} + + +# --------------------------------------------------------------------------- +# Snare patterns — follow dembow positions +# --------------------------------------------------------------------------- + +SNARE_DEMBOW_CLASSICO: list[PatternEntry] = [ + PatternEntry(pos=3.0, vel=100), # beat 1& + PatternEntry(pos=4.0, vel=110), # beat 2 — primary + PatternEntry(pos=11.0, vel=105), # beat 3& — THE signature + PatternEntry(pos=12.0, vel=95), # beat 4 +] + + +# --------------------------------------------------------------------------- +# Hihat patterns — 16th-note grid with dembow accents +# --------------------------------------------------------------------------- + +HIHAT_DEMBOW_CLASSICO: list[PatternEntry] = [ + # All 16ths with accents on dembow positions + PatternEntry(pos=0.0, vel=85), # beat 1 + PatternEntry(pos=0.25, vel=55), + PatternEntry(pos=0.5, vel=60), + PatternEntry(pos=0.75, vel=55), + PatternEntry(pos=1.0, vel=80), # beat 2 + PatternEntry(pos=1.25, vel=55), + PatternEntry(pos=1.5, vel=60), + PatternEntry(pos=1.75, vel=55), + PatternEntry(pos=2.0, vel=85), # beat 3 + PatternEntry(pos=2.25, vel=55), + PatternEntry(pos=2.5, vel=80), # beat 3& — accent + PatternEntry(pos=2.75, vel=55), + PatternEntry(pos=3.0, vel=70), # beat 3 + PatternEntry(pos=3.25, vel=55), + PatternEntry(pos=3.5, vel=75), # beat 4& + PatternEntry(pos=3.75, vel=55), +] + + +# Offbeat hihat — for breakdowns/intervals +HIHAT_OFFBEAT: list[PatternEntry] = [ + PatternEntry(pos=0.5, vel=75), + PatternEntry(pos=1.5, vel=70), + PatternEntry(pos=2.5, vel=75), + PatternEntry(pos=3.5, vel=70), +] + + +# --------------------------------------------------------------------------- +# Chord progression — i-VI-III-VII from real track analysis +# --------------------------------------------------------------------------- + +CHORD_PROGRESSION_MINOR: list[tuple[int, list[int]]] = [ + # (root_semitone_offset, [intervals from root]) + (0, [0, 3, 7]), # i (Am): root, minor 3rd, perfect 5th + (8, [0, 4, 7]), # VI (F): root, major 3rd, perfect 5th + (4, [0, 3, 7]), # III (C): root, minor 3rd, perfect 5th + (10, [0, 4, 7]), # VII (G): root, major 3rd, perfect 5th +] + + +# --------------------------------------------------------------------------- +# Bass pattern — follows kick positions with tresillo grouping +# --------------------------------------------------------------------------- + +BASS_TRESILLO_POSITIONS: list[float] = [ + 0.0, 0.75, 1.5, # first group of 3 + 2.25, 3.0, 3.75, # second group of 3 +] + + +# --------------------------------------------------------------------------- +# Section-aware generator presets +# --------------------------------------------------------------------------- + +SECTION_GENERATOR_MAP: dict[str, dict[str, str]] = { + "intro": { + "kick": "dembow_classico", + "snare": "dembow_classico", + "hihat": "offbeat", + }, + "verse": { + "kick": "dembow_classico", + "snare": "dembow_classico", + "hihat": "dembow_classico", + }, + "build": { + "kick": "dense", + "snare": "dembow_classico", + "hihat": "dembow_classico", + }, + "pre_chorus": { + "kick": "dense", + "snare": "dembow_classico", + "hihat": "dembow_classico", + }, + "chorus": { + "kick": "dense", + "snare": "dembow_classico", + "hihat": "dembow_classico", + }, + "drop": { + "kick": "dense", + "snare": "dembow_classico", + "hihat": "dembow_classico", + }, + "break": { + "kick": "offbeat", + "snare": "offbeat", + "hihat": "offbeat", + }, + "gap": { + "kick": "offbeat", + "snare": "offbeat", + "hihat": "offbeat", + }, + "bridge": { + "kick": "dembow_classico", + "snare": "dembow_classico", + "hihat": "offbeat", + }, + "outro": { + "kick": "trapico", + "snare": "dembow_classico", + "hihat": "dembow_classico", + }, +} + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def get_pattern_bank(name: str) -> list[PatternEntry]: + """Return the pattern bank by name.""" + return PATTERN_BANKS.get(name, DEMBOW_CLASSICO) + + +def get_section_energy(section_name: str) -> float: + """Get energy level for a section type.""" + return SECTION_ENERGY.get(section_name.lower(), 0.5) + + +def get_section_generator_map(section_name: str) -> dict[str, str]: + """Get pattern bank assignments for a section type.""" + return SECTION_GENERATOR_MAP.get(section_name.lower(), SECTION_GENERATOR_MAP["verse"]) + + +# --------------------------------------------------------------------------- +# Structure Templates — genre-aware section arrangement patterns +# --------------------------------------------------------------------------- + +# Templates are lists of section names that define the high-level arrangement. +# Bar counts and energy levels are randomized within bounds when generated. +SECTION_TEMPLATES: dict[str, list[str]] = { + "extracted_real_tracks": [ + "build", "verse", "build", "verse", "gap", "drop", "build", "break", + "drop", "build", "verse", "build", + ], + "standard": [ + "intro", "verse", "chorus", "verse", "chorus", "bridge", "chorus", "outro", + ], + "with_pre_chorus": [ + "intro", "verse", "pre_chorus", "chorus", "verse", "pre_chorus", "chorus", "outro", + ], + "breakdown_drop": [ + "intro", "verse", "chorus", "break", "drop", "verse", "chorus", "outro", + ], +} + +# Default bar count and energy per section type (used as baseline for randomization) +_SECTION_DEFAULTS: dict[str, tuple[int, float]] = { + "intro": (4, 0.3), + "verse": (8, 0.35), + "build": (16, 0.6), + "pre_chorus": (4, 0.65), + "chorus": (8, 0.95), + "drop": (4, 1.0), + "break": (2, 0.05), + "gap": (2, 0.05), + "bridge": (8, 0.4), + "outro": (8, 0.35), +} + + +def generate_structure( + genre_config: dict, + bpm: float, + key: str, + seed: int | None = None, +) -> list[dict]: + """Generate section structure from template with randomization. + + Args: + genre_config: Loaded genre JSON dict + bpm: BPM for potential future use + key: Musical key for potential future use + seed: Random seed for reproducibility (default: None = unseeded). + When set, reseeds the global random state. + + Returns: + List of section dicts with 'name', 'bars', 'energy' keys. + Each call without seed produces different bar counts and energy levels. + """ + if seed is not None: + random.seed(seed) + + templates_dict = genre_config.get("structure", {}).get("templates", SECTION_TEMPLATES) + template_names = list(templates_dict.keys()) + template_key = random.choice(template_names) + template_sections = templates_dict[template_key] + + sections: list[dict] = [] + for s in template_sections: + # Handle both string names (from SECTION_TEMPLATES) and dicts (from JSON) + if isinstance(s, str): + name = s + base_bars, base_energy = _SECTION_DEFAULTS.get(name, (4, 0.5)) + else: + name = s.get("name", "unknown") + base_bars = s.get("bars", 4) + base_energy = s.get("energy", 0.5) + + # Randomize bar counts ±2, energy ±0.1 + bars = max(1, base_bars + random.randint(-2, 2)) + energy = max(0.0, min(1.0, base_energy + random.uniform(-0.1, 0.1))) + sections.append({"name": name, "bars": bars, "energy": round(energy, 2)}) + + return sections diff --git a/src/composer/rhythm.py b/src/composer/rhythm.py index 58bd369..6afb583 100644 --- a/src/composer/rhythm.py +++ b/src/composer/rhythm.py @@ -1,4 +1,15 @@ -"""Reggaeton rhythm generators — pure functions returning note dicts per channel.""" +"""Reggaeton rhythm generators — pure functions returning note dicts per channel. + +All patterns follow the DEMBOW — the foundational rhythm of reggaetón: + + Beat: 1 & 2 & 3 & 4 & + Pos: 0.0 0.5 1.0 1.5 2.0 2.5 3.0 3.5 + Kick: X . . . X . . X ← 1, 3, 4& + Snare: . . X . . X . . ← 2, 3& + +The 3&-position snare hit is the signature dembow characteristic. +Everything locks to this grid. +""" import random @@ -19,38 +30,141 @@ CH_CL = 16 # clap.wav # key — always 60 for drum samples (pitch irrelevant, sample just plays) # vel — 1–127 after applying velocity_mult +# --------------------------------------------------------------------------- +# DEMBOW PATTERN CONSTANTS +# --------------------------------------------------------------------------- + +# Dembow kick: beats 1, 3, 4& (positions 0.0, 2.0, 3.5) +# The 4&-kick is what makes it dembow, not just a straight 1-3 pattern +KICK_DEMBOW_CLASSICO = [ + (0.0, 115), # beat 1 — hard hit + (2.0, 100), # beat 3 — medium + (3.5, 90), # beat 4& — ghost/dembow accent +] + +# Dembow perreo: adds kick on 2& for aggressive perreo style +KICK_DEMBOW_PERREO = [ + (0.0, 115), # beat 1 + (1.5, 90), # beat 2& — extra perreo kick + (2.0, 95), # beat 3 + (3.5, 100), # beat 4& +] + +# Trápico half-time: kicks on 1 and 3 only, simpler 808 feel +KICK_TRAPICO = [ + (0.0, 110), # beat 1 + (2.0, 100), # beat 3 +] + +# Dembow snare: beats 2 and 3& (positions 1.0, 2.5) +# The 3&-position is the NON-NEGOTIABLE syncopation — this is what makes it reggaetón +SNARE_DEMBOW = [ + (1.0, 105), # beat 2 — primary snare hit + (2.5, 100), # beat 3& — THE signature dembow syncopation +] + +# Snare fills: adds extra notes on 2, 3, 3.5 for chorus intensity +SNARE_FILLS = [ + (1.0, 105), # beat 2 + (2.0, 80), # beat 2& — fill + (2.5, 100), # beat 3& — dembow accent + (3.0, 85), # beat 3 + (3.5, 90), # beat 4& — fill +] + +# Hihat 16th with dembow accent pattern +# Accents align with dembow kick/snare positions for drive +HIHAT_16TH_DEMBOW = [ + (0.0, 85), # beat 1 — accent + (0.25, 55), + (0.5, 60), + (0.75, 55), + (1.0, 80), # beat 2 — accent (snare position) + (1.25, 55), + (1.5, 60), + (1.75, 55), + (2.0, 85), # beat 3 — accent + (2.25, 55), + (2.5, 80), # beat 3& — accent (snare position) + (2.75, 55), + (3.0, 70), # beat 3 downbeat (medium) + (3.25, 55), + (3.5, 75), # beat 4& — accent + (3.75, 55), +] + +# Offbeat hihat for intro/breakdown: plays on all & positions +HIHAT_OFFBEAT = [ + (0.5, 75), # 1& + (1.5, 70), # 2& + (2.5, 75), # 3& + (3.5, 70), # 4& +] + +# Clap layered with snare: matches snare positions exactly for punch +CLAP_DEMBOW = [ + (1.0, 115), # beat 2 — reinforce snare + (2.5, 110), # beat 3& — reinforce dembow syncopation +] + +# Congas following dembow: slap on beat 2, open tone on 3& +CONGAS_DEMBOW = [ + (1.0, 95), # slap on beat 2 (matches snare) + (2.5, 85), # open tone on 3& (matches snare) +] + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- -def _apply_groove(notes: list[dict], groove_strength: float) -> list[dict]: +def _apply_groove(note_data, groove_strength: float): """Apply groove timing and velocity variations to notes. + note_data: either a list[dict] of notes OR a dict[int, list[dict]] (channel → notes). groove_strength: 0.0 = no effect, 1.0 = maximum groove feel. """ if groove_strength <= 0: - return notes + return note_data jitter = 5 + groove_strength * 10 nudge = groove_strength * 0.02 - for n in notes: - n["vel"] = max(1, min(127, n["vel"] + random.uniform(-jitter, jitter))) - n["pos"] = max(0, n["pos"] + random.uniform(-nudge, nudge)) - return notes + + # Normalize to {channel: [notes]} format + if isinstance(note_data, dict): + channel_notes_map = note_data + else: + channel_notes_map = {"_single": note_data} + + for channel, notes in channel_notes_map.items(): + for n in notes: + n["vel"] = max(1, min(127, int(n["vel"] + random.uniform(-jitter, jitter)))) + n["pos"] = max(0, n["pos"] + random.uniform(-nudge, nudge)) + + return note_data def _clamp_vel(vel: int) -> int: """Clamp velocity to valid MIDI range [1, 127].""" return max(1, min(127, vel)) - def _apply_vel(base_vel: int, velocity_mult: float) -> int: """Multiply base velocity by velocity_mult and clamp.""" return _clamp_vel(int(base_vel * velocity_mult)) - def _note(pos: float, length: float, vel: int) -> dict: """Create a note dict with key=60.""" return {"pos": pos, "len": length, "key": 60, "vel": vel} +def _build_bar(pattern: list[tuple[float, int]], bar_offset: float) -> list[dict]: + """Build one bar of notes from a pattern list. + + Args: + pattern: List of (beat_position, base_velocity) tuples + bar_offset: Bar offset in beats (bar_n * 4.0) + """ + notes = [] + for pos, base_vel in pattern: + notes.append(_note(bar_offset + pos, 0.25, base_vel)) + return notes + # --------------------------------------------------------------------------- # Kick generators @@ -62,16 +176,17 @@ def kick_main_notes( density: float = 1.0, groove_strength: float = 0.0, ) -> dict[int, list[dict]]: - """Dembow kick: beat 1 (hard, vel 115) + beat 2-and (the dembow hit, vel 105). + """Dembow kick — beats 1, 3, 4& (positions 0.0, 2.0, 3.5). - Positions per bar: 0.0 and 1.5 (the classic "one — &-two" reggaeton kick). + This is the CLASSIC dembow kick. The 4&-position is what separates + reggaetón from generic Latin music. Returns {CH_K: [notes...]}. """ notes: list[dict] = [] for b in range(bars): o = b * 4.0 - notes.append(_note(o, 0.25, _apply_vel(115, velocity_mult))) - notes.append(_note(o + 1.5, 0.25, _apply_vel(105, velocity_mult))) + for pos, base_vel in KICK_DEMBOW_CLASSICO: + notes.append(_note(o + pos, 0.25, _apply_vel(base_vel, velocity_mult))) return _apply_groove({CH_K: notes}, groove_strength) @@ -81,14 +196,16 @@ def kick_sparse_notes( density: float = 1.0, groove_strength: float = 0.0, ) -> dict[int, list[dict]]: - """Sparse intro/outro kick: just beat 1 per bar (vel 110). + """Trápico kick — beats 1 and 3 only (positions 0.0, 2.0). + Half-time feel for trap/reggaetón fusion. Simpler than dembow. Returns {CH_K: [notes...]}. """ notes: list[dict] = [] for b in range(bars): o = b * 4.0 - notes.append(_note(o, 0.25, _apply_vel(110, velocity_mult))) + for pos, base_vel in KICK_TRAPICO: + notes.append(_note(o + pos, 0.25, _apply_vel(base_vel, velocity_mult))) return _apply_groove({CH_K: notes}, groove_strength) @@ -98,9 +215,9 @@ def kick_outro_notes( density: float = 1.0, groove_strength: float = 0.0, ) -> dict[int, list[dict]]: - """Outro kick: dembow pattern with 0.75 baseline softness. + """Outro kick — dembow pattern with softer velocity. - Delegates to kick_main_notes with an additional 0.75 velocity scaling. + Delegates to kick_main_notes with 0.75 velocity baseline. Returns {CH_K: [notes...]}. """ return kick_main_notes(bars, velocity_mult=velocity_mult * 0.75, density=density, groove_strength=groove_strength) @@ -116,17 +233,17 @@ def snare_verse_notes( density: float = 1.0, groove_strength: float = 0.0, ) -> dict[int, list[dict]]: - """Reggaeton snare: beats 2, 3, 3-and, 4 per bar. + """Dembow snare — beats 2 and 3& (positions 1.0, 2.5). - Positions: 1.0 (vel 100), 2.0 (vel 95), 2.5 (vel 110), 3.0 (vel 90). + THE signature dembow characteristic. The 3&-position syncopation + is what makes reggaetón groove the way it does. Returns {CH_S: [notes...]}. """ - _PATTERN = [(1.0, 100), (2.0, 95), (2.5, 110), (3.0, 90)] notes: list[dict] = [] for b in range(bars): o = b * 4.0 - for p, v in _PATTERN: - notes.append(_note(o + p, 0.15, _apply_vel(v, velocity_mult))) + for pos, base_vel in SNARE_DEMBOW: + notes.append(_note(o + pos, 0.15, _apply_vel(base_vel, velocity_mult))) return _apply_groove({CH_S: notes}, groove_strength) @@ -136,24 +253,16 @@ def snare_fill_notes( density: float = 1.0, groove_strength: float = 0.0, ) -> dict[int, list[dict]]: - """Busier snare with 16th-note fills: adds positions 2.25 and 3.75. + """Chorus snare fills — dembow base plus extras on 2, 3, 3&. - Verse base (1.0, 2.0, 2.5, 3.0) plus 16th fills at 2.25 and 3.75. + Adds extra notes for build-up sections while keeping the 3& accent. Returns {CH_S: [notes...]}. """ - _PATTERN = [ - (1.0, 100), - (2.0, 95), - (2.25, 80), # 16th fill - (2.5, 110), - (3.0, 90), - (3.75, 85), # 16th fill - ] notes: list[dict] = [] for b in range(bars): o = b * 4.0 - for p, v in _PATTERN: - notes.append(_note(o + p, 0.15, _apply_vel(v, velocity_mult))) + for pos, base_vel in SNARE_FILLS: + notes.append(_note(o + pos, 0.15, _apply_vel(base_vel, velocity_mult))) return _apply_groove({CH_S: notes}, groove_strength) @@ -163,9 +272,9 @@ def snare_outro_notes( density: float = 1.0, groove_strength: float = 0.0, ) -> dict[int, list[dict]]: - """Softer outro snare (velocity_mult on top of 0.7 baseline). + """Outro snare — dembow with softer velocity. - Delegates to snare_verse_notes with an additional 0.7 velocity scaling. + Delegates to snare_verse_notes with 0.7 velocity baseline. Returns {CH_S: [notes...]}. """ return snare_verse_notes(bars, velocity_mult=velocity_mult * 0.7, density=density, groove_strength=groove_strength) @@ -181,25 +290,19 @@ def hihat_16th_notes( density: float = 1.0, groove_strength: float = 0.0, ) -> dict[int, list[dict]]: - """16th-note hihat with three-tier accent mapping. + """16th-note hihat with dembow accent pattern. - Accented on quarter notes (vel 85), medium on 8ths (vel 60), soft on - off-8ths (vel 40). density=1.0 → all 16ths; density=0.5 → every other. + Accents align with kick/snare positions (beats 1, 2, 3, 3&) to drive the groove. + density=1.0 → all 16ths; density=0.5 → every other 16th. Returns {CH_H: [notes...]}. """ notes: list[dict] = [] step = max(1, round(1.0 / density)) if density > 0 else 1 for b in range(bars): o = b * 4.0 - for i in range(0, 16, step): - beat_frac = i * 0.25 # position within bar in beats - if beat_frac % 1.0 == 0.0: # quarter note position - base_vel = 85 - elif beat_frac % 0.5 == 0.0: # 8th note position - base_vel = 60 - else: # 16th note position - base_vel = 40 - notes.append(_note(o + beat_frac, 0.1, _apply_vel(base_vel, velocity_mult))) + for idx, (pos, base_vel) in enumerate(HIHAT_16TH_DEMBOW): + if idx % step == 0: + notes.append(_note(o + pos, 0.1, _apply_vel(base_vel, velocity_mult))) return _apply_groove({CH_H: notes}, groove_strength) @@ -209,59 +312,65 @@ def hihat_8th_notes( density: float = 1.0, groove_strength: float = 0.0, ) -> dict[int, list[dict]]: - """8th-note hihat for intro/breakdown. + """Offbeat hihat for intro/breakdown. - Accented on beats (vel 70), off-beats softer (vel 50). + Plays on all & positions (0.5, 1.5, 2.5, 3.5) — creates breathing room. Returns {CH_H: [notes...]}. """ notes: list[dict] = [] for b in range(bars): o = b * 4.0 - for i in range(8): - base_vel = 70 if i % 2 == 0 else 50 - notes.append(_note(o + i * 0.5, 0.1, _apply_vel(base_vel, velocity_mult))) + for pos, base_vel in HIHAT_OFFBEAT: + notes.append(_note(o + pos, 0.1, _apply_vel(base_vel, velocity_mult))) return _apply_groove({CH_H: notes}, groove_strength) +# --------------------------------------------------------------------------- +# Clap generator +# --------------------------------------------------------------------------- + def clap_24_notes( bars: int, velocity_mult: float = 1.0, density: float = 1.0, groove_strength: float = 0.0, ) -> dict[int, list[dict]]: - """Classic reggaeton clap: beats 2 and 4 → positions 1.0 and 3.0 per bar. + """Dembow clap — beats 2 and 3& (positions 1.0, 2.5). - Hard clap (vel 120). + Layered with snare to reinforce the dembow characteristic. + Matches SNARE_DEMBOW positions exactly. Returns {CH_CL: [notes...]}. """ notes: list[dict] = [] for b in range(bars): o = b * 4.0 - notes.append(_note(o + 1.0, 0.15, _apply_vel(120, velocity_mult))) - notes.append(_note(o + 3.0, 0.15, _apply_vel(120, velocity_mult))) + for pos, base_vel in CLAP_DEMBOW: + notes.append(_note(o + pos, 0.15, _apply_vel(base_vel, velocity_mult))) return _apply_groove({CH_CL: notes}, groove_strength) +# --------------------------------------------------------------------------- +# Percussion generators +# --------------------------------------------------------------------------- + def perc_combo_notes( bars: int, velocity_mult: float = 1.0, density: float = 1.0, groove_strength: float = 0.0, ) -> dict[int, list[dict]]: - """Perc1 + Perc2 offbeat combo (tumba feel). + """Conga pattern following dembow — slap on beat 2, open on 3&. - perc2 (CH_P2): positions 0.75 (vel 85) and 2.75 (vel 80). - perc1 (CH_P1): positions 1.5 (vel 70) and 3.5 (vel 65). - Returns {CH_P1: [...], CH_P2: [...]}. + perc2 (CH_P2): conga slap and open tone + perc1 (CH_P1): reserved for additional perc (not used in base pattern) + Returns {CH_P2: [...], CH_P1: [...]}. """ p2_notes: list[dict] = [] p1_notes: list[dict] = [] for b in range(bars): o = b * 4.0 - p2_notes.append(_note(o + 0.75, 0.1, _apply_vel(85, velocity_mult))) - p2_notes.append(_note(o + 2.75, 0.1, _apply_vel(80, velocity_mult))) - p1_notes.append(_note(o + 1.5, 0.1, _apply_vel(70, velocity_mult))) - p1_notes.append(_note(o + 3.5, 0.1, _apply_vel(65, velocity_mult))) + for pos, base_vel in CONGAS_DEMBOW: + p2_notes.append(_note(o + pos, 0.1, _apply_vel(base_vel, velocity_mult))) return _apply_groove({CH_P1: p1_notes, CH_P2: p2_notes}, groove_strength) @@ -271,14 +380,13 @@ def rim_build_notes( density: float = 1.0, groove_strength: float = 0.0, ) -> dict[int, list[dict]]: - """Rim roll that builds intensity across bars (4-bar cycle). + """Rim roll that builds intensity across a 4-bar cycle. - Bar N%4=0: 16th indices 0,2,8,14 (sparse opening) - Bar N%4=1: indices 0,2,4,8,10,14 (filling in) - Bar N%4=2: indices 0,2,4,6,8,10,12,14 (every other 16th) - Bar N%4=3: all 16 indices (full roll) - - Velocity ramps: 50 → 65 → 80 → 100 across the 4-bar cycle. + Bar N%4=0: sparse (positions 0, 2, 8, 14 in 16ths) + Bar N%4=1: filling in (positions 0, 2, 4, 8, 10, 14) + Bar N%4=2: every other 16th (positions 0, 2, 4, 6, 8, 10, 12, 14) + Bar N%4=3: full roll (all 16 positions) + Velocity ramps: 50 → 65 → 80 → 100 across the cycle. Returns {CH_R: [notes...]}. """ _PATTERNS = [ @@ -319,13 +427,145 @@ GENERATORS: dict[str, callable] = { } +# --------------------------------------------------------------------------- +# Pattern-bank-aware generators (new — use extracted real-track data) +# --------------------------------------------------------------------------- + +def kick_pattern_bank_notes( + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, + groove_strength: float = 0.0, + bank: str = "dembow_classico", +) -> dict[int, list[dict]]: + """Generate kick notes from a named pattern bank. + + Args: + bars: Number of bars + velocity_mult: Velocity multiplier per section energy + density: 1.0 = all notes in pattern, 0.5 = every other, etc. + groove_strength: Groove amount (0.0 = none) + bank: Pattern bank name — "dembow_classico", "perreo", "trapico", "dense" + """ + from src.composer.patterns import get_pattern_bank, PatternEntry + + pattern = get_pattern_bank(bank) + step = max(1, round(1.0 / density)) if density > 0 else 1 + + notes: list[dict] = [] + for b in range(bars): + o = b * 4.0 + for idx, entry in enumerate(pattern): + if idx % step == 0: + notes.append(_note( + o + entry.pos, + entry.len, + _apply_vel(entry.vel, velocity_mult), + )) + return _apply_groove({CH_K: notes}, groove_strength) + + +def snare_pattern_bank_notes( + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, + groove_strength: float = 0.0, + bank: str = "dembow_classico", +) -> dict[int, list[dict]]: + """Generate snare notes from a named pattern bank. + + Uses SNARE_DEMBOW_CLASSICO positions by default (beats 2 and 3&). + """ + from src.composer.patterns import SNARE_DEMBOW_CLASSICO + + pattern = SNARE_DEMBOW_CLASSICO + step = max(1, round(1.0 / density)) if density > 0 else 1 + + notes: list[dict] = [] + for b in range(bars): + o = b * 4.0 + for idx, entry in enumerate(pattern): + if idx % step == 0: + notes.append(_note( + o + entry.pos, + entry.len, + _apply_vel(entry.vel, velocity_mult), + )) + return _apply_groove({CH_S: notes}, groove_strength) + + +def hihat_pattern_bank_notes( + bars: int, + velocity_mult: float = 1.0, + density: float = 1.0, + groove_strength: float = 0.0, + bank: str = "dembow_classico", +) -> dict[int, list[dict]]: + """Generate hihat notes from a named pattern bank. + + bank="dembow_classico" → 16th-note grid with dembow accents + bank="offbeat" → offbeat positions only (for breakdowns) + """ + from src.composer.patterns import HIHAT_DEMBOW_CLASSICO, HIHAT_OFFBEAT + + pattern = HIHAT_OFFBEAT if bank == "offbeat" else HIHAT_DEMBOW_CLASSICO + step = max(1, round(1.0 / density)) if density > 0 else 1 + + notes: list[dict] = [] + for b in range(bars): + o = b * 4.0 + for idx, entry in enumerate(pattern): + if idx % step == 0: + notes.append(_note( + o + entry.pos, + entry.len, + _apply_vel(entry.vel, velocity_mult), + )) + return _apply_groove({CH_H: notes}, groove_strength) + + +# Extended registry including pattern-bank generators +PATTERN_GENERATORS: dict[str, callable] = { + "kick_pattern_bank_notes": kick_pattern_bank_notes, + "snare_pattern_bank_notes": snare_pattern_bank_notes, + "hihat_pattern_bank_notes": hihat_pattern_bank_notes, + # Fallback to legacy generators + "kick_main_notes": kick_main_notes, + "kick_sparse_notes": kick_sparse_notes, + "snare_verse_notes": snare_verse_notes, + "snare_fill_notes": snare_fill_notes, + "hihat_16th_notes": hihat_16th_notes, + "hihat_8th_notes": hihat_8th_notes, + "clap_24_notes": clap_24_notes, + "perc_combo_notes": perc_combo_notes, + "rim_build_notes": rim_build_notes, +} + + def get_notes( generator_name: str, bars: int, velocity_mult: float = 1.0, density: float = 1.0, groove_strength: float = 0.0, + bank: str = "dembow_classico", ) -> dict[int, list[dict]]: - """Dispatch to the named generator. Raises KeyError if not found.""" - gen = GENERATORS[generator_name] - return gen(bars, velocity_mult, density, groove_strength) + """Dispatch to the named generator. + + Pattern-bank-aware generators accept `bank` parameter. + Legacy generators ignore the bank parameter. + """ + # Try pattern generators first, fall back to legacy + if generator_name in PATTERN_GENERATORS: + gen = PATTERN_GENERATORS[generator_name] + elif generator_name in GENERATORS: + gen = GENERATORS[generator_name] + else: + raise KeyError(f"Unknown generator: {generator_name}") + + # Pattern-bank generators accept bank parameter + import inspect + sig = inspect.signature(gen) + if "bank" in sig.parameters: + return gen(bars, velocity_mult, density, groove_strength, bank=bank) + return gen(bars, velocity_mult, density, groove_strength) \ No newline at end of file diff --git a/src/reaper_builder/__init__.py b/src/reaper_builder/__init__.py index 35b3ae1..c7dfe64 100644 --- a/src/reaper_builder/__init__.py +++ b/src/reaper_builder/__init__.py @@ -51,13 +51,13 @@ _PROJECT_HEADER: list[list[str] | Element] = [ ["SMPTESYNC", "0", "30", "100", "40", "1000", "300", "0", "0", "1", "0", "0"], ["LOOP", "0"], ["LOOPGRAN", "0", "4"], - ["RECORD_PATH", '"Media"', '""'], + ["RECORD_PATH", "Media", ""], Element("RECORD_CFG", [], children=["ZXZhdxgAAQ=="]), [], Element("APPLYFX_CFG", [], children=[]), [], - ["RENDER_FILE", '""'], - ["RENDER_PATTERN", '""'], + ["RENDER_FILE", ""], + ["RENDER_PATTERN", ""], ["RENDER_FMT", "0", "2", "0"], ["RENDER_1X", "0"], ["RENDER_RANGE", "1", "0", "0", "0", "1000"], @@ -181,8 +181,10 @@ _FXCHAIN_FOOTER: list[list[str]] = [ # --------------------------------------------------------------------------- def _make_guid() -> str: - """Generate a random REAPER GUID string.""" - return str(uuid.uuid4()).upper() + """Generate a random REAPER GUID string using random module (seedable).""" + # Use random module directly so seeding works + import random + return str(uuid.UUID(bytes=bytes(random.getrandbits(8) for _ in range(16)), version=4)).upper() def vst3_element(display_name: str, filename: str, uid_guid: str = "", preset_data: list[str] | None = None) -> Element: @@ -233,8 +235,17 @@ class RPPBuilder: builder.write("output.rpp") """ - def __init__(self, song: SongDefinition) -> None: + def __init__(self, song: SongDefinition, seed: int | None = None) -> None: self.song = song + self._seed = seed + if seed is not None: + import random + random.seed(seed) + + def _make_seeded_guid(self) -> str: + """Generate a random REAPER GUID string. Uses seed if provided to RPPBuilder.""" + import random + return str(uuid.UUID(bytes=bytes(random.getrandbits(8) for _ in range(16)), version=4)).upper() def write(self, path: str | Path) -> None: """Serialize the project to a .rpp file at *path*. @@ -255,7 +266,9 @@ class RPPBuilder: m = self.song.meta # Project root — version from test_vst3.rpp line 1 - root = Element("REAPER_PROJECT", ["0.1", "7.65/win64", str(int(uuid.uuid4().time)), "0"]) + # Use random to make it seedable for reproducible output + import random + root = Element("REAPER_PROJECT", ["0.1", "7.65/win64", str(random.getrandbits(64)), "0"]) # Add all static project header lines for line in _PROJECT_HEADER: @@ -263,10 +276,11 @@ class RPPBuilder: root.append(line) # TEMPO is injected dynamically (overrides static header) - root.append(["TEMPO", str(m.bpm), str(m.time_sig_num), str(m.time_sig_den)]) + # REAPER format: TEMPO bpm time_sig_num time_sig_den flag (flag=0 for standard) + root.append(["TEMPO", str(m.bpm), str(m.time_sig_num), str(m.time_sig_den), "0"]) # Master track - master_guid = _make_guid() + master_guid = self._make_seeded_guid() master = Element("TRACK", [master_guid]) master.append(['NAME', "master"]) master.append(["VOLPAN", "1.0", "0", "-1", "-1", "1"]) @@ -285,7 +299,7 @@ class RPPBuilder: if line: footer_copy = [v for v in line] if footer_copy[0] == "FXID": - footer_copy[1] = f"{{{_make_guid()}}}" + footer_copy[1] = f"{{{self._make_seeded_guid()}}}" master_fxchain.append(footer_copy) master.append(master_fxchain) root.append(master) @@ -298,7 +312,7 @@ class RPPBuilder: def _build_track(self, track: TrackDef) -> Element: """Build a TRACK Element with all default attributes from test_vst3.rpp.""" - track_guid = _make_guid() + track_guid = self._make_seeded_guid() track_elem = Element("TRACK", [f"{{{track_guid}}}"]) track_elem.append(["NAME", track.name]) @@ -332,7 +346,7 @@ class RPPBuilder: fxchain.append([v for v in line]) for plugin in track.plugins: fxchain.append(self._build_plugin(plugin)) - fxid_guid = _make_guid() + fxid_guid = self._make_seeded_guid() fxchain.append(["PRESETNAME", "Program 1"]) fxchain.append(["FLOATPOS", "0", "0", "0", "0"]) fxchain.append(["FXID", f"{{{fxid_guid}}}"]) diff --git a/src/selector/__init__.py b/src/selector/__init__.py index 55deb23..7f0bb8b 100644 --- a/src/selector/__init__.py +++ b/src/selector/__init__.py @@ -16,6 +16,7 @@ from __future__ import annotations import json import os +import random from pathlib import Path from typing import Optional from dataclasses import dataclass, field @@ -306,10 +307,27 @@ class SampleSelector: matches.sort(key=lambda m: m.score, reverse=True) return matches[:limit] - def select_one(self, role: str, **kwargs) -> Optional[dict]: - """Select the single best matching sample.""" - results = self.select(role=role, limit=1, **kwargs) - return results[0].sample if results else None + def select_one( + self, + role: str, + seed: Optional[int] = None, + **kwargs, + ) -> Optional[dict]: + """Select one sample using weighted random from top-5 candidates. + + The top-5 candidates are selected with weights [5, 4, 3, 2, 1], + favoring higher-scored results while allowing variation across calls. + Pass seed for reproducible output. + """ + if seed is not None: + random.seed(seed) + results = self.select(role=role, limit=5, **kwargs) + if not results: + return None + candidates = results[:5] + weights = [5, 4, 3, 2, 1][: len(candidates)] + selected = random.choices(candidates, weights=weights, k=1)[0] + return selected.sample def get_roles(self) -> list[str]: """Get all available roles and their counts.""" diff --git a/tests/test_compose_integration.py b/tests/test_compose_integration.py index 72b2d8a..a02c613 100644 --- a/tests/test_compose_integration.py +++ b/tests/test_compose_integration.py @@ -200,14 +200,17 @@ class TestSectionBuilderIntegration: index_path = _ROOT / "data" / "sample_index.json" selector = SampleSelector(str(index_path)) - tracks, sections = build_section_tracks(genre_config, selector, "Am", 95.0) + # Pass explicit sections_data since JSON now uses templates format + sections_data = genre_config.get("structure", {}).get("templates", {}).get("extracted_real_tracks", []) + tracks, sections = build_section_tracks(genre_config, selector, "Am", 95.0, sections_data=sections_data) assert len(tracks) > 0, "Expected at least one track" assert len(sections) > 0, "Expected at least one section" - # Sections should have names + # Sections should have names from the genre config + valid_names = {"intro", "verse", "build", "pre_chorus", "chorus", "drop", + "break", "gap", "bridge", "outro", "verse2", "chorus2", "chorus3"} for sec in sections: - assert sec.name in ["intro", "verse", "chorus", "outro", - "verse2", "chorus2", "bridge", "chorus3"] + assert sec.name in valid_names, f"Unexpected section name: {sec.name}" def test_song_definition_has_sections_field(self): """SongDefinition has a sections field."""