feat: VST3 preset data, project metadata, plugin registry fixes, and token cleanup
- Add VST3_PRESETS dict with base64 preset data for all 10 plugins (required by REAPER to load VST3) - Fix VST3 registry: correct display names, filenames, and uniqueid GUIDs - Add ~50 lines of REAPER project metadata (PANLAW, SAMPLERATE, METRONOME, etc.) - Add 25 track attributes (PEAKCOL, BEAT, AUTOMODE, etc.) and FX chain metadata - Remove unrecognized tokens (RENDER_CFG, PROJBAY, WAK) that caused REAPER warnings - Update compose.py with section-based arrangement and registry key names - Add SectionDef to schema - 72 tests passing
This commit is contained in:
@@ -1,16 +1,17 @@
|
||||
#!/usr/bin/env python
|
||||
"""Compose a REAPER .rpp project from the sample library.
|
||||
|
||||
Single entrypoint: loads sample index, builds a SongDefinition from the selector/composer,
|
||||
Single entrypoint: loads genre config, builds a SongDefinition from sections,
|
||||
and writes a .rpp file.
|
||||
|
||||
Usage:
|
||||
python scripts/compose.py --genre reggaeton --bpm 95 --key Am
|
||||
python scripts/compose.py --genre trap --bpm 140 --key Cm --output output/my_track.rpp
|
||||
python scripts/compose.py --genre reggaeton --bpm 95 --key Am --output output/my_track.rpp
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -18,8 +19,11 @@ from pathlib import Path
|
||||
_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote
|
||||
from src.composer.rhythm import get_notes
|
||||
from src.core.schema import (
|
||||
SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote,
|
||||
PluginDef, SectionDef,
|
||||
)
|
||||
from src.composer.rhythm import get_notes, GENERATORS as RHYTHM_GENERATORS
|
||||
from src.composer.melodic import bass_tresillo, lead_hook, chords_block, pad_sustain
|
||||
from src.composer.converters import rhythm_to_midi, melodic_to_midi
|
||||
from src.selector import SampleSelector
|
||||
@@ -28,67 +32,298 @@ from src.reaper_builder.render import render_project
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Track builders
|
||||
# VST3 plugin builder helpers (premium plugins)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_drum_track(
|
||||
role: str,
|
||||
generator_name: str,
|
||||
bars: int,
|
||||
) -> TrackDef:
|
||||
"""Build a drum MIDI track from a rhythm generator.
|
||||
# Premium VST3 plugins available:
|
||||
# Serum 2 (Xfer Records), Omnisphere (Spectrasonics)
|
||||
# FabFilter Pro-Q 3, Pro-C 2, Pro-R 2, Pro-L 2, Saturn 2, Timeless 3
|
||||
# The Glue (Cytomic)
|
||||
# Valhalla Delay
|
||||
|
||||
def serum2() -> PluginDef:
|
||||
"""Serum 2 synth — used for bass, lead, harmony tracks."""
|
||||
return PluginDef(
|
||||
name="Serum2",
|
||||
path="Serum2.vst3",
|
||||
index=0,
|
||||
)
|
||||
|
||||
|
||||
def omnisphere() -> PluginDef:
|
||||
"""Omnisphere — used for pad tracks."""
|
||||
return PluginDef(
|
||||
name="Omnisphere",
|
||||
path="Omnisphere.vst3",
|
||||
index=0,
|
||||
)
|
||||
|
||||
|
||||
ROLE_MELODIC_GENERATORS = {
|
||||
"bass": bass_tresillo,
|
||||
"lead": lead_hook,
|
||||
"harmony": chords_block,
|
||||
"pad": pad_sustain,
|
||||
}
|
||||
|
||||
ROLE_RHYTHM_GENERATORS = {
|
||||
"drums": "kick_main_notes",
|
||||
"snare": "snare_verse_notes",
|
||||
"hihat": "hihat_16th_notes",
|
||||
"perc": "perc_combo_notes",
|
||||
}
|
||||
|
||||
# Roles that use audio items per hit instead of MIDI pattern
|
||||
AUDIO_ROLES = {"drums", "snare", "hihat", "perc"}
|
||||
|
||||
# Role → sample key (used for SampleSelector)
|
||||
ROLE_TO_SAMPLE_ROLE = {
|
||||
"drums": "kick",
|
||||
"snare": "snare",
|
||||
"hihat": "hihat",
|
||||
"perc": "perc",
|
||||
"bass": "bass",
|
||||
"lead": "lead",
|
||||
"harmony": "keys",
|
||||
"pad": "pad",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Effect chain builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Mapping of effect names to VST3 plugin entries
|
||||
# Format: (registry_key, filename) tuples
|
||||
# registry_key must match a key in VST3_REGISTRY for _build_plugin() lookup
|
||||
_VST3_EFFECTS: dict[str, tuple[str, str]] = {
|
||||
"Pro-Q 3": ("FabFilter Pro-Q 3", "FabFilter Pro-Q 3.vst3"),
|
||||
"Pro-C 2": ("FabFilter Pro-C 2", "FabFilter Pro-C 2.vst3"),
|
||||
"Pro-R 2": ("FabFilter Pro-R 2", "FabFilter Pro-R 2.vst3"),
|
||||
"Timeless 3": ("FabFilter Timeless 3", "FabFilter Timeless 3.vst3"),
|
||||
"Saturn 2": ("FabFilter Saturn 2", "FabFilter Saturn 2.vst3"),
|
||||
"Pro-L 2": ("FabFilter Pro-L 2", "FabFilter Pro-L 2.vst3"),
|
||||
"The Glue": ("The Glue", "The Glue.vst3"),
|
||||
"Valhalla Delay": ("Valhalla Delay", "ValhallaDelay.vst3"),
|
||||
}
|
||||
|
||||
|
||||
def build_fx_chain(role: str, genre_config: dict, track_plugins: list[PluginDef]) -> list[PluginDef]:
|
||||
"""Build a plugin chain for a role from genre config mix settings.
|
||||
|
||||
Args:
|
||||
role: Track name (e.g. "kick", "snare")
|
||||
generator_name: Name from rhythm.GENERATORS (e.g. "kick_main_notes")
|
||||
bars: Number of bars
|
||||
role: Track role (e.g. "drums", "bass", "lead")
|
||||
genre_config: Loaded genre JSON dict
|
||||
track_plugins: Already-added plugins (instruments) to skip
|
||||
|
||||
Returns:
|
||||
List of PluginDef for the FX chain (effects only, no instruments).
|
||||
"""
|
||||
note_dict = get_notes(generator_name, bars)
|
||||
midi_notes = rhythm_to_midi(note_dict)
|
||||
clip = ClipDef(
|
||||
position=0.0,
|
||||
length=bars * 4.0,
|
||||
name=f"{role.capitalize()} Pattern",
|
||||
midi_notes=midi_notes,
|
||||
mix = genre_config.get("mix", {})
|
||||
per_role = mix.get("per_role", {}).get(role, {})
|
||||
|
||||
plugins: list[PluginDef] = []
|
||||
effects = per_role.get("effects", [])
|
||||
for idx, effect_name in enumerate(effects):
|
||||
key = effect_name
|
||||
# Normalize Fruity* aliases
|
||||
if key == "Fruity Parametric EQ 2":
|
||||
key = "Pro-Q 3"
|
||||
elif key == "Fruity Compressor":
|
||||
key = "Pro-C 2"
|
||||
elif key == "Fruity Delay 3":
|
||||
key = "Timeless 3"
|
||||
elif key == "Fruity Reverb 2":
|
||||
key = "Pro-R 2"
|
||||
|
||||
vst3_info = _VST3_EFFECTS.get(key)
|
||||
if vst3_info:
|
||||
registry_key, filename = vst3_info
|
||||
plugins.append(PluginDef(
|
||||
name=registry_key,
|
||||
path=filename,
|
||||
index=idx,
|
||||
))
|
||||
|
||||
return plugins
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Return track builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_return_tracks() -> list[TrackDef]:
|
||||
"""Create reverb and delay return tracks.
|
||||
|
||||
Returns:
|
||||
[Reverb return TrackDef (FabFilter Pro-R 2),
|
||||
Delay return TrackDef (FabFilter Timeless 3)]
|
||||
"""
|
||||
reverb_track = TrackDef(
|
||||
name="Reverb",
|
||||
volume=0.7,
|
||||
pan=0.0,
|
||||
clips=[],
|
||||
plugins=[PluginDef(
|
||||
name="FabFilter Pro-R 2",
|
||||
path="FabFilter_Pro_R_2.vst3",
|
||||
index=0,
|
||||
)],
|
||||
send_reverb=0.0,
|
||||
send_delay=0.0,
|
||||
)
|
||||
return TrackDef(name=role.capitalize(), clips=[clip])
|
||||
delay_track = TrackDef(
|
||||
name="Delay",
|
||||
volume=0.7,
|
||||
pan=0.0,
|
||||
clips=[],
|
||||
plugins=[PluginDef(
|
||||
name="FabFilter Timeless 3",
|
||||
path="FabFilter_Timeless_3.vst3",
|
||||
index=0,
|
||||
)],
|
||||
send_reverb=0.0,
|
||||
send_delay=0.0,
|
||||
)
|
||||
return [reverb_track, delay_track]
|
||||
|
||||
|
||||
def build_melodic_track(
|
||||
role: str,
|
||||
generator_fn,
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section track builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_section_tracks(
|
||||
genre_config: dict,
|
||||
selector: SampleSelector,
|
||||
key: str,
|
||||
bpm: float,
|
||||
bars: int,
|
||||
selector: SampleSelector | None = None,
|
||||
) -> TrackDef:
|
||||
"""Build a melodic MIDI track from a generator function.
|
||||
) -> tuple[list[TrackDef], list[SectionDef]]:
|
||||
"""Build all tracks from genre config sections.
|
||||
|
||||
Creates one set of tracks per role, with clips per section placed at
|
||||
cumulative bar offsets. Applies section energy via velocity_mult and vol_mult.
|
||||
|
||||
Args:
|
||||
role: Track name (e.g. "bass", "lead")
|
||||
generator_fn: Callable from melodic.py (e.g. bass_tresillo)
|
||||
genre_config: Loaded genre JSON dict
|
||||
selector: SampleSelector for sample queries
|
||||
key: Musical key (e.g. "Am")
|
||||
bpm: Tempo for sample selection
|
||||
bars: Number of bars
|
||||
selector: Optional SampleSelector; if provided, sets audio_path on ClipDef
|
||||
bpm: BPM for sample selection
|
||||
|
||||
Returns:
|
||||
(tracks, sections)
|
||||
"""
|
||||
note_list = generator_fn(key=key, bars=bars)
|
||||
midi_notes = melodic_to_midi(note_list)
|
||||
structure = genre_config.get("structure", {})
|
||||
sections_raw = structure.get("sections", [])
|
||||
roles = genre_config.get("roles", {})
|
||||
|
||||
audio_path: str | None = None
|
||||
if selector is not None:
|
||||
match = selector.select_one(role=role, key=key, bpm=bpm)
|
||||
if match:
|
||||
audio_path = match.get("original_path", None)
|
||||
# Parse sections into SectionDef list
|
||||
sections: list[SectionDef] = []
|
||||
for s in sections_raw:
|
||||
sections.append(SectionDef(
|
||||
name=s.get("name", "unknown"),
|
||||
bars=s.get("bars", 4),
|
||||
energy=s.get("energy", 0.5),
|
||||
))
|
||||
|
||||
clip = ClipDef(
|
||||
position=0.0,
|
||||
length=bars * 4.0,
|
||||
name=f"{role.capitalize()} MIDI",
|
||||
audio_path=audio_path,
|
||||
midi_notes=midi_notes,
|
||||
)
|
||||
return TrackDef(name=role.capitalize(), clips=[clip])
|
||||
# Compute cumulative bar offsets for section positions
|
||||
section_offsets: list[float] = []
|
||||
offset = 0.0
|
||||
for sec in sections:
|
||||
section_offsets.append(offset)
|
||||
offset += sec.bars
|
||||
|
||||
# Build one track per role
|
||||
tracks: list[TrackDef] = []
|
||||
|
||||
for role, role_cfg in roles.items():
|
||||
sample_role = ROLE_TO_SAMPLE_ROLE.get(role, role)
|
||||
generator_name = role_cfg.get("notes_template", "")
|
||||
|
||||
# Select sample for this role
|
||||
sample_match = selector.select_one(role=sample_role, key=key, bpm=bpm)
|
||||
sample_path = sample_match.get("original_path") if sample_match else None
|
||||
|
||||
# Collect clips for each section
|
||||
section_clips: list[ClipDef] = []
|
||||
|
||||
for sec_idx, (section, sec_offset) in enumerate(zip(sections, section_offsets)):
|
||||
# Derive velocity and volume multipliers from section energy
|
||||
vel_mult = section.energy
|
||||
vol_mult = section.energy
|
||||
|
||||
if role in ROLE_RHYTHM_GENERATORS:
|
||||
gen_name = ROLE_RHYTHM_GENERATORS[role]
|
||||
note_dict = get_notes(gen_name, section.bars, velocity_mult=vel_mult)
|
||||
|
||||
# Audio roles: one clip per hit (one-shot samples placed at beat positions)
|
||||
if role in AUDIO_ROLES:
|
||||
for bar_offset, bar_notes in note_dict.items():
|
||||
for note_data in bar_notes:
|
||||
note_pos = note_data.get("pos", 0.0)
|
||||
audio_clip = ClipDef(
|
||||
position=sec_offset * 4.0 + bar_offset * 4.0 + note_pos,
|
||||
length=0.5, # one-shot duration
|
||||
name=f"{section.name.capitalize()} {role.capitalize()}",
|
||||
audio_path=sample_path,
|
||||
)
|
||||
section_clips.append(audio_clip)
|
||||
else:
|
||||
# MIDI roles: single clip with all notes
|
||||
midi_notes = rhythm_to_midi(note_dict)
|
||||
clip = ClipDef(
|
||||
position=sec_offset * 4.0, # bars → beats
|
||||
length=section.bars * 4.0,
|
||||
name=f"{section.name.capitalize()} {role.capitalize()}",
|
||||
midi_notes=midi_notes,
|
||||
)
|
||||
section_clips.append(clip)
|
||||
elif role in ROLE_MELODIC_GENERATORS:
|
||||
gen_fn = ROLE_MELODIC_GENERATORS[role]
|
||||
note_list = gen_fn(key=key, bars=section.bars, velocity_mult=vel_mult)
|
||||
midi_notes = melodic_to_midi(note_list)
|
||||
clip = ClipDef(
|
||||
position=sec_offset * 4.0,
|
||||
length=section.bars * 4.0,
|
||||
name=f"{section.name.capitalize()} {role.capitalize()}",
|
||||
midi_notes=midi_notes,
|
||||
audio_path=sample_path,
|
||||
)
|
||||
section_clips.append(clip)
|
||||
|
||||
if not section_clips:
|
||||
continue
|
||||
|
||||
# Build plugins: instrument (if melodic) + FX chain
|
||||
plugins: list[PluginDef] = []
|
||||
|
||||
# Melodic tracks get instrument plugins (Serum 2 or Omnisphere)
|
||||
if role in ("bass", "lead", "harmony"):
|
||||
plugins.append(serum2())
|
||||
elif role == "pad":
|
||||
plugins.append(omnisphere())
|
||||
|
||||
# FX chain from genre config (effects only, instruments already added above)
|
||||
fx_chain = build_fx_chain(role, genre_config, plugins)
|
||||
plugins.extend(fx_chain)
|
||||
|
||||
# Send levels from per_role config
|
||||
per_role_cfg = genre_config.get("mix", {}).get("per_role", {}).get(role, {})
|
||||
send_reverb = 0.3 if per_role_cfg.get("reverb_on_lead") or per_role_cfg.get("reverb_on_snare") else 0.0
|
||||
send_delay = 0.0
|
||||
|
||||
track = TrackDef(
|
||||
name=role.capitalize(),
|
||||
volume=0.85 * vol_mult,
|
||||
pan=0.0,
|
||||
color=0,
|
||||
clips=section_clips,
|
||||
plugins=plugins,
|
||||
send_reverb=send_reverb,
|
||||
send_delay=send_delay,
|
||||
)
|
||||
tracks.append(track)
|
||||
|
||||
return tracks, sections
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -97,7 +332,7 @@ def build_melodic_track(
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compose a REAPER .rpp project from the sample library."
|
||||
description="Compose a REAPER .rpp project from the genre config."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--genre",
|
||||
@@ -107,8 +342,8 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"--bpm",
|
||||
type=float,
|
||||
default=95.0,
|
||||
help="BPM (default: 95)",
|
||||
default=96.0,
|
||||
help="BPM (default: 96)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key",
|
||||
@@ -128,11 +363,11 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"--render-output",
|
||||
default=None,
|
||||
help="Output WAV path for rendering. Defaults to <output>.wav with .rpp extension replaced.",
|
||||
help="Output WAV path for rendering.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate BPM before any writes
|
||||
# Validate BPM
|
||||
if args.bpm <= 0:
|
||||
raise ValueError(f"bpm must be > 0, got {args.bpm}")
|
||||
|
||||
@@ -140,7 +375,16 @@ def main() -> None:
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load sample index (for melodic tracks that use audio samples)
|
||||
# Load genre config
|
||||
genre_path = _ROOT / "knowledge" / "genres" / f"{args.genre.lower()}_2009.json"
|
||||
if not genre_path.exists():
|
||||
print(f"ERROR: genre config not found at {genre_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with open(genre_path, "r", encoding="utf-8") as f:
|
||||
genre_config = json.load(f)
|
||||
|
||||
# Load sample index
|
||||
index_path = _ROOT / "data" / "sample_index.json"
|
||||
if not index_path.exists():
|
||||
print(f"ERROR: sample index not found at {index_path}", file=sys.stderr)
|
||||
@@ -148,42 +392,32 @@ def main() -> None:
|
||||
|
||||
selector = SampleSelector(str(index_path))
|
||||
|
||||
# Determine bar count from genre
|
||||
genre_bar_map = {
|
||||
"reggaeton": 64,
|
||||
"trap": 32,
|
||||
"house": 64,
|
||||
"drill": 32,
|
||||
}
|
||||
bar_count = genre_bar_map.get(args.genre.lower(), 48)
|
||||
# Build tracks and sections from genre config
|
||||
tracks, sections = build_section_tracks(genre_config, selector, args.key, args.bpm)
|
||||
|
||||
# Build drum tracks (no selector needed)
|
||||
drum_tracks = [
|
||||
build_drum_track("kick", "kick_main_notes", bar_count),
|
||||
build_drum_track("snare", "snare_verse_notes", bar_count),
|
||||
build_drum_track("hihat", "hihat_16th_notes", bar_count),
|
||||
build_drum_track("perc", "perc_combo_notes", bar_count),
|
||||
]
|
||||
# Create return tracks
|
||||
return_tracks = create_return_tracks()
|
||||
|
||||
# Build melodic tracks (selector passed only to bass)
|
||||
melodic_tracks = [
|
||||
build_melodic_track("bass", bass_tresillo, args.key, args.bpm, bar_count, selector),
|
||||
build_melodic_track("lead", lead_hook, args.key, args.bpm, bar_count),
|
||||
build_melodic_track("chords", chords_block, args.key, args.bpm, bar_count),
|
||||
build_melodic_track("pad", pad_sustain, args.key, args.bpm, bar_count),
|
||||
]
|
||||
# Assemble SongDefinition
|
||||
meta = SongMeta(
|
||||
bpm=args.bpm,
|
||||
key=args.key,
|
||||
title=f"{genre_config.get('display_name', args.genre.capitalize())}",
|
||||
time_sig_num=genre_config.get("time_signature", [4, 4])[0],
|
||||
time_sig_den=genre_config.get("time_signature", [4, 4])[1],
|
||||
ppq=genre_config.get("ppq", 96),
|
||||
)
|
||||
|
||||
# Assemble full track list
|
||||
all_tracks = drum_tracks + melodic_tracks
|
||||
|
||||
# Build SongDefinition
|
||||
meta = SongMeta(bpm=args.bpm, key=args.key, title=f"{args.genre.capitalize()} Track")
|
||||
song = SongDefinition(meta=meta, tracks=all_tracks)
|
||||
song = SongDefinition(
|
||||
meta=meta,
|
||||
tracks=tracks + return_tracks,
|
||||
sections=sections,
|
||||
)
|
||||
|
||||
# Validate
|
||||
errors = song.validate()
|
||||
if errors:
|
||||
print(f"WARNING: SongDefinition has validation errors:", file=sys.stderr)
|
||||
print("WARNING: SongDefinition has validation errors:", file=sys.stderr)
|
||||
for e in errors:
|
||||
print(f" - {e}", file=sys.stderr)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user