refactor: migrate from FL Studio to REAPER with rpp library
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)
This commit is contained in:
65
src/composer/converters.py
Normal file
65
src/composer/converters.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Converters — transform generator output to MIDI notes for SongDefinition.
|
||||
|
||||
rhythm generators → MidiNote list (channel → GM pitch mapping)
|
||||
melodic generators → MidiNote list (note["key"] = pitch directly)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.core.schema import MidiNote
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GM drum pitch mapping — channels 10-16
|
||||
# ---------------------------------------------------------------------------
|
||||
CHANNEL_PITCH: dict[int, int] = {
|
||||
10: 39, # perc (General MIDI channel 10 = percussion)
|
||||
11: 36, # kick
|
||||
12: 38, # snare
|
||||
13: 37, # rim
|
||||
14: 50, # perc2
|
||||
15: 42, # hihat
|
||||
16: 39, # clap
|
||||
}
|
||||
|
||||
|
||||
def rhythm_to_midi(note_dict: dict[int, list[dict]]) -> list[MidiNote]:
|
||||
"""Convert rhythm generator output (channel → note list) to MidiNote list.
|
||||
|
||||
note_dict: {channel: [{"pos", "len", "key", "vel"}, ...]}
|
||||
- channel must be in CHANNEL_PITCH (10-16)
|
||||
- pitch = CHANNEL_PITCH[channel]
|
||||
- start = note["pos"]
|
||||
- duration = note["len"]
|
||||
- velocity = note["vel"]
|
||||
"""
|
||||
midi_notes: list[MidiNote] = []
|
||||
for channel, notes in note_dict.items():
|
||||
pitch = CHANNEL_PITCH.get(channel, 60)
|
||||
for note in notes:
|
||||
midi_notes.append(MidiNote(
|
||||
pitch=pitch,
|
||||
start=note["pos"],
|
||||
duration=note["len"],
|
||||
velocity=note["vel"],
|
||||
))
|
||||
return midi_notes
|
||||
|
||||
|
||||
def melodic_to_midi(note_list: list[dict]) -> list[MidiNote]:
|
||||
"""Convert melodic generator output (list of note dicts) to MidiNote list.
|
||||
|
||||
note_list: [{"pos", "len", "key", "vel"}, ...]
|
||||
- pitch = note["key"] (directly used, not mapped)
|
||||
- start = note["pos"]
|
||||
- duration = note["len"]
|
||||
- velocity = note["vel"]
|
||||
"""
|
||||
return [
|
||||
MidiNote(
|
||||
pitch=note["key"],
|
||||
start=note["pos"],
|
||||
duration=note["len"],
|
||||
velocity=note["vel"],
|
||||
)
|
||||
for note in note_list
|
||||
]
|
||||
@@ -4,6 +4,8 @@ All generators return list[dict] with format {pos, len, key, vel}.
|
||||
Designed to feed MelodicTrack notes in SongDefinition.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scale definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -52,6 +54,18 @@ def _clamp_vel(v: int) -> int:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -61,6 +75,7 @@ def bass_tresillo(
|
||||
bars: int,
|
||||
octave: int = 3,
|
||||
velocity_mult: float = 1.0,
|
||||
humanize: float = 0.0,
|
||||
) -> list[dict]:
|
||||
"""Reggaeton tresillo bass pattern.
|
||||
|
||||
@@ -90,7 +105,7 @@ def bass_tresillo(
|
||||
vel = _clamp_vel(int(vel * velocity_mult))
|
||||
notes.append({"pos": o + pos, "len": 0.25, "key": key_note, "vel": vel})
|
||||
|
||||
return notes
|
||||
return _apply_humanize(notes, humanize)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -103,6 +118,7 @@ def lead_hook(
|
||||
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.
|
||||
|
||||
@@ -154,7 +170,7 @@ def lead_hook(
|
||||
else:
|
||||
pos += 0.5
|
||||
|
||||
return notes
|
||||
return _apply_humanize(notes, humanize)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -166,6 +182,7 @@ def chords_block(
|
||||
bars: int,
|
||||
octave: int = 4,
|
||||
velocity_mult: float = 1.0,
|
||||
humanize: float = 0.0,
|
||||
) -> list[dict]:
|
||||
"""Blocked chords every 2 beats (half-bar).
|
||||
|
||||
@@ -231,7 +248,7 @@ def chords_block(
|
||||
"vel": vel,
|
||||
})
|
||||
|
||||
return notes
|
||||
return _apply_humanize(notes, humanize)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -243,6 +260,7 @@ def pad_sustain(
|
||||
bars: int,
|
||||
octave: int = 4,
|
||||
velocity_mult: float = 1.0,
|
||||
humanize: float = 0.0,
|
||||
) -> list[dict]:
|
||||
"""Long sustained pad notes, one per bar.
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Reggaeton rhythm generators — pure functions returning note dicts per channel."""
|
||||
|
||||
import random
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Channel constants — match SAMPLE_MAP in channel_skeleton.py
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -21,6 +23,20 @@ CH_CL = 16 # clap.wav
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _apply_groove(notes: list[dict], groove_strength: float) -> list[dict]:
|
||||
"""Apply groove timing and velocity variations to notes.
|
||||
|
||||
groove_strength: 0.0 = no effect, 1.0 = maximum groove feel.
|
||||
"""
|
||||
if groove_strength <= 0:
|
||||
return notes
|
||||
jitter = 5 + groove_strength * 10
|
||||
nudge = groove_strength * 0.02
|
||||
for n in notes:
|
||||
n["vel"] = max(1, min(127, n["vel"] + random.uniform(-jitter, jitter)))
|
||||
n["pos"] = max(0, n["pos"] + random.uniform(-nudge, nudge))
|
||||
return notes
|
||||
|
||||
def _clamp_vel(vel: int) -> int:
|
||||
"""Clamp velocity to valid MIDI range [1, 127]."""
|
||||
return max(1, min(127, vel))
|
||||
@@ -44,6 +60,7 @@ def kick_main_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
groove_strength: float = 0.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Dembow kick: beat 1 (hard, vel 115) + beat 2-and (the dembow hit, vel 105).
|
||||
|
||||
@@ -55,13 +72,14 @@ def kick_main_notes(
|
||||
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}
|
||||
return _apply_groove({CH_K: notes}, groove_strength)
|
||||
|
||||
|
||||
def kick_sparse_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
groove_strength: float = 0.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Sparse intro/outro kick: just beat 1 per bar (vel 110).
|
||||
|
||||
@@ -71,20 +89,21 @@ def kick_sparse_notes(
|
||||
for b in range(bars):
|
||||
o = b * 4.0
|
||||
notes.append(_note(o, 0.25, _apply_vel(110, velocity_mult)))
|
||||
return {CH_K: notes}
|
||||
return _apply_groove({CH_K: notes}, groove_strength)
|
||||
|
||||
|
||||
def kick_outro_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
groove_strength: float = 0.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)
|
||||
return kick_main_notes(bars, velocity_mult=velocity_mult * 0.75, density=density, groove_strength=groove_strength)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -95,6 +114,7 @@ def snare_verse_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
groove_strength: float = 0.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Reggaeton snare: beats 2, 3, 3-and, 4 per bar.
|
||||
|
||||
@@ -107,13 +127,14 @@ def snare_verse_notes(
|
||||
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}
|
||||
return _apply_groove({CH_S: notes}, groove_strength)
|
||||
|
||||
|
||||
def snare_fill_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
groove_strength: float = 0.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Busier snare with 16th-note fills: adds positions 2.25 and 3.75.
|
||||
|
||||
@@ -133,20 +154,21 @@ def snare_fill_notes(
|
||||
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}
|
||||
return _apply_groove({CH_S: notes}, groove_strength)
|
||||
|
||||
|
||||
def snare_outro_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
groove_strength: float = 0.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)
|
||||
return snare_verse_notes(bars, velocity_mult=velocity_mult * 0.7, density=density, groove_strength=groove_strength)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -157,6 +179,7 @@ def hihat_16th_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
groove_strength: float = 0.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""16th-note hihat with three-tier accent mapping.
|
||||
|
||||
@@ -177,13 +200,14 @@ def hihat_16th_notes(
|
||||
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}
|
||||
return _apply_groove({CH_H: notes}, groove_strength)
|
||||
|
||||
|
||||
def hihat_8th_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
groove_strength: float = 0.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""8th-note hihat for intro/breakdown.
|
||||
|
||||
@@ -196,17 +220,14 @@ def hihat_8th_notes(
|
||||
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}
|
||||
return _apply_groove({CH_H: notes}, groove_strength)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Clap generator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def clap_24_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
groove_strength: float = 0.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Classic reggaeton clap: beats 2 and 4 → positions 1.0 and 3.0 per bar.
|
||||
|
||||
@@ -218,17 +239,14 @@ def clap_24_notes(
|
||||
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}
|
||||
return _apply_groove({CH_CL: notes}, groove_strength)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Percussion generators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def perc_combo_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
groove_strength: float = 0.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Perc1 + Perc2 offbeat combo (tumba feel).
|
||||
|
||||
@@ -244,13 +262,14 @@ def perc_combo_notes(
|
||||
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}
|
||||
return _apply_groove({CH_P1: p1_notes, CH_P2: p2_notes}, groove_strength)
|
||||
|
||||
|
||||
def rim_build_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
groove_strength: float = 0.0,
|
||||
) -> dict[int, list[dict]]:
|
||||
"""Rim roll that builds intensity across bars (4-bar cycle).
|
||||
|
||||
@@ -278,7 +297,7 @@ def rim_build_notes(
|
||||
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}
|
||||
return _apply_groove({CH_R: notes}, groove_strength)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -305,7 +324,8 @@ def get_notes(
|
||||
bars: int,
|
||||
velocity_mult: float = 1.0,
|
||||
density: float = 1.0,
|
||||
groove_strength: float = 0.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)
|
||||
return gen(bars, velocity_mult, density, groove_strength)
|
||||
|
||||
@@ -18,7 +18,7 @@ import random
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
from ..flp_builder.schema import (
|
||||
from ..core.schema import (
|
||||
ArrangementItemDef,
|
||||
ArrangementTrack,
|
||||
PatternDef,
|
||||
|
||||
Reference in New Issue
Block a user