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", "genre": "reggaeton",
"era": "2009", "era": "2009",
"display_name": "Reggaeton 2009 (Era de Oro)", "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": { "bpm": {
"min": 88, "min": 88,
"max": 102, "max": 102,
"default": 96 "default": 99
}, },
"keys": ["Am", "Dm", "Gm", "Cm", "Em", "Fm", "Bbm"], "keys": ["Am", "Dm", "Gm", "Cm", "Em", "Fm", "Bbm"],
"time_signature": [4, 4], "time_signature": [4, 4],
"ppq": 96, "ppq": 96,
"structure": { "structure": {
"template": "intro-verse-chorus-verse-chorus-outro", "template": "extracted_real_tracks",
"sections": [ "templates": {
{"name": "intro", "bars": 4, "energy": 0.3}, "extracted_real_tracks": [
{"name": "verse", "bars": 8, "energy": 0.6}, {"name": "build", "bars": 16, "energy": 0.6},
{"name": "chorus", "bars": 8, "energy": 0.9}, {"name": "verse", "bars": 2, "energy": 0.35},
{"name": "verse2", "bars": 8, "energy": 0.6}, {"name": "build", "bars": 47, "energy": 0.65},
{"name": "chorus2", "bars": 8, "energy": 1.0}, {"name": "verse", "bars": 1, "energy": 0.35},
{"name": "bridge", "bars": 4, "energy": 0.5}, {"name": "gap", "bars": 1, "energy": 0.05},
{"name": "chorus3", "bars": 8, "energy": 1.0}, {"name": "drop", "bars": 2, "energy": 1.0},
{"name": "outro", "bars": 4, "energy": 0.4} {"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": { "roles": {
"drums": { "drums": {
"description": "Patron dembow - kick en 1 y 2.5, snare en 2 y 4, hi-hats en corcheas", "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": "dembow", "pattern_type": "drum_loop",
"preferred_plugins": ["FPC", "Fruity DrumSynth Live", "DirectWave", "Kontakt 7"], "preferred_plugins": ["FPC", "Fruity DrumSynth Live", "DirectWave", "Kontakt 7"],
"midi_channel": 0, "midi_channel": 0,
"mixer_slot": 0, "mixer_slot": 0,
"notes_template": "dembow" "notes_template": "drum_loop_dembow",
"bank": "dembow_classico"
}, },
"bass": { "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", "pattern_type": "808_follow_kick",
"preferred_plugins": ["Serum 2", "Transistor Bass", "Sytrus", "3x Osc", "ravity(S)"], "preferred_plugins": ["Serum 2", "Transistor Bass", "Sytrus", "3x Osc", "ravity(S)"],
"midi_channel": 1, "midi_channel": 1,
"mixer_slot": 1, "mixer_slot": 1,
"octave": 2, "octave": 2,
"notes_template": "bass_808" "notes_template": "bass_tresillo_section_aware"
}, },
"harmony": { "harmony": {
"description": "Piano stabs en offbeats. Closed triads.", "description": "Piano stabs in offbeats. Closed triads. Section-aware velocity.",
"pattern_type": "piano_stabs", "pattern_type": "piano_stabs",
"preferred_plugins": ["FL Keys", "Nexus2", "Kontakt 7", "Sakura", "Pigments"], "preferred_plugins": ["FL Keys", "Nexus2", "Kontakt 7", "Sakura", "Pigments"],
"midi_channel": 2, "midi_channel": 2,
"mixer_slot": 2, "mixer_slot": 2,
"notes_template": "piano_stabs" "notes_template": "piano_stabs_section_aware"
}, },
"lead": { "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", "pattern_type": "brass_hook",
"preferred_plugins": ["Serum 2", "Omnisphere", "Harmor", "Electra", "ravity(S)"], "preferred_plugins": ["Serum 2", "Omnisphere", "Harmor", "Electra", "ravity(S)"],
"midi_channel": 3, "midi_channel": 3,
"mixer_slot": 3, "mixer_slot": 3,
"notes_template": "lead_hook" "notes_template": "lead_hook_section_aware"
}, },
"pad": { "pad": {
"description": "Pad atmosferico sutil para llenar el fondo.", "description": "Pad atmosferico sutil para llenar el fondo. Sustained chord tones.",
"pattern_type": "sustained_pad", "pattern_type": "sustained_pad",
"preferred_plugins": ["Harmor", "Serum 2", "Omnisphere", "FLEX", "Pigments"], "preferred_plugins": ["Harmor", "Serum 2", "Omnisphere", "FLEX", "Pigments"],
"midi_channel": 4, "midi_channel": 4,
@@ -83,7 +109,7 @@
"popularity": 0.9 "popularity": 0.9
}, },
{ {
"name": "tensión", "name": "tension",
"chords": ["Am", "F", "C", "G"], "chords": ["Am", "F", "C", "G"],
"beats_per_chord": 4, "beats_per_chord": 4,
"popularity": 0.7 "popularity": 0.7
@@ -152,5 +178,11 @@
"Rumor de Guerra - Hector El Father", "Rumor de Guerra - Hector El Father",
"Pose - Daddy Yankee", "Pose - Daddy Yankee",
"Llamé Pa Verte - Wisin y Yandel" "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 argparse
import json import json
import random
import sys import sys
from pathlib import Path 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.rhythm import get_notes, GENERATORS as RHYTHM_GENERATORS
from src.composer.melodic import bass_tresillo, lead_hook, chords_block, pad_sustain 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.converters import rhythm_to_midi, melodic_to_midi
from src.composer.patterns import generate_structure
from src.selector import SampleSelector from src.selector import SampleSelector
from src.reaper_builder import RPPBuilder from src.reaper_builder import RPPBuilder
from src.reaper_builder.render import render_project from src.reaper_builder.render import render_project
@@ -67,9 +69,9 @@ ROLE_MELODIC_GENERATORS = {
} }
ROLE_RHYTHM_GENERATORS = { ROLE_RHYTHM_GENERATORS = {
"drums": "kick_main_notes", "drums": "kick_pattern_bank_notes",
"snare": "snare_verse_notes", "snare": "snare_pattern_bank_notes",
"hihat": "hihat_16th_notes", "hihat": "hihat_pattern_bank_notes",
"perc": "perc_combo_notes", "perc": "perc_combo_notes",
} }
@@ -197,6 +199,10 @@ def build_section_tracks(
selector: SampleSelector, selector: SampleSelector,
key: str, key: str,
bpm: float, 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]]: ) -> tuple[list[TrackDef], list[SectionDef]]:
"""Build all tracks from genre config sections. """Build all tracks from genre config sections.
@@ -208,17 +214,33 @@ def build_section_tracks(
selector: SampleSelector for sample queries selector: SampleSelector for sample queries
key: Musical key (e.g. "Am") key: Musical key (e.g. "Am")
bpm: BPM for sample selection 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: Returns:
(tracks, sections) (tracks, sections)
""" """
structure = genre_config.get("structure", {})
sections_raw = structure.get("sections", [])
roles = genre_config.get("roles", {}) 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 # Parse sections into SectionDef list
sections: list[SectionDef] = [] sections: list[SectionDef] = []
for s in sections_raw: for s in sections_data:
sections.append(SectionDef( sections.append(SectionDef(
name=s.get("name", "unknown"), name=s.get("name", "unknown"),
bars=s.get("bars", 4), bars=s.get("bars", 4),
@@ -265,7 +287,17 @@ def build_section_tracks(
if role in ROLE_RHYTHM_GENERATORS: if role in ROLE_RHYTHM_GENERATORS:
gen_name = ROLE_RHYTHM_GENERATORS[role] 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) # Audio roles: one clip per hit (one-shot samples placed at beat positions)
if role in AUDIO_ROLES: if role in AUDIO_ROLES:
@@ -291,7 +323,13 @@ def build_section_tracks(
section_clips.append(clip) section_clips.append(clip)
elif role in ROLE_MELODIC_GENERATORS: elif role in ROLE_MELODIC_GENERATORS:
gen_fn = ROLE_MELODIC_GENERATORS[role] 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) midi_notes = melodic_to_midi(note_list)
# Melodic roles use MIDI instruments — no audio_path needed # Melodic roles use MIDI instruments — no audio_path needed
clip = ClipDef( clip = ClipDef(
@@ -377,6 +415,12 @@ def main() -> None:
default=None, default=None,
help="Output WAV path for rendering.", 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() args = parser.parse_args()
# Validate BPM # Validate BPM
@@ -404,8 +448,15 @@ def main() -> None:
selector = SampleSelector(str(index_path)) selector = SampleSelector(str(index_path))
# Build tracks and sections from genre config # Generate section structure from template with randomization
tracks, sections = build_section_tracks(genre_config, selector, args.key, args.bpm) # 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 # Create return tracks
return_tracks = create_return_tracks() return_tracks = create_return_tracks()
@@ -434,7 +485,7 @@ def main() -> None:
print(f" - {e}", file=sys.stderr) print(f" - {e}", file=sys.stderr)
# Write .rpp # Write .rpp
builder = RPPBuilder(song) builder = RPPBuilder(song, seed=args.seed)
builder.write(str(output_path)) builder.write(str(output_path))
# Render if requested # 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. Designed to feed MelodicTrack notes in SongDefinition.
""" """
@@ -11,10 +16,10 @@ import random
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SCALES = { SCALES = {
"minor": [0, 2, 3, 5, 7, 8, 10], # natural minor "minor": [0, 2, 3, 5, 7, 8, 10], # natural minor
"major": [0, 2, 4, 5, 7, 9, 11], "major": [0, 2, 4, 5, 7, 9, 11],
"phrygian": [0, 1, 3, 5, 7, 8, 10], "phrygian": [0, 1, 3, 5, 7, 8, 10],
"dorian": [0, 2, 3, 5, 7, 9, 10], "dorian": [0, 2, 3, 5, 7, 9, 10],
} }
ROOT_SEMITONE = { 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 # Internal helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -36,7 +69,6 @@ def _parse_key(key_str: str) -> tuple[int, str]:
else: else:
root_str = key_str root_str = key_str
scale_name = "major" scale_name = "major"
root = ROOT_SEMITONE.get(root_str) root = ROOT_SEMITONE.get(root_str)
if root is None: if root is None:
raise ValueError(f"Unknown root: {root_str}") raise ValueError(f"Unknown root: {root_str}")
@@ -54,7 +86,7 @@ def _clamp_vel(v: int) -> int:
return max(1, min(127, v)) 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.""" """Apply humanization (velocity jitter + position nudge) to note list."""
if humanize <= 0: if humanize <= 0:
return notes 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( def bass_tresillo(
@@ -76,42 +108,173 @@ def bass_tresillo(
octave: int = 3, octave: int = 3,
velocity_mult: float = 1.0, velocity_mult: float = 1.0,
humanize: float = 0.0, humanize: float = 0.0,
section_type: str = "verse",
) -> list[dict]: ) -> 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 The 3-3-2 grouping is the BASS DNA of reggaetón:
Root note on downbeats (0.0, 1.5, 3.0), fifth (7 semitones) on upbeats. - 3 notes in first group (positions 0.0, 0.75, 1.5)
Velocity: 110 for downbeats, 85 for upbeats. - 3 notes in second group (positions 2.25, 3.0, 3.75)
Default octave=3 gives root in MIDI range 45-52 (A3-E4), within 36-55. - 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) root, scale = _parse_key(key)
scale_notes = _get_scale_notes(root, scale, octave) root_note = root + (octave - 1) * 12 # octave 3 → MIDI 45 for A
root_note = scale_notes[0] # degree 0 fifth_note = root_note + 7 # perfect fifth above root
fifth_note = root_note + 7 # up a perfect fifth
# 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] = [] notes: list[dict] = []
for b in range(bars): for b in range(bars):
o = b * 4.0 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] positions = [0.0, 0.75, 1.5, 2.25, 3.0, 3.75]
for idx, pos in enumerate(positions): 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 key_note = root_note
vel = 110 vel = vel_root
else: # upbeats: fifth else: # upbeats: fifth (positions 1, 3, 5)
key_note = fifth_note 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)) 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) 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( def lead_hook(
key: str, key: str,
bars: int, bars: int,
@@ -119,54 +282,89 @@ def lead_hook(
density: float = 0.6, density: float = 0.6,
velocity_mult: float = 1.0, velocity_mult: float = 1.0,
humanize: float = 0.0, humanize: float = 0.0,
section_type: str = "verse",
) -> list[dict]: ) -> 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] The hook emphasizes the DEMBOW syncopation — notes on beats 2, 3&, 4
Note durations: 0.5 or 1.0 beats. (positions 4.0, 11.0, 12.0 from the real-track analysis).
density=1.0 → every slot filled; density=0.5 → half filled.
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) root, scale = _parse_key(key)
intervals = SCALES.get(scale, SCALES["major"]) intervals = SCALES.get(scale, SCALES["major"])
# Map scale degrees to MIDI notes (extend to cover octave 5 and 6 for melody) # Build scale notes for octave 5 and 6
scale_notes_oct5 = _get_scale_notes(root, scale, octave) # 7 notes scale_notes_oct5 = _get_scale_notes(root, scale, octave) # 7 notes
scale_notes_oct6 = _get_scale_notes(root, scale, octave + 1) scale_notes_oct6 = _get_scale_notes(root, scale, octave + 1)
# Degree pattern (0-indexed scale degrees) # Section-aware adjustments
degrees = [0, 2, 4, 2, 3, 1, 0, 2, 4, 5, 4, 2, 0] 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] = [] notes: list[dict] = []
# Step through the pattern at half-beat intervals # Dembow accent positions from real track analysis: 4, 8, 11, 12
# density controls whether we actually place a note # These are beats 2, 3, 3&, 4 in 16th-note positions
step = max(1, round(1.0 / density)) if density > 0 else 1 dembow_positions = [4.0, 8.0, 11.0, 12.0]
pos = 0.0 pos = 0.0
degree_idx = 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: while pos < bars * 4.0:
slot = int(pos * 2) # 0.5-beat slots slot_half = 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]
# Duration: 1.0 beat on strong beats (quarter), 0.5 elsewhere if slot_half % step == 0:
is_strong = (slot % 4 == 0) # Check if this position is a dembow accent position
length = 1.0 if is_strong else 0.5 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)) vel = _clamp_vel(int(vel * velocity_mult))
notes.append({"pos": pos, "len": length, "key": midi_note, "vel": vel}) notes.append({"pos": pos, "len": length, "key": midi_note, "vel": vel})
# Advance degree index degree_idx = (degree_idx + 1) % len(_HOOK_DEGREES)
degree_idx = (degree_idx + 1) % len(degrees)
if is_strong: # Advance by length
pos += 1.0 pos += 0.5 # fixed 8th-note step
else:
pos += 0.5
else: else:
pos += 0.5 pos += 0.5
@@ -174,85 +372,7 @@ def lead_hook(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Chords: block chords # Pad: sustain following chord progression
# ---------------------------------------------------------------------------
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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def pad_sustain( def pad_sustain(
@@ -261,46 +381,53 @@ def pad_sustain(
octave: int = 4, octave: int = 4,
velocity_mult: float = 1.0, velocity_mult: float = 1.0,
humanize: float = 0.0, humanize: float = 0.0,
section_type: str = "verse",
) -> list[dict]: ) -> 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. 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) root, scale = _parse_key(key)
if scale == "minor": # Section-aware velocity
chord_intervals = [ section = section_type.lower()
(0, 3, 7), if section in ("chorus", "drop"):
(10, 1, 5), base_vel = 75
(8, 0, 4), elif section in ("verse", "bridge"):
(10, 1, 5), base_vel = 55
] elif section in ("break", "gap"):
root_notes_per_bar = [0, 10, 8, 10] # root semitone offsets per bar base_vel = 25
elif section == "intro":
base_vel = 45
else: else:
chord_intervals = [ base_vel = 65
(0, 4, 7),
(7, 11, 2), # Use classic_minor progression
(9, 0, 4), prog_data = _CHORD_INTERVALLS.get("classic_minor", _CHORD_INTERVALLS["classic_minor"])
(5, 9, 0), root_offsets = prog_data["root_offsets"] # [0, 8, 4, 10]
]
root_notes_per_bar = [0, 7, 9, 5]
notes: list[dict] = [] notes: list[dict] = []
for b in range(bars): for b in range(bars):
o = b * 4.0 o = b * 4.0
cycle = b % 4 chord_idx = b % 4
root_interval = root_notes_per_bar[cycle] root_interval = root_offsets[chord_idx]
midi_note = root + octave * 12 + root_interval midi_note = root + octave * 12 + root_interval
vel = 70 vel = _clamp_vel(int(base_vel * velocity_mult))
vel = _clamp_vel(int(vel * velocity_mult))
notes.append({ notes.append({
"pos": o, "pos": o,
"len": 3.5, "len": 3.5, # sustained for most of the bar
"key": midi_note, "key": midi_note,
"vel": vel, "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 import random
@@ -19,38 +30,141 @@ CH_CL = 16 # clap.wav
# key — always 60 for drum samples (pitch irrelevant, sample just plays) # key — always 60 for drum samples (pitch irrelevant, sample just plays)
# vel — 1127 after applying velocity_mult # 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 # 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. """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. groove_strength: 0.0 = no effect, 1.0 = maximum groove feel.
""" """
if groove_strength <= 0: if groove_strength <= 0:
return notes return note_data
jitter = 5 + groove_strength * 10 jitter = 5 + groove_strength * 10
nudge = groove_strength * 0.02 nudge = groove_strength * 0.02
for n in notes:
n["vel"] = max(1, min(127, n["vel"] + random.uniform(-jitter, jitter))) # Normalize to {channel: [notes]} format
n["pos"] = max(0, n["pos"] + random.uniform(-nudge, nudge)) if isinstance(note_data, dict):
return notes 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: def _clamp_vel(vel: int) -> int:
"""Clamp velocity to valid MIDI range [1, 127].""" """Clamp velocity to valid MIDI range [1, 127]."""
return max(1, min(127, vel)) return max(1, min(127, vel))
def _apply_vel(base_vel: int, velocity_mult: float) -> int: def _apply_vel(base_vel: int, velocity_mult: float) -> int:
"""Multiply base velocity by velocity_mult and clamp.""" """Multiply base velocity by velocity_mult and clamp."""
return _clamp_vel(int(base_vel * velocity_mult)) return _clamp_vel(int(base_vel * velocity_mult))
def _note(pos: float, length: float, vel: int) -> dict: def _note(pos: float, length: float, vel: int) -> dict:
"""Create a note dict with key=60.""" """Create a note dict with key=60."""
return {"pos": pos, "len": length, "key": 60, "vel": vel} 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 # Kick generators
@@ -62,16 +176,17 @@ def kick_main_notes(
density: float = 1.0, density: float = 1.0,
groove_strength: float = 0.0, groove_strength: float = 0.0,
) -> dict[int, list[dict]]: ) -> 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...]}. Returns {CH_K: [notes...]}.
""" """
notes: list[dict] = [] notes: list[dict] = []
for b in range(bars): for b in range(bars):
o = b * 4.0 o = b * 4.0
notes.append(_note(o, 0.25, _apply_vel(115, velocity_mult))) for pos, base_vel in KICK_DEMBOW_CLASSICO:
notes.append(_note(o + 1.5, 0.25, _apply_vel(105, velocity_mult))) notes.append(_note(o + pos, 0.25, _apply_vel(base_vel, velocity_mult)))
return _apply_groove({CH_K: notes}, groove_strength) return _apply_groove({CH_K: notes}, groove_strength)
@@ -81,14 +196,16 @@ def kick_sparse_notes(
density: float = 1.0, density: float = 1.0,
groove_strength: float = 0.0, groove_strength: float = 0.0,
) -> dict[int, list[dict]]: ) -> 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...]}. Returns {CH_K: [notes...]}.
""" """
notes: list[dict] = [] notes: list[dict] = []
for b in range(bars): for b in range(bars):
o = b * 4.0 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) return _apply_groove({CH_K: notes}, groove_strength)
@@ -98,9 +215,9 @@ def kick_outro_notes(
density: float = 1.0, density: float = 1.0,
groove_strength: float = 0.0, groove_strength: float = 0.0,
) -> dict[int, list[dict]]: ) -> 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...]}. Returns {CH_K: [notes...]}.
""" """
return kick_main_notes(bars, velocity_mult=velocity_mult * 0.75, density=density, groove_strength=groove_strength) 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, density: float = 1.0,
groove_strength: float = 0.0, groove_strength: float = 0.0,
) -> dict[int, list[dict]]: ) -> 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...]}. Returns {CH_S: [notes...]}.
""" """
_PATTERN = [(1.0, 100), (2.0, 95), (2.5, 110), (3.0, 90)]
notes: list[dict] = [] notes: list[dict] = []
for b in range(bars): for b in range(bars):
o = b * 4.0 o = b * 4.0
for p, v in _PATTERN: for pos, base_vel in SNARE_DEMBOW:
notes.append(_note(o + p, 0.15, _apply_vel(v, velocity_mult))) notes.append(_note(o + pos, 0.15, _apply_vel(base_vel, velocity_mult)))
return _apply_groove({CH_S: notes}, groove_strength) return _apply_groove({CH_S: notes}, groove_strength)
@@ -136,24 +253,16 @@ def snare_fill_notes(
density: float = 1.0, density: float = 1.0,
groove_strength: float = 0.0, groove_strength: float = 0.0,
) -> dict[int, list[dict]]: ) -> 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...]}. 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] = [] notes: list[dict] = []
for b in range(bars): for b in range(bars):
o = b * 4.0 o = b * 4.0
for p, v in _PATTERN: for pos, base_vel in SNARE_FILLS:
notes.append(_note(o + p, 0.15, _apply_vel(v, velocity_mult))) notes.append(_note(o + pos, 0.15, _apply_vel(base_vel, velocity_mult)))
return _apply_groove({CH_S: notes}, groove_strength) return _apply_groove({CH_S: notes}, groove_strength)
@@ -163,9 +272,9 @@ def snare_outro_notes(
density: float = 1.0, density: float = 1.0,
groove_strength: float = 0.0, groove_strength: float = 0.0,
) -> dict[int, list[dict]]: ) -> 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...]}. Returns {CH_S: [notes...]}.
""" """
return snare_verse_notes(bars, velocity_mult=velocity_mult * 0.7, density=density, groove_strength=groove_strength) 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, density: float = 1.0,
groove_strength: float = 0.0, groove_strength: float = 0.0,
) -> dict[int, list[dict]]: ) -> 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 Accents align with kick/snare positions (beats 1, 2, 3, 3&) to drive the groove.
off-8ths (vel 40). density=1.0 → all 16ths; density=0.5 → every other. density=1.0 → all 16ths; density=0.5 → every other 16th.
Returns {CH_H: [notes...]}. Returns {CH_H: [notes...]}.
""" """
notes: list[dict] = [] notes: list[dict] = []
step = max(1, round(1.0 / density)) if density > 0 else 1 step = max(1, round(1.0 / density)) if density > 0 else 1
for b in range(bars): for b in range(bars):
o = b * 4.0 o = b * 4.0
for i in range(0, 16, step): for idx, (pos, base_vel) in enumerate(HIHAT_16TH_DEMBOW):
beat_frac = i * 0.25 # position within bar in beats if idx % step == 0:
if beat_frac % 1.0 == 0.0: # quarter note position notes.append(_note(o + pos, 0.1, _apply_vel(base_vel, velocity_mult)))
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)))
return _apply_groove({CH_H: notes}, groove_strength) return _apply_groove({CH_H: notes}, groove_strength)
@@ -209,59 +312,65 @@ def hihat_8th_notes(
density: float = 1.0, density: float = 1.0,
groove_strength: float = 0.0, groove_strength: float = 0.0,
) -> dict[int, list[dict]]: ) -> 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...]}. Returns {CH_H: [notes...]}.
""" """
notes: list[dict] = [] notes: list[dict] = []
for b in range(bars): for b in range(bars):
o = b * 4.0 o = b * 4.0
for i in range(8): for pos, base_vel in HIHAT_OFFBEAT:
base_vel = 70 if i % 2 == 0 else 50 notes.append(_note(o + pos, 0.1, _apply_vel(base_vel, velocity_mult)))
notes.append(_note(o + i * 0.5, 0.1, _apply_vel(base_vel, velocity_mult)))
return _apply_groove({CH_H: notes}, groove_strength) return _apply_groove({CH_H: notes}, groove_strength)
# ---------------------------------------------------------------------------
# Clap generator
# ---------------------------------------------------------------------------
def clap_24_notes( def clap_24_notes(
bars: int, bars: int,
velocity_mult: float = 1.0, velocity_mult: float = 1.0,
density: float = 1.0, density: float = 1.0,
groove_strength: float = 0.0, groove_strength: float = 0.0,
) -> dict[int, list[dict]]: ) -> 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...]}. Returns {CH_CL: [notes...]}.
""" """
notes: list[dict] = [] notes: list[dict] = []
for b in range(bars): for b in range(bars):
o = b * 4.0 o = b * 4.0
notes.append(_note(o + 1.0, 0.15, _apply_vel(120, velocity_mult))) for pos, base_vel in CLAP_DEMBOW:
notes.append(_note(o + 3.0, 0.15, _apply_vel(120, velocity_mult))) notes.append(_note(o + pos, 0.15, _apply_vel(base_vel, velocity_mult)))
return _apply_groove({CH_CL: notes}, groove_strength) return _apply_groove({CH_CL: notes}, groove_strength)
# ---------------------------------------------------------------------------
# Percussion generators
# ---------------------------------------------------------------------------
def perc_combo_notes( def perc_combo_notes(
bars: int, bars: int,
velocity_mult: float = 1.0, velocity_mult: float = 1.0,
density: float = 1.0, density: float = 1.0,
groove_strength: float = 0.0, groove_strength: float = 0.0,
) -> dict[int, list[dict]]: ) -> 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). perc2 (CH_P2): conga slap and open tone
perc1 (CH_P1): positions 1.5 (vel 70) and 3.5 (vel 65). perc1 (CH_P1): reserved for additional perc (not used in base pattern)
Returns {CH_P1: [...], CH_P2: [...]}. Returns {CH_P2: [...], CH_P1: [...]}.
""" """
p2_notes: list[dict] = [] p2_notes: list[dict] = []
p1_notes: list[dict] = [] p1_notes: list[dict] = []
for b in range(bars): for b in range(bars):
o = b * 4.0 o = b * 4.0
p2_notes.append(_note(o + 0.75, 0.1, _apply_vel(85, velocity_mult))) for pos, base_vel in CONGAS_DEMBOW:
p2_notes.append(_note(o + 2.75, 0.1, _apply_vel(80, velocity_mult))) p2_notes.append(_note(o + pos, 0.1, _apply_vel(base_vel, 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)))
return _apply_groove({CH_P1: p1_notes, CH_P2: p2_notes}, groove_strength) 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, density: float = 1.0,
groove_strength: float = 0.0, groove_strength: float = 0.0,
) -> dict[int, list[dict]]: ) -> 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=0: sparse (positions 0, 2, 8, 14 in 16ths)
Bar N%4=1: indices 0,2,4,8,10,14 (filling in) Bar N%4=1: filling in (positions 0, 2, 4, 8, 10, 14)
Bar N%4=2: indices 0,2,4,6,8,10,12,14 (every other 16th) Bar N%4=2: every other 16th (positions 0, 2, 4, 6, 8, 10, 12, 14)
Bar N%4=3: all 16 indices (full roll) Bar N%4=3: full roll (all 16 positions)
Velocity ramps: 50 → 65 → 80 → 100 across the cycle.
Velocity ramps: 50 → 65 → 80 → 100 across the 4-bar cycle.
Returns {CH_R: [notes...]}. Returns {CH_R: [notes...]}.
""" """
_PATTERNS = [ _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( def get_notes(
generator_name: str, generator_name: str,
bars: int, bars: int,
velocity_mult: float = 1.0, velocity_mult: float = 1.0,
density: float = 1.0, density: float = 1.0,
groove_strength: float = 0.0, groove_strength: float = 0.0,
bank: str = "dembow_classico",
) -> dict[int, list[dict]]: ) -> dict[int, list[dict]]:
"""Dispatch to the named generator. Raises KeyError if not found.""" """Dispatch to the named generator.
gen = GENERATORS[generator_name]
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) 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"], ["SMPTESYNC", "0", "30", "100", "40", "1000", "300", "0", "0", "1", "0", "0"],
["LOOP", "0"], ["LOOP", "0"],
["LOOPGRAN", "0", "4"], ["LOOPGRAN", "0", "4"],
["RECORD_PATH", '"Media"', '""'], ["RECORD_PATH", "Media", ""],
Element("RECORD_CFG", [], children=["ZXZhdxgAAQ=="]), Element("RECORD_CFG", [], children=["ZXZhdxgAAQ=="]),
[], [],
Element("APPLYFX_CFG", [], children=[]), Element("APPLYFX_CFG", [], children=[]),
[], [],
["RENDER_FILE", '""'], ["RENDER_FILE", ""],
["RENDER_PATTERN", '""'], ["RENDER_PATTERN", ""],
["RENDER_FMT", "0", "2", "0"], ["RENDER_FMT", "0", "2", "0"],
["RENDER_1X", "0"], ["RENDER_1X", "0"],
["RENDER_RANGE", "1", "0", "0", "0", "1000"], ["RENDER_RANGE", "1", "0", "0", "0", "1000"],
@@ -181,8 +181,10 @@ _FXCHAIN_FOOTER: list[list[str]] = [
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _make_guid() -> str: def _make_guid() -> str:
"""Generate a random REAPER GUID string.""" """Generate a random REAPER GUID string using random module (seedable)."""
return str(uuid.uuid4()).upper() # 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: 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") builder.write("output.rpp")
""" """
def __init__(self, song: SongDefinition) -> None: def __init__(self, song: SongDefinition, seed: int | None = None) -> None:
self.song = song 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: def write(self, path: str | Path) -> None:
"""Serialize the project to a .rpp file at *path*. """Serialize the project to a .rpp file at *path*.
@@ -255,7 +266,9 @@ class RPPBuilder:
m = self.song.meta m = self.song.meta
# Project root — version from test_vst3.rpp line 1 # 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 # Add all static project header lines
for line in _PROJECT_HEADER: for line in _PROJECT_HEADER:
@@ -263,10 +276,11 @@ class RPPBuilder:
root.append(line) root.append(line)
# TEMPO is injected dynamically (overrides static header) # 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 track
master_guid = _make_guid() master_guid = self._make_seeded_guid()
master = Element("TRACK", [master_guid]) master = Element("TRACK", [master_guid])
master.append(['NAME', "master"]) master.append(['NAME', "master"])
master.append(["VOLPAN", "1.0", "0", "-1", "-1", "1"]) master.append(["VOLPAN", "1.0", "0", "-1", "-1", "1"])
@@ -285,7 +299,7 @@ class RPPBuilder:
if line: if line:
footer_copy = [v for v in line] footer_copy = [v for v in line]
if footer_copy[0] == "FXID": 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_fxchain.append(footer_copy)
master.append(master_fxchain) master.append(master_fxchain)
root.append(master) root.append(master)
@@ -298,7 +312,7 @@ class RPPBuilder:
def _build_track(self, track: TrackDef) -> Element: def _build_track(self, track: TrackDef) -> Element:
"""Build a TRACK Element with all default attributes from test_vst3.rpp.""" """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 = Element("TRACK", [f"{{{track_guid}}}"])
track_elem.append(["NAME", track.name]) track_elem.append(["NAME", track.name])
@@ -332,7 +346,7 @@ class RPPBuilder:
fxchain.append([v for v in line]) fxchain.append([v for v in line])
for plugin in track.plugins: for plugin in track.plugins:
fxchain.append(self._build_plugin(plugin)) fxchain.append(self._build_plugin(plugin))
fxid_guid = _make_guid() fxid_guid = self._make_seeded_guid()
fxchain.append(["PRESETNAME", "Program 1"]) fxchain.append(["PRESETNAME", "Program 1"])
fxchain.append(["FLOATPOS", "0", "0", "0", "0"]) fxchain.append(["FLOATPOS", "0", "0", "0", "0"])
fxchain.append(["FXID", f"{{{fxid_guid}}}"]) fxchain.append(["FXID", f"{{{fxid_guid}}}"])

View File

@@ -16,6 +16,7 @@ from __future__ import annotations
import json import json
import os import os
import random
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -306,10 +307,27 @@ class SampleSelector:
matches.sort(key=lambda m: m.score, reverse=True) matches.sort(key=lambda m: m.score, reverse=True)
return matches[:limit] return matches[:limit]
def select_one(self, role: str, **kwargs) -> Optional[dict]: def select_one(
"""Select the single best matching sample.""" self,
results = self.select(role=role, limit=1, **kwargs) role: str,
return results[0].sample if results else None 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]: def get_roles(self) -> list[str]:
"""Get all available roles and their counts.""" """Get all available roles and their counts."""

View File

@@ -200,14 +200,17 @@ class TestSectionBuilderIntegration:
index_path = _ROOT / "data" / "sample_index.json" index_path = _ROOT / "data" / "sample_index.json"
selector = SampleSelector(str(index_path)) 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(tracks) > 0, "Expected at least one track"
assert len(sections) > 0, "Expected at least one section" 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: for sec in sections:
assert sec.name in ["intro", "verse", "chorus", "outro", assert sec.name in valid_names, f"Unexpected section name: {sec.name}"
"verse2", "chorus2", "bridge", "chorus3"]
def test_song_definition_has_sections_field(self): def test_song_definition_has_sections_field(self):
"""SongDefinition has a sections field.""" """SongDefinition has a sections field."""