Replace FL Studio binary .flp output with REAPER text-based .rpp output using the rpp Python library (Perlence/rpp). - Add core/schema.py: DAW-agnostic data types (SongDefinition, TrackDef, ClipDef, MidiNote, PluginDef) - Add reaper_builder/: RPP file generation via rpp.Element + headless render via reaper.exe CLI - Add composer/converters.py: bridge rhythm.py/melodic.py note dicts to core.schema MidiNote objects - Rewrite scripts/compose.py: real generator pipeline with --render flag - Delete src/flp_builder/, src/scanner/, mcp/, flstudio-mcp/, old scripts - Add 40 passing tests (schema, builder, converters, compose, render)
306 lines
9.9 KiB
Python
306 lines
9.9 KiB
Python
"""Melodic pattern generators for reggaeton production.
|
|
|
|
All generators return list[dict] with format {pos, len, key, vel}.
|
|
Designed to feed MelodicTrack notes in SongDefinition.
|
|
"""
|
|
|
|
import random
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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))
|
|
|
|
|
|
def _apply_humanize(notes, humanize):
|
|
"""Apply humanization (velocity jitter + position nudge) to note list."""
|
|
if humanize <= 0:
|
|
return notes
|
|
jitter = humanize * 5
|
|
nudge = humanize * 0.03
|
|
for n in notes:
|
|
n["vel"] = _clamp_vel(int(n["vel"] + random.uniform(-jitter, jitter)))
|
|
n["pos"] = max(0, n["pos"] + random.uniform(-nudge, nudge))
|
|
return notes
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bass: tresillo
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def bass_tresillo(
|
|
key: str,
|
|
bars: int,
|
|
octave: int = 3,
|
|
velocity_mult: float = 1.0,
|
|
humanize: float = 0.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 _apply_humanize(notes, humanize)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lead: hook
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def lead_hook(
|
|
key: str,
|
|
bars: int,
|
|
octave: int = 5,
|
|
density: float = 0.6,
|
|
velocity_mult: float = 1.0,
|
|
humanize: float = 0.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 _apply_humanize(notes, humanize)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chords: block chords
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def chords_block(
|
|
key: str,
|
|
bars: int,
|
|
octave: int = 4,
|
|
velocity_mult: float = 1.0,
|
|
humanize: float = 0.0,
|
|
) -> list[dict]:
|
|
"""Blocked chords every 2 beats (half-bar).
|
|
|
|
Minor progression: i - VII - VI - VII (degrees 0, 6, 5, 6 in natural minor)
|
|
Major progression: I - V - vi - IV (degrees 0, 4, 5, 3 in major)
|
|
Each chord: root + third + fifth (3 notes stacked at same position).
|
|
"""
|
|
root, scale = _parse_key(key)
|
|
scale_notes_oct4 = _get_scale_notes(root, scale, octave)
|
|
|
|
if scale == "minor":
|
|
# i - VII - VI - VII (natural minor)
|
|
# VII = degree 6 (raised 7th = 10 semitones from root in minor)
|
|
# In natural minor: degrees 0,6,5,6
|
|
# We need to build chords: root, 3rd, 5th
|
|
chord_degrees = [
|
|
[0, 2, 4], # i — degrees 0, 2, 4 in minor
|
|
[6, 1, 3], # VII — degree 6 wraps to next octave; 1=2nd, 3=4th
|
|
[5, 0, 2], # VI — degree 5 wraps; 0=root of next octave
|
|
[6, 1, 3], # VII (repeat)
|
|
]
|
|
# For proper stacking, use only the first 7 scale degrees
|
|
# Chord VII in minor: root is degree 6 (10 semitones above)
|
|
# Build using absolute semitones: i = root+0,root+3,root+7
|
|
# VII = root+10, root+12 (=0 of next), root+15 (=3 of next)
|
|
pass # We'll rebuild below
|
|
|
|
# Simpler approach: build chords using semitone intervals from root
|
|
if scale == "minor":
|
|
# i (0,3,7), VIIb (10,1,5), VI (8,11,2), VII (10,1,5)
|
|
chord_intervals = [
|
|
(0, 3, 7), # i
|
|
(10, 1, 5), # VII (raised 7th in harmonic minor: 10 semitones)
|
|
(8, 0, 4), # VI
|
|
(10, 1, 5), # VII
|
|
]
|
|
else:
|
|
# I (0,4,7), V (7,11,2), vi (9,0,4), IV (5,9,0)
|
|
chord_intervals = [
|
|
(0, 4, 7), # I
|
|
(7, 11, 2), # V
|
|
(9, 0, 4), # vi (9 = root+9)
|
|
(5, 9, 0), # IV (5 = root+5)
|
|
]
|
|
|
|
notes: list[dict] = []
|
|
for b in range(bars):
|
|
o = b * 4.0
|
|
chord_idx = b % 4
|
|
intervals = chord_intervals[chord_idx]
|
|
|
|
# Chord positions at half-bar: 0.0 and 2.0
|
|
chord_positions = [0.0, 2.0]
|
|
for cpos in chord_positions:
|
|
for interval in intervals:
|
|
midi_note = root + octave * 12 + interval
|
|
vel = 90
|
|
vel = _clamp_vel(int(vel * velocity_mult))
|
|
notes.append({
|
|
"pos": o + cpos,
|
|
"len": 1.75, # almost 2 beats (leave gap)
|
|
"key": midi_note,
|
|
"vel": vel,
|
|
})
|
|
|
|
return _apply_humanize(notes, humanize)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pad: sustain
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def pad_sustain(
|
|
key: str,
|
|
bars: int,
|
|
octave: int = 4,
|
|
velocity_mult: float = 1.0,
|
|
humanize: float = 0.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 |