feat: reggaeton production system with intelligent sample selection and FLP generation

This commit is contained in:
renato97
2026-05-02 21:40:18 -03:00
commit 4d941f3f90
62 changed files with 8656 additions and 0 deletions

310
src/composer/__init__.py Normal file
View File

@@ -0,0 +1,310 @@
from __future__ import annotations
import json
import math
from pathlib import Path
from typing import Optional
KNOWLEDGE_DIR = Path(__file__).parent.parent.parent / "knowledge"
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
SCALE_INTERVALS = {
"major": [0, 2, 4, 5, 7, 9, 11],
"minor": [0, 2, 3, 5, 7, 8, 10],
"harmonic_minor": [0, 2, 3, 5, 7, 8, 11],
"melodic_minor": [0, 2, 3, 5, 7, 9, 11],
"dorian": [0, 2, 3, 5, 7, 9, 10],
"phrygian": [0, 1, 3, 5, 7, 8, 10],
}
CHORD_TYPES = {
"maj": [0, 4, 7],
"min": [0, 3, 7],
"dim": [0, 3, 6],
"aug": [0, 4, 8],
"7": [0, 4, 7, 10],
"m7": [0, 3, 7, 10],
"sus2": [0, 2, 7],
"sus4": [0, 5, 7],
}
def note_to_midi(note_str: str) -> int:
note_str = note_str.strip()
if len(note_str) == 1:
name = note_str[0]
octave = 4
elif len(note_str) == 2:
if note_str[1] == "#":
name = note_str[:2]
octave = 4
else:
name = note_str[0]
octave = int(note_str[1])
else:
name = note_str[:2] if note_str[1] == "#" else note_str[0]
octave = int(note_str[-1])
base = NOTE_NAMES.index(name)
return (octave + 1) * 12 + base
def parse_chord_name(chord_str: str) -> tuple[int, list[int]]:
chord_str = chord_str.strip()
root = chord_str[0]
idx = 1
if len(chord_str) > 1 and chord_str[1] == "#":
root += "#"
idx = 2
suffix = chord_str[idx:]
if suffix == "m" or suffix == "min":
chord_type = "min"
elif suffix == "dim":
chord_type = "dim"
elif suffix == "7":
chord_type = "7"
elif suffix == "m7":
chord_type = "m7"
elif suffix == "sus2":
chord_type = "sus2"
elif suffix == "sus4":
chord_type = "sus4"
elif suffix == "":
chord_type = "maj"
else:
chord_type = "maj"
root_midi = note_to_midi(root + "4")
intervals = CHORD_TYPES.get(chord_type, [0, 4, 7])
return root_midi, intervals
def generate_dembow(bars: int = 8, ppq: int = 96) -> list[dict]:
notes = []
for bar in range(bars):
offset = bar * 4.0
kick_positions = [0.0, 2.5]
snare_positions = [2.0, 4.0]
hihat_positions = [i * 0.5 for i in range(8)]
for p in kick_positions:
notes.append({"position": offset + p, "length": 0.25, "key": 36, "velocity": 110})
for p in snare_positions:
notes.append({"position": offset + p, "length": 0.15, "key": 38, "velocity": 105})
for p in hihat_positions:
notes.append({"position": offset + p, "length": 0.1, "key": 42, "velocity": 75})
for i in [1, 3, 5, 7]:
notes.append({
"position": offset + hihat_positions[i],
"length": 0.1,
"key": 46,
"velocity": 60,
})
return notes
def generate_bass_808(
chord_progression: list[str],
beats_per_chord: int = 4,
octave: int = 2,
bars: int = 8,
) -> list[dict]:
notes = []
pos = 0.0
total_beats = bars * 4
while pos < total_beats:
for chord_name in chord_progression:
root_midi, _ = parse_chord_name(chord_name)
bass_note = root_midi - (4 - octave) * 12
notes.append({
"position": pos,
"length": min(beats_per_chord - 0.1, total_beats - pos),
"key": bass_note,
"velocity": 100,
})
pos += beats_per_chord
if pos >= total_beats:
break
return notes
def generate_piano_stabs(
chord_progression: list[str],
beats_per_chord: int = 4,
bars: int = 8,
) -> list[dict]:
notes = []
pos = 0.0
total_beats = bars * 4
while pos < total_beats:
for chord_name in chord_progression:
root_midi, intervals = parse_chord_name(chord_name)
chord_notes = [root_midi + iv for iv in intervals]
for stab_pos in [0.5, 1.5, 2.5, 3.5]:
actual_pos = pos + stab_pos
if actual_pos >= total_beats:
break
for cn in chord_notes:
notes.append({
"position": actual_pos,
"length": 0.2,
"key": cn,
"velocity": 70,
})
pos += beats_per_chord
if pos >= total_beats:
break
return notes
def generate_lead_hook(
chord_progression: list[str],
beats_per_chord: int = 4,
bars: int = 8,
octave: int = 5,
) -> list[dict]:
notes = []
pos = 0.0
total_beats = bars * 4
hook_patterns = [
[0, 0.5, 1.0, 2.0, 3.0],
[0, 1.0, 1.5, 2.0, 3.5],
[0, 0.25, 0.5, 2.0, 2.5, 3.0],
]
pattern_idx = 0
while pos < total_beats:
for chord_name in chord_progression:
root_midi, intervals = parse_chord_name(chord_name)
scale_notes = [root_midi + iv for iv in [0, 2, 3, 5, 7, 8, 10]]
target_octave_notes = [n + (octave - 4) * 12 for n in scale_notes]
pattern = hook_patterns[pattern_idx % len(hook_patterns)]
for i, p in enumerate(pattern):
actual_pos = pos + p
if actual_pos >= total_beats:
break
note_idx = i % len(target_octave_notes)
notes.append({
"position": actual_pos,
"length": 0.4 if i < len(pattern) - 1 else 0.8,
"key": target_octave_notes[note_idx],
"velocity": 90 if i % 2 == 0 else 75,
})
pos += beats_per_chord
pattern_idx += 1
if pos >= total_beats:
break
return notes
def generate_pad(
chord_progression: list[str],
beats_per_chord: int = 4,
bars: int = 8,
octave: int = 4,
) -> list[dict]:
notes = []
pos = 0.0
total_beats = bars * 4
while pos < total_beats:
for chord_name in chord_progression:
root_midi, intervals = parse_chord_name(chord_name)
chord_notes = [root_midi + (octave - 4) * 12 + iv for iv in intervals]
duration = min(beats_per_chord, total_beats - pos)
for cn in chord_notes:
notes.append({
"position": pos,
"length": duration,
"key": cn,
"velocity": 45,
})
pos += beats_per_chord
if pos >= total_beats:
break
return notes
def generate_latin_perc(bars: int = 8) -> list[dict]:
notes = []
for bar in range(bars):
offset = bar * 4.0
shaker = [(i * 0.25) + 0.125 for i in range(16)]
for p in shaker:
notes.append({"position": offset + p, "length": 0.1, "key": 50, "velocity": 55})
congas = [0.0, 1.0, 2.0, 3.0]
for p in congas:
notes.append({"position": offset + p, "length": 0.2, "key": 54, "velocity": 65})
rim = [0.75, 2.75]
for p in rim:
notes.append({"position": offset + p, "length": 0.1, "key": 37, "velocity": 50})
return notes
def compose_from_genre(
genre_path: str | Path,
custom_overrides: Optional[dict] = None,
) -> dict:
with open(genre_path, "r", encoding="utf-8") as f:
genre = json.load(f)
if custom_overrides:
genre.update(custom_overrides)
bpm = genre["bpm"]["default"]
ppq = genre.get("ppq", 96)
key = genre["keys"][0]
progression = genre["chord_progressions"][0]["chords"]
beats_per_chord = genre["chord_progressions"][0].get("beats_per_chord", 4)
bars = genre["structure"]["sections"][1]["bars"]
composition = {
"meta": {
"genre": genre["genre"],
"era": genre.get("era", ""),
"bpm": bpm,
"ppq": ppq,
"key": key,
"chord_progression": progression,
"beats_per_chord": beats_per_chord,
"bars": bars,
},
"tracks": [],
}
for role_name, role_config in genre["roles"].items():
track = {
"role": role_name,
"description": role_config["description"],
"preferred_plugins": role_config["preferred_plugins"],
"mixer_slot": role_config.get("mixer_slot", 0),
}
if role_name == "drums":
track["notes"] = generate_dembow(bars, ppq)
elif role_name == "bass":
track["notes"] = generate_bass_808(
progression, beats_per_chord,
octave=role_config.get("octave", 2),
bars=bars,
)
elif role_name == "harmony":
track["notes"] = generate_piano_stabs(progression, beats_per_chord, bars)
elif role_name == "lead":
track["notes"] = generate_lead_hook(
progression, beats_per_chord, bars,
octave=role_config.get("octave", 5),
)
elif role_name == "pad":
track["notes"] = generate_pad(
progression, beats_per_chord, bars,
octave=role_config.get("octave", 4),
)
elif role_name == "perc":
track["notes"] = generate_latin_perc(bars)
composition["tracks"].append(track)
return composition

