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

@@ -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."
}
}

227
scripts/analyze_examples.py Normal file
View File

@@ -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")

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

View File

@@ -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 <SOURCE WAVE> 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(
"<REAPER_PROJECT 0.1 7.65/win64",
'<REAPER_PROJECT 0.1 "7.65/win64"'
)
Path(OUTPUT).parent.mkdir(parents=True, exist_ok=True)
Path(OUTPUT).write_text(output_str, encoding="utf-8")
print(f"Written: {OUTPUT}")
if __name__ == "__main__":
build()

View File

@@ -1,6 +1,11 @@
"""Melodic pattern generators for reggaeton production.
"""Melodic pattern generators for reggaetón production.
All patterns follow reggaetón theory:
- Bass follows TRESILLO 3-3-2 grouping
- Chords follow i-VI-III-VII progression (Am-F-C-G)
- Lead syncopation matches dembow (beats 2 and 3&)
- Pad follows chord progression with sustained notes
All generators return list[dict] with format {pos, len, key, vel}.
Designed to feed MelodicTrack notes in SongDefinition.
"""
@@ -11,10 +16,10 @@ import random
# ---------------------------------------------------------------------------
SCALES = {
"minor": [0, 2, 3, 5, 7, 8, 10], # natural minor
"major": [0, 2, 4, 5, 7, 9, 11],
"phrygian": [0, 1, 3, 5, 7, 8, 10],
"dorian": [0, 2, 3, 5, 7, 9, 10],
"minor": [0, 2, 3, 5, 7, 8, 10], # natural minor
"major": [0, 2, 4, 5, 7, 9, 11],
"phrygian": [0, 1, 3, 5, 7, 8, 10],
"dorian": [0, 2, 3, 5, 7, 9, 10],
}
ROOT_SEMITONE = {
@@ -24,6 +29,34 @@ ROOT_SEMITONE = {
}
# ---------------------------------------------------------------------------
# Chord progressions — i-VI-III-VII is THE reggaetón progression
# ---------------------------------------------------------------------------
# Semitone intervals from root for each chord in the progression
# classic_minor: Am - F - C - G (i - VI - III - VII in natural minor)
_CHORD_INTERVALLS = {
# i (root position): 0, 3, 7 (root, minor 3rd, perfect 5th)
# VI: F = F(5) + 8 semitones from A (0). F(5) + 8 = A(9) + 8 = 17 → octave = 5. Intervals: (8, 0, 4)
# III: C = C(0) + 4 semitones from A. Intervals: (4, 7, 11)
# VII: G = G(7) + 10 semitones from A (raised 7th in harmonic minor). Intervals: (10, 1, 5)
"classic_minor": {
"intervals": [(0, 3, 7), (8, 0, 4), (4, 7, 11), (10, 1, 5)],
"root_offsets": [0, 8, 4, 10], # root semitone offsets for each chord
},
# tension: Am - F - C - G but with different voicing feel
"tension": {
"intervals": [(0, 3, 7), (8, 0, 4), (4, 7, 11), (10, 1, 5)],
"root_offsets": [0, 8, 4, 10],
},
# romantic: Am - G - F - E (i - VII - VI - V in natural minor)
"romantic": {
"intervals": [(0, 3, 7), (10, 1, 5), (8, 0, 4), (4, 9, 0)],
"root_offsets": [0, 10, 8, 4],
},
}
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
@@ -36,7 +69,6 @@ def _parse_key(key_str: str) -> 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
return _apply_humanize(notes, humanize)

425
src/composer/patterns.py Normal file
View File

@@ -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

View File

@@ -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 — 1127 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)

View File

@@ -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}}}"])

View File

@@ -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."""

View File

@@ -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."""