288
src/composer/melodic.py Normal file
View File

@@ -0,0 +1,288 @@
"""Melodic pattern generators for reggaeton production.
All generators return list[dict] with format {pos, len, key, vel}.
Designed to feed MelodicTrack notes in SongDefinition.
"""
# ---------------------------------------------------------------------------
# Scale definitions
# ---------------------------------------------------------------------------
SCALES = {
"minor": [0, 2, 3, 5, 7, 8, 10], # natural minor
"major": [0, 2, 4, 5, 7, 9, 11],
"phrygian": [0, 1, 3, 5, 7, 8, 10],
"dorian": [0, 2, 3, 5, 7, 9, 10],
}
ROOT_SEMITONE = {
"C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3,
"E": 4, "F": 5, "F#": 6, "Gb": 6, "G": 7, "G#": 8,
"Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11,
}
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _parse_key(key_str: str) -> tuple[int, str]:
"""Parse a key like 'Am', 'C#m', 'Dm', 'C' into (root_semitone, scale_name)."""
if key_str.endswith("m") and key_str != "m":
root_str = key_str[:-1]
scale_name = "minor"
else:
root_str = key_str
scale_name = "major"
root = ROOT_SEMITONE.get(root_str)
if root is None:
raise ValueError(f"Unknown root: {root_str}")
return root, scale_name
def _get_scale_notes(root: int, scale: str, octave: int) -> list[int]:
"""Return MIDI note numbers for all degrees of the scale in given octave."""
intervals = SCALES.get(scale, SCALES["major"])
return [root + octave * 12 + interval for interval in intervals]
def _clamp_vel(v: int) -> int:
"""Clamp velocity to valid MIDI range [1, 127]."""
return max(1, min(127, v))
# ---------------------------------------------------------------------------
# Bass: tresillo
# ---------------------------------------------------------------------------
def bass_tresillo(
key: str,
bars: int,
octave: int = 3,
velocity_mult: float = 1.0,
) -> list[dict]:
"""Reggaeton tresillo bass pattern.
6 notes per bar at positions: 0.0, 0.75, 1.5, 2.25, 3.0, 3.75
Root note on downbeats (0.0, 1.5, 3.0), fifth (7 semitones) on upbeats.
Velocity: 110 for downbeats, 85 for upbeats.
Default octave=3 gives root in MIDI range 45-52 (A3-E4), within 36-55.
"""
root, scale = _parse_key(key)
scale_notes = _get_scale_notes(root, scale, octave)
root_note = scale_notes[0] # degree 0
fifth_note = root_note + 7 # up a perfect fifth
notes: list[dict] = []
for b in range(bars):
o = b * 4.0
# Positions within the bar
positions = [0.0, 0.75, 1.5, 2.25, 3.0, 3.75]
for idx, pos in enumerate(positions):
if idx % 2 == 0: # downbeats: root
key_note = root_note
vel = 110
else: # upbeats: fifth
key_note = fifth_note
vel = 85
vel = _clamp_vel(int(vel * velocity_mult))
notes.append({"pos": o + pos, "len": 0.25, "key": key_note, "vel": vel})
return notes
# ---------------------------------------------------------------------------
# Lead: hook
# ---------------------------------------------------------------------------
def lead_hook(
key: str,
bars: int,
octave: int = 5,
density: float = 0.6,
velocity_mult: float = 1.0,
) -> list[dict]:
"""Simple melodic hook over 4-8 bars.
Uses scalar degrees: [0, 2, 4, 2, 3, 1, 0, 2, 4, 5, 4, 2, 0]
Note durations: 0.5 or 1.0 beats.
density=1.0 → every slot filled; density=0.5 → half filled.
"""
root, scale = _parse_key(key)
intervals = SCALES.get(scale, SCALES["major"])
# Map scale degrees to MIDI notes (extend to cover octave 5 and 6 for melody)
scale_notes_oct5 = _get_scale_notes(root, scale, octave) # 7 notes
scale_notes_oct6 = _get_scale_notes(root, scale, octave + 1)
# Degree pattern (0-indexed scale degrees)
degrees = [0, 2, 4, 2, 3, 1, 0, 2, 4, 5, 4, 2, 0]
notes: list[dict] = []
# Step through the pattern at half-beat intervals
# density controls whether we actually place a note
step = max(1, round(1.0 / density)) if density > 0 else 1
pos = 0.0
degree_idx = 0
while pos < bars * 4.0:
slot = int(pos * 2) # 0.5-beat slots
if slot % step == 0:
# Pick note alternating between octave 5 and 6 for contour
use_oct6 = (degree_idx // 2) % 3 == 0 # every few notes go higher
midi_note = scale_notes_oct6[degrees[degree_idx] % 7] \
if use_oct6 else scale_notes_oct5[degrees[degree_idx] % 7]
# Duration: 1.0 beat on strong beats (quarter), 0.5 elsewhere
is_strong = (slot % 4 == 0)
length = 1.0 if is_strong else 0.5
vel = 100 if is_strong else 80
vel = _clamp_vel(int(vel * velocity_mult))
notes.append({"pos": pos, "len": length, "key": midi_note, "vel": vel})
# Advance degree index
degree_idx = (degree_idx + 1) % len(degrees)
if is_strong:
pos += 1.0
else:
pos += 0.5
else:
pos += 0.5
return notes
# ---------------------------------------------------------------------------
# Chords: block chords
# ---------------------------------------------------------------------------
def chords_block(
key: str,
bars: int,
octave: int = 4,
velocity_mult: float = 1.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 notes
# ---------------------------------------------------------------------------
# Pad: sustain
# ---------------------------------------------------------------------------
def pad_sustain(
key: str,
bars: int,
octave: int = 4,
velocity_mult: float = 1.0,
) -> list[dict]:
"""Long sustained pad notes, one per bar.
Follows chord progression from chords_block.
Notes last 3.5 beats to avoid collision with next bar's note.
Soft velocity (65-75).
"""
root, scale = _parse_key(key)
if scale == "minor":
chord_intervals = [
(0, 3, 7),
(10, 1, 5),
(8, 0, 4),
(10, 1, 5),
]
root_notes_per_bar = [0, 10, 8, 10] # root semitone offsets per bar
else:
chord_intervals = [
(0, 4, 7),
(7, 11, 2),
(9, 0, 4),
(5, 9, 0),
]
root_notes_per_bar = [0, 7, 9, 5]
notes: list[dict] = []
for b in range(bars):
o = b * 4.0
cycle = b % 4
root_interval = root_notes_per_bar[cycle]
midi_note = root + octave * 12 + root_interval
vel = 70
vel = _clamp_vel(int(vel * velocity_mult))
notes.append({
"pos": o,
"len": 3.5,
"key": midi_note,
"vel": vel,
})
return notes

311
src/composer/rhythm.py Normal file
View File

@@ -0,0 +1,311 @@
"""Reggaeton rhythm generators — pure functions returning note dicts per channel."""
# ---------------------------------------------------------------------------
# Channel constants — match SAMPLE_MAP in channel_skeleton.py
# ---------------------------------------------------------------------------
CH_P1 = 10 # perc1.wav
CH_K = 11 # kick.wav
CH_S = 12 # snare.wav
CH_R = 13 # rim.wav
CH_P2 = 14 # perc2.wav
CH_H = 15 # hihat.wav
CH_CL = 16 # clap.wav
# Note dict format: {"pos": float, "len": float, "key": int, "vel": int}
# pos — in BEATS from start of bar 0 (bar 2 beat 3 → 2*4 + 2 = 10.0)
# len — in beats (0.25 = 16th note at 4/4)
# key — always 60 for drum samples (pitch irrelevant, sample just plays)
# vel — 1127 after applying velocity_mult
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _clamp_vel(vel: int) -> int:
"""Clamp velocity to valid MIDI range [1, 127]."""
return max(1, min(127, vel))
def _apply_vel(base_vel: int, velocity_mult: float) -> int:
"""Multiply base velocity by velocity_mult and clamp."""
return _clamp_vel(int(base_vel * velocity_mult))
def _note(pos: float, length: float, vel: int) -> dict:
"""Create a note dict with key=60."""
return {"pos": pos, "len": length, "key": 60, "vel": vel}
# ---------------------------------------------------------------------------
# Kick generators
# ---------------------------------------------------------------------------
def kick_main_notes(
bars: int,
velocity_mult: float = 1.0,
density: float = 1.0,
) -> dict[int, list[dict]]:
"""Dembow kick: beat 1 (hard, vel 115) + beat 2-and (the dembow hit, vel 105).
Positions per bar: 0.0 and 1.5 (the classic "one — &-two" reggaeton kick).
Returns {CH_K: [notes...]}.
"""
notes: list[dict] = []
for b in range(bars):
o = b * 4.0
notes.append(_note(o, 0.25, _apply_vel(115, velocity_mult)))
notes.append(_note(o + 1.5, 0.25, _apply_vel(105, velocity_mult)))
return {CH_K: notes}
def kick_sparse_notes(
bars: int,
velocity_mult: float = 1.0,
density: float = 1.0,
) -> dict[int, list[dict]]:
"""Sparse intro/outro kick: just beat 1 per bar (vel 110).
Returns {CH_K: [notes...]}.
"""
notes: list[dict] = []
for b in range(bars):
o = b * 4.0
notes.append(_note(o, 0.25, _apply_vel(110, velocity_mult)))
return {CH_K: notes}
def kick_outro_notes(
bars: int,
velocity_mult: float = 1.0,
density: float = 1.0,
) -> dict[int, list[dict]]:
"""Outro kick: dembow pattern with 0.75 baseline softness.
Delegates to kick_main_notes with an additional 0.75 velocity scaling.
Returns {CH_K: [notes...]}.
"""
return kick_main_notes(bars, velocity_mult=velocity_mult * 0.75, density=density)
# ---------------------------------------------------------------------------
# Snare generators
# ---------------------------------------------------------------------------
def snare_verse_notes(
bars: int,
velocity_mult: float = 1.0,
density: float = 1.0,
) -> dict[int, list[dict]]:
"""Reggaeton snare: beats 2, 3, 3-and, 4 per bar.
Positions: 1.0 (vel 100), 2.0 (vel 95), 2.5 (vel 110), 3.0 (vel 90).
Returns {CH_S: [notes...]}.
"""
_PATTERN = [(1.0, 100), (2.0, 95), (2.5, 110), (3.0, 90)]
notes: list[dict] = []
for b in range(bars):
o = b * 4.0
for p, v in _PATTERN:
notes.append(_note(o + p, 0.15, _apply_vel(v, velocity_mult)))
return {CH_S: notes}
def snare_fill_notes(
bars: int,
velocity_mult: float = 1.0,
density: float = 1.0,
) -> dict[int, list[dict]]:
"""Busier snare with 16th-note fills: adds positions 2.25 and 3.75.
Verse base (1.0, 2.0, 2.5, 3.0) plus 16th fills at 2.25 and 3.75.
Returns {CH_S: [notes...]}.
"""
_PATTERN = [
(1.0, 100),
(2.0, 95),
(2.25, 80), # 16th fill
(2.5, 110),
(3.0, 90),
(3.75, 85), # 16th fill
]
notes: list[dict] = []
for b in range(bars):
o = b * 4.0
for p, v in _PATTERN:
notes.append(_note(o + p, 0.15, _apply_vel(v, velocity_mult)))
return {CH_S: notes}
def snare_outro_notes(
bars: int,
velocity_mult: float = 1.0,
density: float = 1.0,
) -> dict[int, list[dict]]:
"""Softer outro snare (velocity_mult on top of 0.7 baseline).
Delegates to snare_verse_notes with an additional 0.7 velocity scaling.
Returns {CH_S: [notes...]}.
"""
return snare_verse_notes(bars, velocity_mult=velocity_mult * 0.7, density=density)
# ---------------------------------------------------------------------------
# Hihat generators
# ---------------------------------------------------------------------------
def hihat_16th_notes(
bars: int,
velocity_mult: float = 1.0,
density: float = 1.0,
) -> dict[int, list[dict]]:
"""16th-note hihat with three-tier accent mapping.
Accented on quarter notes (vel 85), medium on 8ths (vel 60), soft on
off-8ths (vel 40). density=1.0 → all 16ths; density=0.5 → every other.
Returns {CH_H: [notes...]}.
"""
notes: list[dict] = []
step = max(1, round(1.0 / density)) if density > 0 else 1
for b in range(bars):
o = b * 4.0
for i in range(0, 16, step):
beat_frac = i * 0.25 # position within bar in beats
if beat_frac % 1.0 == 0.0: # quarter note position
base_vel = 85
elif beat_frac % 0.5 == 0.0: # 8th note position
base_vel = 60
else: # 16th note position
base_vel = 40
notes.append(_note(o + beat_frac, 0.1, _apply_vel(base_vel, velocity_mult)))
return {CH_H: notes}
def hihat_8th_notes(
bars: int,
velocity_mult: float = 1.0,
density: float = 1.0,
) -> dict[int, list[dict]]:
"""8th-note hihat for intro/breakdown.
Accented on beats (vel 70), off-beats softer (vel 50).
Returns {CH_H: [notes...]}.
"""
notes: list[dict] = []
for b in range(bars):
o = b * 4.0
for i in range(8):
base_vel = 70 if i % 2 == 0 else 50
notes.append(_note(o + i * 0.5, 0.1, _apply_vel(base_vel, velocity_mult)))
return {CH_H: notes}
# ---------------------------------------------------------------------------
# Clap generator
# ---------------------------------------------------------------------------
def clap_24_notes(
bars: int,
velocity_mult: float = 1.0,
density: float = 1.0,
) -> dict[int, list[dict]]:
"""Classic reggaeton clap: beats 2 and 4 → positions 1.0 and 3.0 per bar.
Hard clap (vel 120).
Returns {CH_CL: [notes...]}.
"""
notes: list[dict] = []
for b in range(bars):
o = b * 4.0
notes.append(_note(o + 1.0, 0.15, _apply_vel(120, velocity_mult)))
notes.append(_note(o + 3.0, 0.15, _apply_vel(120, velocity_mult)))
return {CH_CL: notes}
# ---------------------------------------------------------------------------
# Percussion generators
# ---------------------------------------------------------------------------
def perc_combo_notes(
bars: int,
velocity_mult: float = 1.0,
density: float = 1.0,
) -> dict[int, list[dict]]:
"""Perc1 + Perc2 offbeat combo (tumba feel).
perc2 (CH_P2): positions 0.75 (vel 85) and 2.75 (vel 80).
perc1 (CH_P1): positions 1.5 (vel 70) and 3.5 (vel 65).
Returns {CH_P1: [...], CH_P2: [...]}.
"""
p2_notes: list[dict] = []
p1_notes: list[dict] = []
for b in range(bars):
o = b * 4.0
p2_notes.append(_note(o + 0.75, 0.1, _apply_vel(85, velocity_mult)))
p2_notes.append(_note(o + 2.75, 0.1, _apply_vel(80, velocity_mult)))
p1_notes.append(_note(o + 1.5, 0.1, _apply_vel(70, velocity_mult)))
p1_notes.append(_note(o + 3.5, 0.1, _apply_vel(65, velocity_mult)))
return {CH_P1: p1_notes, CH_P2: p2_notes}
def rim_build_notes(
bars: int,
velocity_mult: float = 1.0,
density: float = 1.0,
) -> dict[int, list[dict]]:
"""Rim roll that builds intensity across bars (4-bar cycle).
Bar N%4=0: 16th indices 0,2,8,14 (sparse opening)
Bar N%4=1: indices 0,2,4,8,10,14 (filling in)
Bar N%4=2: indices 0,2,4,6,8,10,12,14 (every other 16th)
Bar N%4=3: all 16 indices (full roll)
Velocity ramps: 50 → 65 → 80 → 100 across the 4-bar cycle.
Returns {CH_R: [notes...]}.
"""
_PATTERNS = [
[0, 2, 8, 14],
[0, 2, 4, 8, 10, 14],
[0, 2, 4, 6, 8, 10, 12, 14],
list(range(16)),
]
_BASE_VELS = [50, 65, 80, 100]
notes: list[dict] = []
for b in range(bars):
cycle = b % 4
o = b * 4.0
base_vel = _BASE_VELS[cycle]
vel = _apply_vel(base_vel, velocity_mult)
for idx in _PATTERNS[cycle]:
notes.append(_note(o + idx * 0.25, 0.1, vel))
return {CH_R: notes}
# ---------------------------------------------------------------------------
# Registry & dispatcher
# ---------------------------------------------------------------------------
GENERATORS: dict[str, callable] = {
"kick_main_notes": kick_main_notes,
"kick_sparse_notes": kick_sparse_notes,
"kick_outro_notes": kick_outro_notes,
"snare_verse_notes": snare_verse_notes,
"snare_fill_notes": snare_fill_notes,
"snare_outro_notes": snare_outro_notes,
"hihat_16th_notes": hihat_16th_notes,
"hihat_8th_notes": hihat_8th_notes,
"clap_24_notes": clap_24_notes,
"perc_combo_notes": perc_combo_notes,
"rim_build_notes": rim_build_notes,
}
def get_notes(
generator_name: str,
bars: int,
velocity_mult: float = 1.0,
density: float = 1.0,
) -> dict[int, list[dict]]:
"""Dispatch to the named generator. Raises KeyError if not found."""
gen = GENERATORS[generator_name]
return gen(bars, velocity_mult, density)

296
src/composer/variation.py Normal file
View File

@@ -0,0 +1,296 @@
"""Variation engine — generates unique SongDefinition instances from a seed.
Pure functions: no file I/O, no print statements. The only side effect is
the deterministic randomness from ``random.Random(idx)`` — same seed always
produces the same output.
Usage::
from src.composer.variation import generate_variant, generate_batch
one_song = generate_variant(42)
fifty = generate_batch(50)
"""
from __future__ import annotations
import random
from pathlib import Path
from typing import Iterator
from ..flp_builder.schema import (
ArrangementItemDef,
ArrangementTrack,
PatternDef,
SongDefinition,
SongMeta,
)
# ---------------------------------------------------------------------------
# Musical constants
# ---------------------------------------------------------------------------
BPMS: list[int] = [88, 90, 92, 94, 95, 96, 98, 100, 102]
KEYS_MINOR: list[str] = ["Am", "Dm", "Em", "Gm", "Bm", "Cm", "Fm"]
KEYS_MAJOR: list[str] = ["C", "F", "G", "D", "A"]
ALL_KEYS: list[str] = KEYS_MINOR + KEYS_MAJOR
PROGRESSIONS: list[str] = [
"i-VII-VI-VII", # Am-G-F-G (classic)
"i-iv-VII-III", # Am-Dm-G-C
"i-VI-III-VII", # Am-F-C-G
"i-VII-III-VI", # Am-G-C-F
"I-V-vi-IV", # C-G-Am-F (major mode)
"I-IV-V-I", # classic major
"i-III-VII-VI", # minor dreamy
"i-v-iv-VII", # dark minor
"I-vi-IV-V", # 50s progression
"i-VII-VI-iv", # modern dark
"i-VI-VII-i", # loop
"i-iv-i-VII", # minimal
"I-II-vi-V", # modern major
"i-III-VI-VII", # uplift
"vi-IV-I-V", # axis
]
TITLE_PREFIXES: list[str] = [
"Zona", "Barrio", "Calle", "Noche", "Fuego",
"Ritmo", "Poder", "Flow", "Vibra", "Cuerpo",
]
TITLE_SUFFIXES: list[str] = [
"Caliente", "Oscura", "Sin Fin", "Total", "Real",
"Fatal", "Natural", "Del Party", "Con Flow", "Urbano",
]
# ---------------------------------------------------------------------------
# Variation axis parameters
# ---------------------------------------------------------------------------
DENSITY_LEVELS: list[float] = [0.6, 0.75, 1.0]
VEL_MULT_LEVELS: list[float] = [0.85, 1.0, 1.1]
SECTION_REPEATS: list[int] = [1, 2] # verse/chorus repeat multiplier
SAMPLES_MAP: dict[str, str] = {
"kick": "kick.wav",
"snare": "snare.wav",
"rim": "rim.wav",
"clap": "clap.wav",
"hihat": "hihat.wav",
"perc1": "perc1.wav",
"perc2": "perc2.wav",
}
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _make_title(rng: random.Random) -> str:
"""Combine a random prefix + suffix into a song title."""
return f"{rng.choice(TITLE_PREFIXES)} {rng.choice(TITLE_SUFFIXES)}"
def _base_tracks() -> list[ArrangementTrack]:
"""Fixed arrangement track layout — 5 tracks, same for all variants."""
return [
ArrangementTrack(index=1, name="Kick"),
ArrangementTrack(index=2, name="Snare"),
ArrangementTrack(index=3, name="Hihat"),
ArrangementTrack(index=4, name="Clap/Rim"),
ArrangementTrack(index=5, name="Perc"),
]
def _build_patterns(rng: random.Random) -> list[PatternDef]:
"""Build 9 base patterns with per-pattern randomized density and velocity_mult.
Pattern ids, names, instruments, channels, and generators are fixed —
matching the reggaeton_template.json structure. The variation axes
(density, velocity_mult) are randomized per pattern.
"""
# (id, name, instrument, channel, bars, generator)
base: list[tuple[int, str, str, int, int, str]] = [
(1, "Kick Main", "kick", 11, 8, "kick_main_notes"),
(2, "Snare Verse", "snare", 12, 8, "snare_verse_notes"),
(3, "Hihat 16th", "hihat", 15, 8, "hihat_16th_notes"),
(4, "Clap 2-4", "clap", 16, 8, "clap_24_notes"),
(5, "Perc Combo", "perc2", 14, 8, "perc_combo_notes"),
(6, "Kick Sparse", "kick", 11, 8, "kick_sparse_notes"),
(7, "Hihat 8th", "hihat", 15, 8, "hihat_8th_notes"),
(8, "Rim Build", "rim", 13, 4, "rim_build_notes"),
(9, "Kick Outro", "kick", 11, 8, "kick_outro_notes"),
]
patterns: list[PatternDef] = []
for pid, name, inst, ch, bars, gen in base:
patterns.append(
PatternDef(
id=pid,
name=name,
instrument=inst,
channel=ch,
bars=bars,
generator=gen,
velocity_mult=rng.choice(VEL_MULT_LEVELS),
density=rng.choice(DENSITY_LEVELS),
)
)
return patterns
def _build_arrangement(
rng: random.Random,
patterns: list[PatternDef], # noqa: ARG001 reserved for future use
) -> list[ArrangementItemDef]:
"""Build arrangement items with variable verse/chorus lengths and optional
breakdown.
Structure::
INTRO (4 bars) — kick_sparse + hihat_8th
VERSE (8|16) — kick_main + snare + hihat_16th + perc_combo
PRE-CHORUS (4 bars) — above + rim_build
CHORUS (8|16) — kick_main + snare + hihat_16th + clap_24 + perc_combo
[VERSE 2 + PRE-CHORUS 2 + CHORUS 2]
[BREAKDOWN (8 bars, 50% chance)] — hihat_8th + kick_sparse
OUTRO (8 bars) — kick_outro + snare + hihat_16th + clap_24
"""
items: list[ArrangementItemDef] = []
cursor: float = 0.0
verse_bars: int = 8 * rng.choice(SECTION_REPEATS)
chorus_bars: int = 8 * rng.choice(SECTION_REPEATS)
has_breakdown: bool = rng.random() < 0.5
def add(pattern_id: int, track: int, length: float) -> None:
"""Append one arrangement item at the current cursor (no advance)."""
items.append(
ArrangementItemDef(
pattern=pattern_id,
bar=cursor,
bars=length,
track=track,
)
)
# --- INTRO (4 bars) ---
add(6, 1, 4) # kick_sparse on Kick
add(7, 3, 4) # hihat_8th on Hihat
cursor += 4
# --- VERSE / PRE-CHORUS / CHORUS × 2 ---
for _ in range(2):
# VERSE
add(1, 1, verse_bars) # kick_main
add(2, 2, verse_bars) # snare_verse
add(3, 3, verse_bars) # hihat_16th
add(5, 5, verse_bars) # perc_combo
cursor += verse_bars
# PRE-CHORUS (4 bars)
add(1, 1, 4) # kick_main
add(2, 2, 4) # snare_verse
add(3, 3, 4) # hihat_16th
add(5, 5, 4) # perc_combo
add(8, 4, 4) # rim_build on Clap/Rim
cursor += 4
# CHORUS
add(1, 1, chorus_bars) # kick_main
add(2, 2, chorus_bars) # snare_verse
add(3, 3, chorus_bars) # hihat_16th
add(4, 4, chorus_bars) # clap_24
add(5, 5, chorus_bars) # perc_combo
cursor += chorus_bars
# --- BREAKDOWN (optional, 8 bars) ---
if has_breakdown:
add(6, 1, 8) # kick_sparse
add(7, 3, 8) # hihat_8th
cursor += 8
# --- OUTRO (8 bars) ---
add(9, 1, 8) # kick_outro on Kick
add(2, 2, 8) # snare_verse on Snare
add(3, 3, 8) # hihat_16th on Hihat
add(4, 4, 8) # clap_24 on Clap/Rim
cursor += 8
return items
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def generate_variant(idx: int) -> SongDefinition:
"""Generate a unique ``SongDefinition`` from integer seed *idx*.
Uses ``random.Random(idx)`` for full reproducibility.
Same ``idx`` → same output, always.
Varies:
- BPM (from ``BPMS``)
- Key (from ``ALL_KEYS``)
- Progression name (from ``PROGRESSIONS``)
- Title (random prefix + suffix)
- Pattern density (per pattern, from ``DENSITY_LEVELS``)
- Pattern velocity_mult (per pattern, from ``VEL_MULT_LEVELS``)
- Verse/chorus bar count (8 or 16 bars)
- Whether breakdown is included (50 % chance)
Uniqueness key: ``(bpm, key, progression_name)`` — checked externally
by ``generate_batch``.
"""
rng = random.Random(idx)
bpm: int = rng.choice(BPMS)
key: str = rng.choice(ALL_KEYS)
prog: str = rng.choice(PROGRESSIONS)
title: str = _make_title(rng)
meta = SongMeta(bpm=bpm, key=key, title=title)
patterns: list[PatternDef] = _build_patterns(rng)
tracks: list[ArrangementTrack] = _base_tracks()
items: list[ArrangementItemDef] = _build_arrangement(rng, patterns)
return SongDefinition(
meta=meta,
samples=SAMPLES_MAP.copy(),
patterns=patterns,
tracks=tracks,
items=items,
progression_name=prog,
section_template="standard",
)
def generate_batch(
count: int = 50,
max_attempts: int = 1000,
) -> list[SongDefinition]:
"""Generate *count* unique songs (unique on bpm+key+progression triple).
Iterates seeds 0 … *max_attempts* until *count* unique songs are found.
Raises ``RuntimeError`` if not enough unique combos are found.
"""
seen: set[tuple[int, str, str]] = set()
songs: list[SongDefinition] = []
for seed in range(max_attempts):
song = generate_variant(seed)
uniq = (song.meta.bpm, song.meta.key, song.progression_name)
if uniq not in seen:
seen.add(uniq)
songs.append(song)
if len(songs) >= count:
break
if len(songs) < count:
raise RuntimeError(
f"Only found {len(songs)} unique songs in {max_attempts} attempts"
)
return songs