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:
renato97
2026-05-03 14:00:11 -03:00
parent af6d61c8a1
commit 53741b48b6
6 changed files with 1541 additions and 141 deletions

View File

@@ -1,16 +1,17 @@
#!/usr/bin/env python #!/usr/bin/env python
"""Compose a REAPER .rpp project from the sample library. """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. and writes a .rpp file.
Usage: Usage:
python scripts/compose.py --genre reggaeton --bpm 95 --key Am 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 from __future__ import annotations
import argparse import argparse
import json
import sys import sys
from pathlib import Path from pathlib import Path
@@ -18,8 +19,11 @@ from pathlib import Path
_ROOT = Path(__file__).parent.parent _ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(_ROOT)) sys.path.insert(0, str(_ROOT))
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote from src.core.schema import (
from src.composer.rhythm import get_notes 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.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.selector import SampleSelector 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( # Premium VST3 plugins available:
role: str, # Serum 2 (Xfer Records), Omnisphere (Spectrasonics)
generator_name: str, # FabFilter Pro-Q 3, Pro-C 2, Pro-R 2, Pro-L 2, Saturn 2, Timeless 3
bars: int, # The Glue (Cytomic)
) -> TrackDef: # Valhalla Delay
"""Build a drum MIDI track from a rhythm generator.
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: Args:
role: Track name (e.g. "kick", "snare") role: Track role (e.g. "drums", "bass", "lead")
generator_name: Name from rhythm.GENERATORS (e.g. "kick_main_notes") genre_config: Loaded genre JSON dict
bars: Number of bars 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) mix = genre_config.get("mix", {})
midi_notes = rhythm_to_midi(note_dict) per_role = mix.get("per_role", {}).get(role, {})
clip = ClipDef(
position=0.0, plugins: list[PluginDef] = []
length=bars * 4.0, effects = per_role.get("effects", [])
name=f"{role.capitalize()} Pattern", for idx, effect_name in enumerate(effects):
midi_notes=midi_notes, 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, # Section track builder
generator_fn, # ---------------------------------------------------------------------------
def build_section_tracks(
genre_config: dict,
selector: SampleSelector,
key: str, key: str,
bpm: float, bpm: float,
bars: int, ) -> tuple[list[TrackDef], list[SectionDef]]:
selector: SampleSelector | None = None, """Build all tracks from genre config sections.
) -> TrackDef:
"""Build a melodic MIDI track from a generator function. 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: Args:
role: Track name (e.g. "bass", "lead") genre_config: Loaded genre JSON dict
generator_fn: Callable from melodic.py (e.g. bass_tresillo) selector: SampleSelector for sample queries
key: Musical key (e.g. "Am") key: Musical key (e.g. "Am")
bpm: Tempo for sample selection bpm: BPM for sample selection
bars: Number of bars
selector: Optional SampleSelector; if provided, sets audio_path on ClipDef Returns:
(tracks, sections)
""" """
note_list = generator_fn(key=key, bars=bars) structure = genre_config.get("structure", {})
midi_notes = melodic_to_midi(note_list) sections_raw = structure.get("sections", [])
roles = genre_config.get("roles", {})
audio_path: str | None = None # Parse sections into SectionDef list
if selector is not None: sections: list[SectionDef] = []
match = selector.select_one(role=role, key=key, bpm=bpm) for s in sections_raw:
if match: sections.append(SectionDef(
audio_path = match.get("original_path", None) name=s.get("name", "unknown"),
bars=s.get("bars", 4),
energy=s.get("energy", 0.5),
))
# 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( clip = ClipDef(
position=0.0, position=sec_offset * 4.0, # bars → beats
length=bars * 4.0, length=section.bars * 4.0,
name=f"{role.capitalize()} MIDI", name=f"{section.name.capitalize()} {role.capitalize()}",
audio_path=audio_path,
midi_notes=midi_notes, midi_notes=midi_notes,
) )
return TrackDef(name=role.capitalize(), clips=[clip]) 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: def main() -> None:
parser = argparse.ArgumentParser( 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( parser.add_argument(
"--genre", "--genre",
@@ -107,8 +342,8 @@ def main() -> None:
parser.add_argument( parser.add_argument(
"--bpm", "--bpm",
type=float, type=float,
default=95.0, default=96.0,
help="BPM (default: 95)", help="BPM (default: 96)",
) )
parser.add_argument( parser.add_argument(
"--key", "--key",
@@ -128,11 +363,11 @@ def main() -> None:
parser.add_argument( parser.add_argument(
"--render-output", "--render-output",
default=None, 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() args = parser.parse_args()
# Validate BPM before any writes # Validate BPM
if args.bpm <= 0: if args.bpm <= 0:
raise ValueError(f"bpm must be > 0, got {args.bpm}") raise ValueError(f"bpm must be > 0, got {args.bpm}")
@@ -140,7 +375,16 @@ def main() -> None:
output_path = Path(args.output) output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True) 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" index_path = _ROOT / "data" / "sample_index.json"
if not index_path.exists(): if not index_path.exists():
print(f"ERROR: sample index not found at {index_path}", file=sys.stderr) 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)) selector = SampleSelector(str(index_path))
# Determine bar count from genre # Build tracks and sections from genre config
genre_bar_map = { tracks, sections = build_section_tracks(genre_config, selector, args.key, args.bpm)
"reggaeton": 64,
"trap": 32,
"house": 64,
"drill": 32,
}
bar_count = genre_bar_map.get(args.genre.lower(), 48)
# Build drum tracks (no selector needed) # Create return tracks
drum_tracks = [ return_tracks = create_return_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),
]
# Build melodic tracks (selector passed only to bass) # Assemble SongDefinition
melodic_tracks = [ meta = SongMeta(
build_melodic_track("bass", bass_tresillo, args.key, args.bpm, bar_count, selector), bpm=args.bpm,
build_melodic_track("lead", lead_hook, args.key, args.bpm, bar_count), key=args.key,
build_melodic_track("chords", chords_block, args.key, args.bpm, bar_count), title=f"{genre_config.get('display_name', args.genre.capitalize())}",
build_melodic_track("pad", pad_sustain, args.key, args.bpm, bar_count), 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 song = SongDefinition(
all_tracks = drum_tracks + melodic_tracks meta=meta,
tracks=tracks + return_tracks,
# Build SongDefinition sections=sections,
meta = SongMeta(bpm=args.bpm, key=args.key, title=f"{args.genre.capitalize()} Track") )
song = SongDefinition(meta=meta, tracks=all_tracks)
# Validate # Validate
errors = song.validate() errors = song.validate()
if errors: 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: for e in errors:
print(f" - {e}", file=sys.stderr) print(f" - {e}", file=sys.stderr)

View File

@@ -170,6 +170,25 @@ class TrackDef:
send_delay: float = 0.0 send_delay: float = 0.0
@dataclass
class SectionDef:
"""A section in the song arrangement with energy and dynamics.
Attributes:
name: Display name (e.g. "intro", "chorus", "verse")
bars: Length in bars
energy: Energy level 0.01.0 (controls velocity multiplier)
velocity_mult: Velocity multiplier applied to all notes in section
vol_mult: Volume multiplier applied to track in section
"""
name: str
bars: int
energy: float = 0.5
velocity_mult: float = 1.0
vol_mult: float = 1.0
@dataclass @dataclass
class SongDefinition: class SongDefinition:
"""Complete song definition — the source of truth for one .rpp file. """Complete song definition — the source of truth for one .rpp file.
@@ -184,6 +203,7 @@ class SongDefinition:
progression_name: Chord progression name (e.g. "i-VII-VI-VII") progression_name: Chord progression name (e.g. "i-VII-VI-VII")
section_template: Section template name (default "standard") section_template: Section template name (default "standard")
samples: Sample file map (name → filename) samples: Sample file map (name → filename)
sections: Section definitions in playback order
""" """
meta: SongMeta meta: SongMeta
@@ -193,6 +213,7 @@ class SongDefinition:
progression_name: str = "i-VII-VI-VII" progression_name: str = "i-VII-VI-VII"
section_template: str = "standard" section_template: str = "standard"
samples: dict[str, str] = field(default_factory=dict) samples: dict[str, str] = field(default_factory=dict)
sections: list[SectionDef] = field(default_factory=list)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Validation # Validation

View File

@@ -14,6 +14,168 @@ from rpp import Element, dumps
from ..core.schema import SongDefinition, TrackDef, ClipDef, PluginDef from ..core.schema import SongDefinition, TrackDef, ClipDef, PluginDef
# ---------------------------------------------------------------------------
# Ground truth constants from output/test_vst3.rpp
# ---------------------------------------------------------------------------
#: Lines 2-92 from test_vst3.rpp — static project metadata.
#: TEMPO (line 69) is replaced dynamically in _build_element().
#: Parent elements (<NOTES>, <METRONOME>, etc.) include their children directly.
#: Plain attribute lines are simple lists.
_PROJECT_HEADER: list[list[str] | Element] = [
Element("NOTES", ["0", "2"]),
[],
["RIPPLE", "0", "0"],
["GROUPOVERRIDE", "0", "0", "0", "0"],
["AUTOXFADE", "129"],
["ENVATTACH", "3"],
["POOLEDENVATTACH", "0"],
["TCPUIFLAGS", "0"],
["MIXERUIFLAGS", "11", "48"],
["ENVFADESZ10", "40"],
["PEAKGAIN", "1"],
["FEEDBACK", "0"],
["PANLAW", "1"],
["PROJOFFS", "0", "0", "0"],
["MAXPROJLEN", "0", "0"],
["GRID", "3199", "8", "1", "8", "1", "0", "0", "0"],
["TIMEMODE", "1", "5", "-1", "30", "0", "0", "-1", "0"],
["VIDEO_CONFIG", "0", "0", "65792"],
["PANMODE", "3"],
["PANLAWFLAGS", "3"],
["CURSOR", "0"],
["ZOOM", "100", "0", "0"],
["VZOOMEX", "6", "0"],
["USE_REC_CFG", "0"],
["RECMODE", "1"],
["SMPTESYNC", "0", "30", "100", "40", "1000", "300", "0", "0", "1", "0", "0"],
["LOOP", "0"],
["LOOPGRAN", "0", "4"],
["RECORD_PATH", '"Media"', '""'],
Element("RECORD_CFG", [], children=["ZXZhdxgAAQ=="]),
[],
Element("APPLYFX_CFG", [], children=[]),
[],
["RENDER_FILE", '""'],
["RENDER_PATTERN", '""'],
["RENDER_FMT", "0", "2", "0"],
["RENDER_1X", "0"],
["RENDER_RANGE", "1", "0", "0", "0", "1000"],
["RENDER_RESAMPLE", "3", "0", "1"],
["RENDER_ADDTOPROJ", "0"],
["RENDER_STEMS", "0"],
["RENDER_DITHER", "0"],
["RENDER_TRIM", "0.000001", "0.000001", "0", "0"],
["TIMELOCKMODE", "1"],
["TEMPOENVLOCKMODE", "1"],
["ITEMMIX", "1"],
["DEFPITCHMODE", "589824", "0"],
["TAKELANE", "1"],
["SAMPLERATE", "44100", "0", "0"],
[],
["LOCK", "1"],
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"],
]),
[],
["GLOBAL_AUTO", "-1"],
# TEMPO line is injected dynamically — do not include static entry
["PLAYRATE", "1", "0", "0.25", "4"],
["SELECTION", "0", "0"],
["SELECTION2", "0", "0"],
["MASTERAUTOMODE", "0"],
["MASTERTRACKHEIGHT", "0", "0"],
["MASTERPEAKCOL", "16576"],
["MASTERMUTESOLO", "0"],
["MASTERTRACKVIEW", "0", "0.6667", "0.5", "0.5", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0", "0"],
["MASTERHWOUT", "0", "0", "1", "0", "0", "0", "0", "-1"],
["MASTER_NCH", "2", "2"],
["MASTER_VOLUME", "1", "0", "-1", "-1", "1"],
["MASTER_PANMODE", "3"],
["MASTER_PANLAWFLAGS", "3"],
["MASTER_FX", "1"],
["MASTER_SEL", "0"],
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"],
]),
[],
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"],
]),
[],
["RULERHEIGHT", "86", "86"],
["RULERLANE", "1", "4", "", "0", "-1"],
["RULERLANE", "2", "8", "", "0", "-1"],
[],
]
#: Default attributes for every TRACK — from test_vst3.rpp lines 108-131.
_TRACK_DEFAULTS: list[list[str]] = [
["PEAKCOL", "16576"],
["BEAT", "-1"],
["AUTOMODE", "0"],
["PANLAWFLAGS", "3"],
["VOLPAN", "1", "0", "-1", "-1", "1"],
["MUTESOLO", "0", "0", "0"],
["IPHASE", "0"],
["PLAYOFFS", "0", "1"],
["ISBUS", "0", "0"],
["BUSCOMP", "0", "0", "0", "0", "0"],
["SHOWINMIX", "1", "0.6667", "0.5", "1", "0.5", "0", "0", "0", "0"],
["FIXEDLANES", "9", "0", "0", "0", "0"],
["LANEREC", "-1", "-1", "-1", "0"],
["SEL", "0"],
["REC", "0", "0", "1", "0", "0", "0", "0", "0"],
["VU", "64"],
["TRACKHEIGHT", "0", "0", "0", "0", "0", "0", "0"],
["INQ", "0", "0", "0", "0.5", "100", "0", "0", "100"],
["NCHAN", "2"],
["FX", "1"],
["TRACKID", ""], # filled dynamically with same GUID as TRACK opening
["PERF", "0"],
["MIDIOUT", "-1"],
["MAINSEND", "1", "0"],
]
#: FXCHAIN header metadata — from test_vst3.rpp lines 133-137 and 159-162.
_FXCHAIN_HEADER: list[list[str]] = [
["WNDRECT", "24", "52", "655", "408"],
["SHOW", "0"],
["LASTSEL", "0"],
["DOCKED", "0"],
["BYPASS", "0", "0", "0"],
]
#: FXCHAIN footer metadata — from test_vst3.rpp lines 159-162.
_FXCHAIN_FOOTER: list[list[str]] = [
["PRESETNAME", "Program 1"],
["FLOATPOS", "0", "0", "0", "0"],
["FXID", ""], # filled dynamically
]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -23,6 +185,40 @@ def _make_guid() -> str:
return str(uuid.uuid4()).upper() return str(uuid.uuid4()).upper()
def vst3_element(display_name: str, filename: str, uid_guid: str = "", preset_data: list[str] | None = None) -> Element:
"""Build a VST3 Element for REAPER .rpp.
REAPER format (from real .rpp files):
<VST "VST3: PluginName (Vendor)" filename.vst3 0 "" uniqueid{GUID} "">
preset_line_1
preset_line_2
...
The uniqueid{GUID} is extracted from REAPER's reaper-vstplugins64.ini:
Filename.vst3=hash,uniqueid{GUID,DisplayName!!!Type}
REAPER REQUIRES base64 preset data inside VST blocks for VST3 plugins to load.
Without preset data, plugins show as "not available" even with correct name/filename/GUID.
Args:
display_name: Full REAPER display name, e.g. "VST3: Serum 2 (Xfer Records)"
filename: Plugin filename, e.g. "Serum2.vst3"
uid_guid: uniqueid{GUID} string from REAPER scan, e.g. "691258006{56534558...}"
preset_data: Optional list of base64 preset lines to include as children.
Returns:
Element('VST', [display_name, filename, '0', '', uid_guid, '']) with preset lines as children
"""
if uid_guid:
elem = Element("VST", [display_name, filename, "0", "", uid_guid, ""])
else:
elem = Element("VST", [display_name, filename, "0", ""])
if preset_data:
for line in preset_data:
elem.append(line)
return elem
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# RPPBuilder # RPPBuilder
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -47,23 +243,51 @@ class RPPBuilder:
OSError: If the file cannot be written. OSError: If the file cannot be written.
""" """
root = self._build_element() root = self._build_element()
content = dumps(root)
# CRITICAL 1: quote the version string in the header
# rpp library produces <REAPER_PROJECT 0.1 7.65/win64 ...> but REAPER needs quotes
content = content.replace('<REAPER_PROJECT 0.1 7.65/win64', '<REAPER_PROJECT 0.1 "7.65/win64"')
p = Path(path) p = Path(path)
p.write_text(dumps(root), encoding="utf-8") p.write_text(content, encoding="utf-8")
def _build_element(self) -> Element: def _build_element(self) -> Element:
"""Build the Element tree for the .rpp file.""" """Build the Element tree for the .rpp file."""
m = self.song.meta m = self.song.meta
# Project root # Project root — version from test_vst3.rpp line 1
root = Element("REAPER_PROJECT", ["0.1", "6.0", str(int(uuid.uuid4().time))]) root = Element("REAPER_PROJECT", ["0.1", "7.65/win64", str(int(uuid.uuid4().time)), "0"])
# TEMPO is a flat attribute line, NOT a child element # Add all static project header lines
for line in _PROJECT_HEADER:
if line is not None: # preserve all Elements (even empty) and non-empty lists
root.append(line)
# TEMPO is injected dynamically (overrides static header)
root.append(["TEMPO", str(m.bpm), str(m.time_sig_num), str(m.time_sig_den)]) root.append(["TEMPO", str(m.bpm), str(m.time_sig_num), str(m.time_sig_den)])
# Master track # Master track
master = Element("TRACK", [_make_guid()]) master_guid = _make_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"])
for line in _TRACK_DEFAULTS:
if line:
defaults_copy = [v for v in line]
if defaults_copy[0] == "TRACKID":
defaults_copy[1] = f"{{{master_guid}}}"
master.append(defaults_copy)
# Master track FXCHAIN (MASTER_FX 1 requires FXCHAIN)
master_fxchain = Element("FXCHAIN", [])
for line in _FXCHAIN_HEADER:
master_fxchain.append([v for v in line])
for line in _FXCHAIN_FOOTER:
if line:
footer_copy = [v for v in line]
if footer_copy[0] == "FXID":
footer_copy[1] = f"{{{_make_guid()}}}"
master_fxchain.append(footer_copy)
master.append(master_fxchain)
root.append(master) root.append(master)
# User tracks # User tracks
@@ -73,35 +297,425 @@ class RPPBuilder:
return root return root
def _build_track(self, track: TrackDef) -> Element: def _build_track(self, track: TrackDef) -> Element:
"""Build a TRACK Element.""" """Build a TRACK Element with all default attributes from test_vst3.rpp."""
track_elem = Element("TRACK", [_make_guid()]) track_guid = _make_guid()
track_elem = Element("TRACK", [f"{{{track_guid}}}"])
track_elem.append(["NAME", track.name]) track_elem.append(["NAME", track.name])
# Default attributes
for line in _TRACK_DEFAULTS:
if line:
defaults_copy = [v for v in line]
if defaults_copy[0] == "TRACKID":
defaults_copy[1] = f"{{{track_guid}}}"
elif defaults_copy[0] == "VOLPAN":
vol = track.volume vol = track.volume
pan = track.pan pan = track.pan
track_elem.append([f"VOLPAN", f"{vol:.6f}", f"{pan:.6f}", "-1", "-1", "1"]) defaults_copy = [f"VOLPAN", f"{vol:.6f}", f"{pan:.6f}", "-1", "-1", "1"]
elif defaults_copy[0] == "SEL":
defaults_copy = ["SEL", "1"] # user track is selected by default
track_elem.append(defaults_copy)
if track.color != 0: # Override NCHAN based on track configuration
track_elem.append(["COLOR", str(track.color)]) # Find and update NCHAN if already set
nchan_found = False
for i, child in enumerate(track_elem.children):
if isinstance(child, list) and child[0] == "NCHAN":
child[1] = "2"
nchan_found = True
break
# Plugins (FXCHAIN) # Plugins (FXCHAIN) — wrap VST elements inside proper FXCHAIN structure
if track.plugins: if track.plugins:
fxchain = Element("FXCHAIN", []) fxchain = Element("FXCHAIN", [])
for line in _FXCHAIN_HEADER:
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()
fxchain.append(["PRESETNAME", "Program 1"])
fxchain.append(["FLOATPOS", "0", "0", "0", "0"])
fxchain.append(["FXID", f"{{{fxid_guid}}}"])
track_elem.append(fxchain) track_elem.append(fxchain)
# Send effects
if track.send_reverb > 0:
track_elem.append(["AUXRECV", "0", f"{track.send_reverb:.6f}", "-1", "-1", "0"])
if track.send_delay > 0:
track_elem.append(["AUXRECV", "1", f"{track.send_delay:.6f}", "-1", "-1", "0"])
# Clips (items) # Clips (items)
for clip in track.clips: for clip in track.clips:
track_elem.append(self._build_clip(clip)) track_elem.append(self._build_clip(clip))
return track_elem return track_elem
# VST3 plugin registry: short name → (display_name, filename_on_disk, uid{GUID})
# display_name and uid{GUID} from REAPER's reaper-vstplugins64.ini scan.
# filename_on_disk is the ACTUAL .vst3 filename as it exists on disk
# (with spaces, matching what REAPER writes in .rpp files).
VST3_REGISTRY: dict[str, tuple[str, str, str]] = {
"Serum2": (
"VST3: Serum 2 (Xfer Records)",
"Serum2.vst3",
"691258006{56534558667350736572756D20320000}",
),
"Omnisphere": (
"VST3: Omnisphere (Spectrasonics)",
"Omnisphere.vst3",
"103502701{84E8DE5F9255222296FAE4133C935A18}",
),
"FabFilter Pro-Q 3": (
"VST3: Pro-Q 3 (FabFilter)",
"FabFilter Pro-Q 3.vst3",
"756089518{72C4DB717A4D459AB97E51745D84B39D}",
),
"FabFilter Pro-C 2": (
"VST3: Pro-C 2 (FabFilter)",
"FabFilter Pro-C 2.vst3",
"1000537396{79F415E3C8E74807AD5DA3CF7024F618}",
),
"FabFilter Pro-R 2": (
"VST3: Pro-R 2 (FabFilter)",
"FabFilter Pro-R 2.vst3",
"585842631{6070873C802A4B078FC06AB5459154E9}",
),
"FabFilter Pro-L 2": (
"VST3: Pro-L 2 (FabFilter)",
"FabFilter Pro-L 2.vst3",
"1938458649{AFD92F729A0447B7B5E8D1D568DEA985}",
),
"FabFilter Saturn 2": (
"VST3: Saturn 2 (FabFilter)",
"FabFilter Saturn 2.vst3",
"1437095695{8D067533D8A0491DBAA36C064C6ABBFB}",
),
"FabFilter Timeless 3": (
"VST3: Timeless 3 (FabFilter)",
"FabFilter Timeless 3.vst3",
"2123585227{D2EE67F2C552402D902115931AFDAE6B}",
),
"The Glue": (
"VST3: The Glue (Cytomic)",
"The Glue.vst3",
"336504517{5653544379546774686520676C756500}",
),
"Valhalla Delay": (
"VST3: ValhallaDelay (Valhalla DSP, LLC)",
"ValhallaDelay.vst3",
"1674641571{565354644C617976616C68616C6C6164}",
),
}
# VST3 preset data — base64-encoded state blocks for each plugin.
# REAPER REQUIRES these preset lines inside VST blocks for VST3 plugins to load.
# Without preset data, plugins show as "not available" even with correct name/filename/GUID.
VST3_PRESETS: dict[str, list[str]] = {
"Serum2": [
"Z4R+ae5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAbQgAAAEAAAAAAAAA",
"zQQAAAEAAABYZmVySnNvbgC5AAAAAAAAAHsiY29tcG9uZW50IjoicHJvY2Vzc29yIiwiaGFzaCI6IjgxZTEyMWYxNGI2Y2IyYjA2YzMzMjQzZDk1ZDIxYWIxIiwicHJv",
"ZHVjdCI6IlNlcnVtMkZYIiwicHJvZHVjdFZlcnNpb24iOiIyLjAuMjIiLCJ1cmwiOiJodHRwczovL3hmZXJyZWNvcmRzLmNvbS8iLCJ2ZW5kb3IiOiJYZmVyIFJlY29y",
"ZHMiLCJ2ZXJzaW9uIjo4LjB96RgAAAIAAAAotS/9YOkXjR8AFjCaPFBr2gbodialcV1VbJoVgFuWWaflcoTUerVxc8k2222dIo/JNba20TbOHBIjAvyim4hIZLuYBkOj",
"Cn3dAo0AhgCNAJU243xMKYKe59pf2A+LmqTo/x96nrO24cwi/iPX+RFIteCpSYqg57kmc1uJ6dwYcHI4kGpBpK3xFIFrFDIqjYxjgS3JqK7n2rssy7I6IgZCuKZJYAGi",
"+eVmFxbKcakH4nUJTSMWapIi6HlOztkwdlIYM46d9MLeCplp1pbHC2N9O1JnU2u54NhbETMZAk392iaBAOxY3wrL6gSAqdfa8gLLqgWz4dSKHZQHFu2bodsJrTi1WE9m",
"ptnjIYbcplnti4B2OPdzUzohhliklM9FLXIiSU1SBD0PclCDHEhSkxRBz/Me97THeSQ1SRH0nveee+05T1KTFDnIPe4555pznKQmKYKe5+Zs+ekk/kNSkxRBz5stmc7K",
"4XwHEfQ81646c/YS6rnz5Pp1zZtd8vcr/XDHmsFsIpqIJqKx4LQhdg7n+oSZa2cNJMwz8hBDNLLJlXk+5MxWBT9jVoH9rbKCDRomcMAdCoS4n2FXXrPskXI8PJ/BzDzn",
"nHOutdZaw8AgwHNLcOXSESaw25YTCQwK7FSjKRJuMmCD8uApMnfELzMHTdNUgKg/L4nEdSuD3o/VLxYF9nACx/vQ73Vrg4KCALY5ywAb9GZQAv1IrICvzhHz1C8R4/lr",
"zODbl3MTzoSz6k983UHNqByWXZ/IDLP4115OAL9qS2J1C7cYBA+xHnCLVZprWvUv3GKzZpSKh6vO6xeIBT619zF2+NfOXL2+xR5Orx2xtIDlqMF0SERERCQpSFHSGEAj",
"kdkkHRLAUBiOwhAEQiCGYAglEQRBQAhmGlKCRUHN1UALEAczXz0gMEUMUg/I72Ha2X9lejEDdYR8KCu6i1QeBbuujg2PwzCHKfqM9VBSFBV7pMakjpL7SBcfeyuyPWYt",
"Cqw2yq3Id0jtT4kyOfYBHn7MBg4N6MWKARqYdIBRWEyo/VDzLp6K6f//makXGDLmd6CJvrRh7hF1+DVp8yXYKda8mvdtEh4MhwaiuUhrvQMBOS5SsQvD4++3eY8239eA",
"ae/p9fDb20eb7+dDu6/Xg8+v6yNgx01KhM8JMMEARg5VQTM2ajybsmMaNgU0dqsgywJ8Gq6pwH0xRwMpnIIA0qth5Hvg2QYF+OQZFwguah8wXodmcN3SEE18Amsu4KMa",
"jAPN4RXSjPlHjS5Y6MHBwwcLDGHkcFhrOWnbJKLUtgZP5zLRdzspCZtTajRtv5Zihl0n02exI+U3WW9mpXWTZY9OhGwkh2cHYoWmfCnGb2Fb8DC0GpADAAAAAAAAWGZl",
"ckpzb24A/QAAAAAAAAB7ImNvbXBvbmVudCI6ImNvbnRyb2xsZXIiLCJoYXNoIjoiYjg0YWYyZGUzOTA2MTM0NWVmNThkOTIzMDE4MjE2MjYiLCJwcmVzZXRBdXRob3Ii",
"OiIiLCJwcmVzZXREZXNjcmlwdGlvbiI6IiIsInByZXNldE5hbWUiOiIgLSBJbml0IC0gIiwicHJvZHVjdCI6IlNlcnVtMkZYIiwicHJvZHVjdFZlcnNpb24iOiIyLjAu",
"MjIiLCJ1cmwiOiJodHRwczovL3hmZXJyZWNvcmRzLmNvbS8iLCJ2ZW5kb3IiOiJYZmVyIFJlY29yZHMiLCJ2ZXJzaW9uIjo4LjB9SgcAAAIAAAAotS/9YEoGhRMA5h5p",
"OlBnnQMg2JmZmZnFzkQkPGZWs9ULakKkJC1FrrZjbdNK7pRrTLjfNDTBGtHcgdX3/V4On2ZNcaBmqu9aAFsAVgB8eQz0zIPp+0q3bJcF6o41JZbVusaZKi4y52MJaX1j",
"y84uRVHUyVIUAwInUvvXv+lLNVJSDp3sm46fN5gGTgAo8V1iuRmFE0kE8nA0Lqvy0Aqr6rJAfYpIwUGXO5+Ub+45Q6KibHp0mcVZyuj1KCSJBtFiMcSdth7NfH8DwJa1",
"48yv9DIznEgikIejcVnVKLTCyjXP+MREEoE8HI3LmqgwCwLtenViEnwqnwkiWuxZepP/wk0PHuW27a/yW0r6d9I4urMnrR2N7jLaCjd5oT4/QseDAJo6npY9edPRqQP3",
"dOuPPQHACIhoMcl/Y9eMw7RnuqDUB24EpuHSed+ZSJsM9/lmtNNRqsBO6gEURVFOX4ij+N4+8qk2+9sYHY0Lqz2N/d8byGAo2HqvCAAo34vXtagW1zap22+H+bFcN3oa",
"/RpSJCiuT4wiQTHqjjWjB1pf1xhm94JlAxZU/aPtygPoqegGWajBUKVMMDUTjARa0koHsCIqKtEDEgAycQqpemQCmmCCCSZM/o9piwHErCirVFLbEQqK8BhwC5MOQ/9H",
"0Yu+Iufi/Gt5t3SEzGHkM36itgXehxZmejg2h+br8O549C7nKLQAF4mkv+8A2Lc4kLZstX3rg5rS1Hi5O50i7rY6xtru7d4G4lWW7ulsMU50EzAHTkc5t0siNKeYo8yP",
"FoXiDr5ojXGIoH/6Azwj9ipFuaVeNO8IeHFxVyzVtVp4D8y6TcbLZLS7ic4H",
"AFByb2dyYW0gMQAAAAAA",
],
"Omnisphere": [
"nefcOe5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAwxIAAAEAAAAAAAAA",
"sxIAAAEAAAD/yZo7AAAAAAEAAAAAAAAAeBIAAAAAAAA8U3ludGhNYXN0ZXIgdmVycz0iMy4wLjFjIj4KPEVOVFJZREVTQ1IgbmFtZT0iIiBsaWJyYXJ5PSIiIEFUVFJJ",
"Ql9WQUxVRV9EQVRBPSIiPgo8L0VOVFJZREVTQ1I+CiA8U3ludGhNYXN0ZXJFbmdpbmVQYXJhbUJsb2NrPgo8TWFzdGVyRW5naW5lQmFzZVBhcmFtQmxvY2sgc2NhbGVO",
"YW1lPSIiIEhXcHJvZmlsZT0iIiB2ZXJzaW9uPSIxIiAgaW5MZXZlbD0iM2Y0MDAwMDAiICBnYWluPSIzZjQwMDAwMCIgIG1hc3Rlck1peD0iM2Y4MDAwMDAiICBtYXN0",
"ZXJCeXA9IjAiICBhdXRvTGRQYXRjaD0iMCIgIHBhbmljPSIwIiAgdHVuVj0iNDNkYzAwMDAiICBwUGFuMD0iM2YwMDAwMDAiICBwUGFuMT0iM2YwMDAwMDAiICBwUGFu",
"Mj0iM2YwMDAwMDAiICBwUGFuMz0iM2YwMDAwMDAiICBwUGFuND0iM2YwMDAwMDAiICBwUGFuNT0iM2YwMDAwMDAiICBwUGFuNj0iM2YwMDAwMDAiICBwUGFuNz0iM2Yw",
"MDAwMDAiICBwTGV2ZWwwPSIzZjQwMDAwMCIgIHBMZXZlbDE9IjNmNDAwMDAwIiAgcExldmVsMj0iM2Y0MDAwMDAiICBwTGV2ZWwzPSIzZjQwMDAwMCIgIHBMZXZlbDQ9",
"IjNmNDAwMDAwIiAgcExldmVsNT0iM2Y0MDAwMDAiICBwTGV2ZWw2PSIzZjQwMDAwMCIgIHBMZXZlbDc9IjNmNDAwMDAwIiAgcExhdGNoMD0iMCIgIHBMYXRjaDE9IjAi",
"ICBwTGF0Y2gyPSIwIiAgcExhdGNoMz0iMCIgIHBMYXRjaDQ9IjAiICBwTGF0Y2g1PSIwIiAgcExhdGNoNj0iMCIgIHBMYXRjaDc9IjAiICBwVHJpZ2dlcjA9IjAiICBw",
"VHJpZ2dlcjE9IjAiICBwVHJpZ2dlcjI9IjAiICBwVHJpZ2dlcjM9IjAiICBwVHJpZ2dlcjQ9IjAiICBwVHJpZ2dlcjU9IjAiICBwVHJpZ2dlcjY9IjAiICBwVHJpZ2dl",
"cjc9IjAiICBwU3VzRW4wPSIzZjgwMDAwMCIgIHBTdXNFbjE9IjNmODAwMDAwIiAgcFN1c0VuMj0iM2Y4MDAwMDAiICBwU3VzRW4zPSIzZjgwMDAwMCIgIHBTdXNFbjQ9",
"IjNmODAwMDAwIiAgcFN1c0VuNT0iM2Y4MDAwMDAiICBwU3VzRW42PSIzZjgwMDAwMCIgIHBTdXNFbjc9IjNmODAwMDAwIiAgcE11dGUwPSIwIiAgcE11dGUxPSIwIiAg",
"cE11dGUyPSIwIiAgcE11dGUzPSIwIiAgcE11dGU0PSIwIiAgcE11dGU1PSIwIiAgcE11dGU2PSIwIiAgcE11dGU3PSIwIiAgcFNvbG8wPSIwIiAgcFNvbG8xPSIwIiAg",
"cFNvbG8yPSIwIiAgcFNvbG8zPSIwIiAgcFNvbG80PSIwIiAgcFNvbG81PSIwIiAgcFNvbG82PSIwIiAgcFNvbG83PSIwIiAgcEdBdHRlbjA9IjAiICBwR0F0dGVuMT0i",
"MCIgIHBHQXR0ZW4yPSIwIiAgcEdBdHRlbjM9IjAiICBwR0F0dGVuND0iMCIgIHBHQXR0ZW41PSIwIiAgcEdBdHRlbjY9IjAiICBwR0F0dGVuNz0iMCIgIHAwQXV4U25k",
"MD0iMCIgIHAwQXV4U25kMT0iMCIgIHAwQXV4U25kMj0iMCIgIHAwQXV4U25kMz0iMCIgIHAxQXV4U25kMD0iMCIgIHAxQXV4U25kMT0iMCIgIHAxQXV4U25kMj0iMCIg",
"IHAxQXV4U25kMz0iMCIgIHAyQXV4U25kMD0iMCIgIHAyQXV4U25kMT0iMCIgIHAyQXV4U25kMj0iMCIgIHAyQXV4U25kMz0iMCIgIHAzQXV4U25kMD0iMCIgIHAzQXV4",
"U25kMT0iMCIgIHAzQXV4U25kMj0iMCIgIHAzQXV4U25kMz0iMCIgIHA0QXV4U25kMD0iMCIgIHA0QXV4U25kMT0iMCIgIHA0QXV4U25kMj0iMCIgIHA0QXV4U25kMz0i",
"MCIgIHA1QXV4U25kMD0iMCIgIHA1QXV4U25kMT0iMCIgIHA1QXV4U25kMj0iMCIgIHA1QXV4U25kMz0iMCIgIHA2QXV4U25kMD0iMCIgIHA2QXV4U25kMT0iMCIgIHA2",
"U25kMj0iMCIgIHA2QXV4U25kMz0iMCIgIHA3QXV4U25kMD0iMCIgIHA3QXV4U25kMT0iMCIgIHA3QXV4U25kMj0iMCIgIHA3QXV4U25kMz0iMCIgIG91dDA9IjAi",
"ICBvdXQxPSIwIiAgb3V0Mj0iMCIgIG91dDM9IjAiICBvdXQ0PSIwIiAgb3V0NT0iMCIgIG91dDY9IjAiICBvdXQ3PSIwIiAgY2hhbjA9IjAiICBjaGFuMT0iMSIgIGNo",
"YW4yPSIyIiAgY2hhbjM9IjMiICBjaGFuND0iNCIgIGNoYW41PSI1IiAgY2hhbjY9IjYiICBjaGFuNz0iNyIgIG1nMD0iMCIgIG1nMT0iMCIgIG1nMj0iMCIgIG1nMz0i",
"MCIgIG1nND0iMCIgIG1nNT0iMCIgIG1nNj0iMCIgIG1nNz0iMCIgIHNwbjA9IjEiICBzcG4xPSIxIiAgc3BuMj0iMSIgIHNwbjM9IjEiICBzcG40PSIxIiAgc3BuNT0i",
"MSIgIHNwbjY9IjEiICBzcG43PSIxIiAgYnJvd3NlVXA9IjAiICBicm93c2VEbj0iMCIgIE9iamVjdFN0ZXA9ImJmODAwMDAwIiAgRmlsdGVyU3RlcDA9IjAiICBGaWx0",
"ZXJTdGVwMT0iMCIgIEZpbHRlclN0ZXAyPSIwIiAgRmlsdGVyU3RlcDM9IjAiICBGaWx0ZXJTdGVwND0iMCIgPgo8TUlESUVYUFJFU1NJT04gTXBlT25PZmY9IjAiICBN",
"cGVCZW5kUmFuZ2U9IjQ4IiAgTWlkaVNtb290aFJpc2UwPSIzZWRlYjg1MiIgIE1pZGlTbW9vdGhSaXNlMT0iM2YwMDAwMDAiICBNaWRpU21vb3RoUmlzZTI9IjAiICBN",
"aWRpU21vb3RoUmlzZTM9IjNmMDAwMDAwIiAgTWlkaVNtb290aFJpc2U0PSIzZjAwMDAwMCIgIE1pZGlTbW9vdGhSaXNlNT0iM2YwMDAwMDAiICBNaWRpU21vb3RoUmlz",
"ZTY9IjAiICBNaWRpU21vb3RoUmlzZTc9IjAiICBNaWRpU21vb3RoUmlzZTg9IjAiICBNaWRpU21vb3RoUmlzZTk9IjAiICBNaWRpU21vb3RoUmlzZTEwPSIwIiAgTWlk",
"aVNtb290aFJpc2UxMT0iMCIgIE1pZGlTbW9vdGhSaXNlMTI9IjNmMDAwMDAwIiAgTWlkaVNtb290aFJpc2UxMz0iM2YwMDAwMDAiICBNaWRpU21vb3RoRmFsbDA9IjNl",
"ZGViODUyIiAgTWlkaVNtb290aEZhbGwxPSIzZjAwMDAwMCIgIE1pZGlTbW9vdGhGYWxsMj0iMCIgIE1pZGlTbW9vdGhGYWxsMz0iM2YwMDAwMDAiICBNaWRpU21vb3Ro",
"RmFsbDQ9IjNmMDAwMDAwIiAgTWlkaVNtb290aEZhbGw1PSIzZjAwMDAwMCIgIE1pZGlTbW9vdGhGYWxsNj0iMCIgIE1pZGlTbW9vdGhGYWxsNz0iMCIgIE1pZGlTbW9v",
"dGhGYWxsOD0iMCIgIE1pZGlTbW9vdGhGYWxsOT0iMCIgIE1pZGlTbW9vdGhGYWxsMTA9IjAiICBNaWRpU21vb3RoRmFsbDExPSIwIiAgTWlkaVNtb290aEZhbGwxMj0i",
"M2YwMDAwMDAiICBNaWRpU21vb3RoRmFsbDEzPSIzZjAwMDAwMCIgPgo8L01JRElFWFBSRVNTSU9OPgogPE1FZmZSYWNrIFByZXNldD0iUmFjayBQcmVzZXRzIj4KPEVG",
"Rk1PRFVMRSBUeXBlPSJObyBFZmZlY3QiIFAwPSIwIiAgUDE9IjAiICBQMj0iMCIgIFAzPSIwIiAgUDQ9IjAiICBQNT0iMCIgIFA2PSIwIiAgUDc9IjAiICBQOD0iMCIg",
"IFA5PSIwIiAgUDEwPSIwIiAgUDExPSIwIiAgUDEyPSIwIiAgUDEzPSIwIiAgUDE0PSIwIiAgQWN0aXZlPSIwIiAgTWl4TG9jaz0iMCIgPgo8L0VGRk1PRFVMRT4KIDxF",
"RkZNT0RVTEUgVHlwZT0iTm8gRWZmZWN0IiBQMD0iMCIgIFAxPSIwIiAgUDI9IjAiICBQMz0iMCIgIFA0PSIwIiAgUDU9IjAiICBQNj0iMCIgIFA3PSIwIiAgUDg9IjAi",
"ICBQOT0iMCIgIFAxMD0iMCIgIFAxMT0iMCIgIFAxMj0iMCIgIFAxMz0iMCIgIFAxND0iMCIgIEFjdGl2ZT0iMCIgIE1peExvY2s9IjAiID4KPC9FRkZNT0RVTEU+CiA8",
"RUZGTU9EVUxFIFR5cGU9Ik5vIEVmZmVjdCIgUDA9IjAiICBQMT0iMCIgIFAyPSIwIiAgUDM9IjAiICBQND0iMCIgIFA1PSIwIiAgUDY9IjAiICBQNz0iMCIgIFA4PSIw",
"IiAgUDk9IjAiICBQMTA9IjAiICBQMTE9IjAiICBQMTI9IjAiICBQMTM9IjAiICBQMTQ9IjAiICBBY3RpdmU9IjAiICBNaXhMb2NrPSIwIiA+CjwvRUZGTU9EVUxFPgog",
"PEVGRk1PRFVMRSBUeXBlPSJObyBFZmZlY3QiIFAwPSIwIiAgUDE9IjAiICBQMj0iMCIgIFAzPSIwIiAgUDQ9IjAiICBQNT0iMCIgIFA2PSIwIiAgUDc9IjAiICBQOD0i",
"MCIgIFA5PSIwIiAgUDEwPSIwIiAgUDExPSIwIiAgUDEyPSIwIiAgUDEzPSIwIiAgUDE0PSIwIiAgQWN0aXZlPSIwIiAgTWl4TG9jaz0iMCIgPgo8L0VGRk1PRFVMRT4K",
"IDwvTUVmZlJhY2s+CiA8QUVmZlJhY2swPgo8RUZGTU9EVUxFIFR5cGU9Ik5vIEVmZmVjdCIgUDA9IjAiICBQMT0iMCIgIFAyPSIwIiAgUDM9IjAiICBQND0iMCIgIFA1",
"PSIwIiAgUDY9IjAiICBQNz0iMCIgIFA4PSIwIiAgUDk9IjAiICBQMTA9IjAiICBQMTE9IjAiICBQMTI9IjAiICBQMTM9IjAiICBQMTQ9IjAiICBBY3RpdmU9IjAiICBN",
"aXhMb2NrPSIwIiA+CjwvRUZGTU9EVUxFPgogPEVGRk1PRFVMRSBUeXBlPSJObyBFZmZlY3QiIFAwPSIwIiAgUDE9IjAiICBQMj0iMCIgIFAzPSIwIiAgUDQ9IjAiICBQ",
"NT0iMCIgIFA2PSIwIiAgUDc9IjAiICBQOD0iMCIgIFA5PSIwIiAgUDEwPSIwIiAgUDExPSIwIiAgUDEyPSIwIiAgUDEzPSIwIiAgUDE0PSIwIiAgQWN0aXZlPSIwIiAg",
"TWl4TG9jaz0iMCIgPgo8L0VGRk1PRFVMRT4KIDxFRkZNT0RVTEUgVHlwZT0iTm8gRWZmZWN0IiBQMD0iMCIgIFAxPSIwIiAgUDI9IjAiICBQMz0iMCIgIFA0PSIwIiAg",
"UDU9IjAiICBQNj0iMCIgIFA3PSIwIiAgUDg9IjAiICBQOT0iMCIgIFAxMD0iMCIgIFAxMT0iMCIgIFAxMj0iMCIgIFAxMz0iMCIgIFAxND0iMCIgIEFjdGl2ZT0iMCIg",
"IE1peExvY2s9IjAiID4KPC9FRkZNT0RVTEU+CiA8RUZGTU9EVUxFIFR5cGU9Ik5vIEVmZmVjdCIgUDA9IjAiICBQMT0iMCIgIFAyPSIwIiAgUDM9IjAiICBQND0iMCIg",
"IFA1PSIwIiAgUDY9IjAiICBQNz0iMCIgIFA4PSIwIiAgUDk9IjAiICBQMTA9IjAiICBQMTE9IjAiICBQMTI9IjAiICBQMTM9IjAiICBQMTQ9IjAiICBBY3RpdmU9IjAi",
"ICBNaXhMb2NrPSIwIiA+CjwvRUZGTU9EVUxFPgogPC9BRWZmUmFjazA+CiA8L01hc3RlckVuZ2luZUJhc2VQYXJhbUJsb2NrPgogPC9TeW50aE1hc3RlckVuZ2luZVBh",
"cmFtQmxvY2s+CiA8TUlESWxlYXJuMj4KPC9NSURJbGVhcm4yPgogPC9TeW50aE1hc3Rlcj4KIAAAAAAAAAAAAAAAAAAAAAAAAAAAAEpVQ0VQcml2YXRlRGF0YQAAAAAA",
"AAAA",
"AFByb2dyYW0gMQAAAAAA",
],
"FabFilter Pro-Q 3": [
"rgIRLe5e7f4EAAAAAQAAAAAAAAACAAAAAAAAAAQAAAAAAAAACAAAAAAAAAACAAAAAQAAAAAAAAACAAAAAAAAAAoGAAABAAAAAAAAAA==",
"sAUAAAEAAABGRkJTAQAAAGYBAAAAAAAAAACAP9pzH0EAAAAAAAAAAAAAgD8AAIA/AAAAPwAAAAAAAIA/AAAAQAAAgD8AAAAAAAAAAAAAgD/acx9BAAAAAAAAAAAAAIA/",
"AACAPwAAAD8AAAAAAACAPwAAAEAAAIA/AAAAAAAAAAAAAIA/2nMfQQAAAAAAAAAAAACAPwAAgD8AAAA/AAAAAAAAgD8AAABAAACAPwAAAAAAAAAAAACAP9pzH0EAAAAA",
"AAAAAAAAgD8AAIA/AAAAPwAAAAAAAIA/AAAAQAAAgD8AAAAAAAAAAAAAgD/acx9BAAAAAAAAAAAAAIA/AACAPwAAAD8AAAAAAACAPwAAAEAAAIA/AAAAAAAAAAAAAIA/",
"2nMfQQAAAAAAAAAAAACAPwAAgD8AAAA/AAAAAAAAgD8AAABAAACAPwAAAAAAAAAAAACAP9pzH0EAAAAAAAAAAAAAgD8AAIA/AAAAPwAAAAAAAIA/AAAAQAAAgD8AAAAA",
"AAAAAAAAgD/acx9BAAAAAAAAAAAAAIA/AACAPwAAAD8AAAAAAACAPwAAAEAAAIA/AAAAAAAAAAAAAIA/2nMfQQAAAAAAAAAAAACAPwAAgD8AAAA/AAAAAAAAgD8AAABA",
"AACAPwAAAAAAAAAAAACAP9pzH0EAAAAAAAAAAAAAgD8AAIA/AAAAPwAAAAAAAIA/AAAAQAAAgD8AAAAAAAAAAAAAgD/acx9BAAAAAAAAAAAAAIA/AACAPwAAAD8AAAAA",
"AACAPwAAAEAAAIA/AAAAAAAAAAAAAIA/2nMfQQAAAAAAAAAAAACAPwAAgD8AAAA/AAAAAAAAgD8AAABAAACAPwAAAAAAAAAAAACAP9pzH0EAAAAAAAAAAAAAgD8AAIA/",
"AAAAPwAAAAAAAIA/AAAAQAAAgD8AAAAAAAAAAAAAgD/acx9BAAAAAAAAAAAAAIA/AACAPwAAAD8AAAAAAACAPwAAAEAAAIA/AAAAAAAAAAAAAIA/2nMfQQAAAAAAAAAA",
"AACAPwAAgD8AAAA/AAAAAAAAgD8AAABAAACAPwAAAAAAAAAAAACAP9pzH0EAAAAAAAAAAAAAgD8AAIA/AAAAPwAAAAAAAIA/AAAAQAAAgD8AAAAAAAAAAAAAgD/acx9B",
"AAAAAAAAAAAAAIA/AACAPwAAAD8AAAAAAACAPwAAAEAAAIA/AAAAAAAAAAAAAIA/2nMfQQAAAAAAAAAAAACAPwAAgD8AAAA/AAAAAAAAgD8AAABAAACAPwAAAAAAAAAA",
"AACAP9pzH0EAAAAAAAAAAAAAgD8AAIA/AAAAPwAAAAAAAIA/AAAAQAAAgD8AAAAAAAAAAAAAgD/acx9BAAAAAAAAAAAAAIA/AACAPwAAAD8AAAAAAACAPwAAAEAAAIA/",
"AAAAAAAAAAAAAIA/2nMfQQAAAAAAAAAAAACAPwAAgD8AAAA/AAAAAAAAgD8AAABAAACAPwAAAAAAAAAAAACAP9pzH0EAAAAAAAAAAAAAgD8AAIA/AAAAPwAAAAAAAIA/",
"AAAAQAAAgD8AAAAAAAAAAAAAgD/acx9BAAAAAAAAAAAAAIA/AACAPwAAAD8AAAAAAACAPwAAAEAAAIA/AAAAAAAAAAAAAIA/2nMfQQAAAAAAAAAAAACAPwAAgD8AAAA/",
"AAAAAAAAgD8AAABAAACAPwAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AACAPwAAgL8AAIA/AAAAQAAAAEAAAEBAAAAAAAAAgD8AAIA/",
"AAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAARkZwcgEAAAAAAAAASgAAAAAAAABGUTNwAwAAAA8AAABEZWZhdWx0IFNldHRpbmf/////AQAAAAcAAABUcmFjayAxAAAAAEN1U1YBAAAAAAAAAEZG",
"ZWQAAAAAAACAPw==",
"AFByb2dyYW0gMQAAAAAA",
],
"FabFilter Pro-C 2": [
"NP2iO+5e7f4EAAAAAQAAAAAAAAACAAAAAAAAAAQAAAAAAAAACAAAAAAAAAACAAAAAQAAAAAAAAACAAAAAAAAAP8AAAABAAAAAAAAAA==",
"4wAAAAEAAABGYWJGAgAAAA8AAABEZWZhdWx0IFNldHRpbmcAAAAALgAAAAAAAAAAAJDBmpkZPwAAkEEAAHBCzczMPbaN0j4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgL8A",
"AAAAAACAPwAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAHia1EAAAEBAAACAPwAAgD/acx9BAAAAAAAAAD8AAAAAAAAAAHiaREEAAEBAAAAAAAAAAAAAAIA/AAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBAAACAPwAAgD8BAAAAAQAAAAwAAAAAAAAARkZlZAAAAAAAAIA/",
"AFByb2dyYW0gMQAAAAAA",
],
"FabFilter Pro-R 2": [
"xz/rIu5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAASwMAAAEAAAAAAAAA",
"OAIAAAEAAABGRkJTAQAAAIgAAAAAAAA/AAAAAAAAAD8AAAA/AAAAAJqZmT4AAAAAMzMzPwAAAADIAbRBAAAAAAAAAAAAAHpDAAAAAAAAAAAAAAAAJCeEPQAAAAAAAAAA",
"AACAPwAAgD9/CnpA0H/UvnzDuz4AAIA/AACAPwAAgD8AAIA/EqcxQUgPwb7MiIk+AAAAAAAAgD8AAAAAAAAAANpzH0EAAAAAAAAAPwAAAAAAAIA/AAAAAAAAAADacx9B",
"AAAAAAAAAD8AAAAAAACAPwAAAAAAAAAA2nMfQQAAAAAAAAA/AAAAAAAAgD8AAAAAAAAAANpzH0EAAAAAAAAAPwAAAAAAAIA/AAAAAAAAAADacy9BAAAAwCsNGD8AAAAA",
"AACAPwAAAEAAAIA/AACAPwAAgD/cz1hBAAAAwAAAAD8AAIBAAACAPwAAAEAAAIA/AACAPwAAgD8+qRNBAACQwKvlzz4AAAAAAACAPwAAAEAAAIA/AAAAAAAAAADacx9B",
"AAAAAAAAAD8AAAAAAACAPwAAAEAAAIA/AAAAAAAAAADacx9BAAAAAAAAAD8AAAAAAACAPwAAAEAAAIA/AAAAAAAAAADacx9BAAAAAAAAAD8AAAAAAACAPwAAAEAAAIA/",
"AAAAPwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAAPwAAAD8AAAA/AAAAAM3MTD/NzEw/AAAAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAgD8AAAAARkZwcgEAAAAAAAAA",
"AwEAAAAAAABGUjJwAwAAAA8AAABEZWZhdWx0IFNldHRpbmf/////AQAAAAAAAAAAAAAAQ3VTVgEAAAADAAAABgAAAEFVVEhPUgkAAABGYWJGaWx0ZXILAAAAREVTQ1JJ",
"UFRJT053AAAAVGhpcyBpcyB0aGUgZGVmYXVsdCBwcmVzZXQgZm9yIFByby1SIDIsIHdoaWNoIGlzIGxvYWRlZCBmb3IgZXZlcnkgbmV3IGluc3RhbmNlLgoKRmVlbCBm",
"cmVlIHRvIGN1c3RvbWl6ZSBpdCBhcyB5b3UgbGlrZSEEAAAAVEFHUxMAAABkZWZhdWx0LGhhbGwsbWVkaXVtRkZlZAAAAAAAAIA/",
"AFByb2dyYW0gMQAAAAAA",
],
"FabFilter Pro-L 2": [
"GYiKc+5e7f4EAAAAAQAAAAAAAAACAAAAAAAAAAQAAAAAAAAACAAAAAAAAAACAAAAAQAAAAAAAAACAAAAAAAAAMcAAAABAAAAAAAAAA==",
"qwAAAAEAAABGYWJGAgAAAA8AAABEZWZhdWx0IFNldHRpbmcAAAAAIAAAAAAAAAAAAKBA7FE4PqR/0D5+jcY+AADAPgAAAD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAEAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAEAAAIA/AAAAAAAAAAAAAGDBAAAAAAAAgD8AAAAAAQAAAAEAAAAMAAAAAAAAAEZGZWQA",
"AAAAAACAPw==",
"AFByb2dyYW0gMQAAAAAA",
],
"FabFilter Saturn 2": [
"D1eoVe5e7f4EAAAAAQAAAAAAAAACAAAAAAAAAAQAAAAAAAAACAAAAAAAAAACAAAAAQAAAAAAAAACAAAAAAAAAEcPAAABAAAAAAAAAA==",
"9A4AAAEAAABGRkJTAQAAALcDAAAAAAAAAAAAAAMAgL8AAAAAAAAAAAAAyEIAAIA/AAAAANPn/kAK16M8AADAQM3MTD4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAyEIAAAAA",
"AAAAAAAAgD8AAAAAPE2qQAAAAEAAAAAAtef+QAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADIQgAAAAAAAAAAAACAPwAAAAA8TapAAAAAQAAAAAC15/5A",
"AAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMhCAAAAAAAAAAAAAIA/AAAAADxNqkAAAABAAAAAALXn/kAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAyEIAAAAAAAAAAAAAgD8AAAAAPE2qQAAAAEAAAAAAtef+QAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADIQgAAAAAAAAAAAACAPwAAAAA8TapA",
"AAAAQAAAAAC15/5AAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMhCAAAAAAAAAAAAAIA/AAAAADxNqkAAAABAAAAAAAAAAAAAAAAAAACAPwAAgD8AAAAA",
"AAAAAAAAgD8AAIA/AAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAgD8AAIA/AAAAAAAAAAAAAIA/AACAPwAAAAC+9H0+AAAAAAAAAD8AAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAvvR9PgAAAAAAAAA/",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAL70fT4AAAAA",
"AAAAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC+9H0+",
"AAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAaPY8+AAAAAAAAAAAAAAA/AAAAAAAAAD8AAIA/AAAAAAAAAAAAAAA/AAAAAAAAAAAaPY8+AAAAAAAAAAAAAAA/AAAAAAAAAD8AAIA/",
"AAAAAAAAAAAAAAA/AAAAAAAAAAAaPY8+AAAAAAAAAAAAAAA/AAAAAAAAAD8AAIA/AAAAAAAAAAAAAAA/AAAAAAAAAAAaPY8+AAAAAAAAAAAAAAA/AAAAAAAAAD8AAIA/",
"AAAAAAAAAAAAAAA/AAAAAAAAAAAaPY8+AAAAAAAAAAAAAAA/AAAAAAAAAD8AAIA/AAAAAAAAAAAAAAA/AAAAAAAAAAAaPY8+AAAAAAAAAAAAAAA/AAAAAAAAAD8AAIA/",
"AAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAA",
"AAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAA",
"AAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAA/",
"AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAA",
"AAAAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAA",
"AAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAA",
"AAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAA",
"AAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAA/",
"AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAA",
"AAAAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAA",
"AAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAAAAAAAAAAAAABGRnByAQAAAAAAAABDAAAA",
"AAAAAEZTMmEDAAAADwAAAERlZmF1bHQgU2V0dGluZ/////8BAAAAAAAAAAAAAABDdVNWAQAAAAAAAABGRmVkAAAAAAAAgD8=",
"AFByb2dyYW0gMQAAAAAA",
],
"FabFilter Timeless 3": [
"y1aTfu5e7f4EAAAAAQAAAAAAAAACAAAAAAAAAAQAAAAAAAAACAAAAAAAAAACAAAAAQAAAAAAAAACAAAAAAAAAJMQAAABAAAAAAAAAA==",
"1A8AAAEAAABGRkJTAQAAAO8DAACBMJY+ADJrPBrAFT8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAA/AAAAAAAAAAAAAAAAAACAPwAAAD8AAAAAAAAAAAAAAAAAAIA/",
"AAAAPwAAAAAAAAAAAAAAAAAAgD8AAAA/AAAAAAAAAAAAAAAAAACAPwAAAD8AAAAAAAAAAAAAAAAAAIA/AAAAPwAAAAAAAAAAAAAAAAAAgD8AAAA/AAAAAAAAAAAAAAAA",
"AACAPwAAAD8AAAAAAAAAAAAAAAAAAIA/AAAAPwAAAAAAAAAAAAAAAAAAgD8AAAA/AAAAAAAAAAAAAAAAAACAPwAAAD8AAAAAAAAAAAAAAAAAAIA/AAAAPwAAAAAAAAAA",
"AAAAAAAAgD8AAAA/AAAAAAAAAAAAAAAAAACAPwAAAD8AAAAAAAAAAAAAAAAAAIA/AAAAPwAAAAAAAAAAAACAPwAAgD8AAAAAMzOzPgAAAAAAAAAAAAAAAAAAAAB4mvRA",
"AAAAAAAAAAAAAAAAAAAgQQAAgD8AAIA/AACAPwAAgD94mkRBAAAAAAAAAADAzEw9AAAgQQAAAAAAAABAAACAPwAAgD88TQpBAAAAwQAAAACEPbW+AAAgQQAAgEAAAIA/",
"AACAPwAAAAC1poFBAAAAAAAAAAAAAAAAAAAgQQAAAAAAAIA/AACAPwAAAAC1poFBAAAAAAAAAAAAAAAAAAAgQQAAAAAAAIA/AACAPwAAAAC1poFBAAAAAAAAAAAAAAAA",
"AAAgQQAAAAAAAIA/AACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAgD8AAAAAzMzMvgAAAAAAAABA",
"AAAAANDMTD4AAAAAAAAAAAAAAADQzEw+AAAAAAAAAAAAAAAAAAAAAAAAgD8AAIA/AAAAAAAAAAAAAIA/AACAPwAAAAAAAAAAAACAPwAAgD8AAAAAAAAAAAAAgD8AAIA/",
"AAAAQA6kXj4AAAAAAAAAPwAAAABmZmY/AAAAAAAAAAAAAAAAAACAPwAAAAB9ZXo/AABAQAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAADpB04+AAAAAAAAAD8AAAAAAPBJPgAAAAAAAAAAAAAAAAAAgEEAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAA",
"AAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAH5qLD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAA4JETPgAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAA",
"AAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAgD8AAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAJgsjPwAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAA",
"AAAAAAAAgD8AAAAAvvR9PgAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAL70fT4AAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAC+9H0+AAAAAAAAAD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAvvR9PgAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABo9jz4AAAAAAAAAAAAAAD8AAAAAAAAAPwAAgD8AAAAAAAAAAAAAAD8AAAAAAAAAABo9jz4AAAAA",
"AAAAAAAAAD8AAAAAAAAAPwAAgD8AAAAAAAAAAAAAAD8AAAAAAAAAABo9jz4AAAAAAAAAAAAAAD8AAAAAAAAAPwAAgD8AAAAAAAAAAAAAAD8AAAAAAAAAABo9jz4AAAAA",
"AAAAAAAAAD8AAAAAAAAAPwAAgD8AAAAAAAAAAAAAAD8AAAAAAAAAABo9jz4AAAAAAAAAAAAAAD8AAAAAAAAAPwAAgD8AAAAAAAAAAAAAAD8AAIA/AAAAAAAAAAAh1Ec+dd2pPgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPwAAAAAAACBBAADIQQAAAAAAAAAAAACAPwAAqkIAAABA",
"cT0KPwAAAAAAAAAAAAA+QwAAUEEAAAAAAAAAAAAAAAAAAABAAACAQAAAgD4AAAAAAAAAAAAAQEMAAFBBAAAAAAAAAAAAAAAAAACAPwAAgEAAADA+AAAAAAAAAAAAAEJD",
"AABgQQAAAAAAAAAAAAAAAAAAQkMAAGBBAAAAAAAAAAAAAAAAAABAQwAAgEAAACA+AAAAAAAAAAAAAEVDAACAQAAAAD4AAAAAAAAAAAAAREMAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAEZGcHIBAAAAAAAAAK8AAAAAAAAARjNUcwMAAAAPAAAARGVmYXVsdCBTZXR0aW5n/////wEAAAAAAAAAAAAAAEN1U1YBAAAABgAAAAMAAABF",
"RjEIAAAARW52ZWxvcGUDAAAARUYyAAAAAAUAAABYTEZPMQYAAABSYW5kb20FAAAAWExGTzIGAAAAV29iYmxlAwAAAFhZMQcAAABEdWNraW5nAwAAAFhZMgsAAABJbnN0",
"YWJpbGl0eUZGZWQAAAAAAACAPw==",
"AFByb2dyYW0gMQAAAAAA",
],
"The Glue": [
"xaYOFO5e7f4EAAAAAQAAAAAAAAACAAAAAAAAAAQAAAAAAAAACAAAAAAAAAACAAAAAQAAAAAAAAACAAAAAAAAAJMIAAABAAAA//8AAA==",
"gwgAAAEAAABWc3RXAAAACAAAAAEAAAAAQ2NuSwAACGtGQkNoAAAAAkN5VGcAAQgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH01ZDMiGrBwAA",
"PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4gPEN5dG9taWMgUHJvZHVjdD0iVGhlIEdsdWUiIFZlcnNpb249IjEuOC4wIj48U29uZ1ByZXNldCBW",
"ZXJzaW9uPSIxLjguMCI+PFN0YXRlIFZlcnNpb249IjEuOC4wIj48VHVwbGUgS2V5PSJVaVNjYWxlIiBWYWx1ZT0iMSIvPjxUdXBsZSBLZXk9IkhkUmVuZGVyIiBWYWx1",
"ZT0iZmFsc2UiLz48VHVwbGUgS2V5PSJEZXRlY3RTaWxlbmNlIiBWYWx1ZT0idHJ1ZSIvPjxUdXBsZSBLZXk9Ik92ZXJTYW1wbGVSZWFsdGltZSIgVmFsdWU9IngyIi8+",
"PFR1cGxlIEtleT0iT3ZlclNhbXBsZVJlbmRlciIgVmFsdWU9Ing4Ii8+PFR1cGxlIEtleT0iT3ZlclNhbXBsZVR5cGVVcCIgVmFsdWU9IkxpblBoYXNlIi8+PFR1cGxl",
"IEtleT0iT3ZlclNhbXBsZVR5cGVEbiIgVmFsdWU9IkxpblBoYXNlIi8+PFR1cGxlIEtleT0iQ2hlY2tJbyIgVmFsdWU9ImZhbHNlIi8+PFR1cGxlIEtleT0iQ3VycmVu",
"dFByZXNldCIgVmFsdWU9IjAiLz48VHVwbGUgS2V5PSJHdWlTdGF0ZSIgVmFsdWU9IjE3Nzc4MjUxMDYyMDkiLz48VHVwbGUgS2V5PSJTaG93UGVha05lZWRsZSIgVmFs",
"dWU9ImZhbHNlIi8+PC9TdGF0ZT48UHJlc2V0IE5hbWU9IkZhY3RvcnkgRGVmYXVsdCIgVmVyc2lvbj0iMS44LjAiPjxQYXJhbWV0ZXJzPjxUdXBsZSBLZXk9IlBvd2Vy",
"IiBWYWx1ZT0iWWVzIi8+PFR1cGxlIEtleT0iVGhyZXNob2xkIiBWYWx1ZT0iMC4wMDAwMDAwMGRCIi8+PFR1cGxlIEtleT0iTWFrZXVwIiBWYWx1ZT0iMC4wMDAwMDAw",
"MGRCIi8+PFR1cGxlIEtleT0iUmFuZ2UiIFZhbHVlPSJGdWxsIi8+PFR1cGxlIEtleT0iQXR0YWNrIiBWYWx1ZT0iMSBtUyIvPjxUdXBsZSBLZXk9IlJlbGVhc2UiIFZh",
"bHVlPSIwLjYgUyIvPjxUdXBsZSBLZXk9IlJhdGlvIiBWYWx1ZT0iNCIvPjxUdXBsZSBLZXk9IkNvbXBJbiIgVmFsdWU9IlllcyIvPjxUdXBsZSBLZXk9IldldE1peCIg",
"VmFsdWU9IjEwMC4wMDAwMDAwMCUiLz48VHVwbGUgS2V5PSJQZWFrQ2xpcEluIiBWYWx1ZT0iTm8iLz48VHVwbGUgS2V5PSJTaWRlY2hhaW5IcCIgVmFsdWU9Ik9mZiIv",
"PjxUdXBsZSBLZXk9IkV4dFNpZGVjaGFpbkluIiBWYWx1ZT0iTm8iLz48VHVwbGUgS2V5PSJEY0Jsb2NrSW8iIFZhbHVlPSJObyIvPjxUdXBsZSBLZXk9IkJ5cGFzcyIg",
"VmFsdWU9Ik5vIi8+PFR1cGxlIEtleT0iVnVNZXRlciIgVmFsdWU9IjAuMDAwMDA0NzdkQiIvPjxUdXBsZSBLZXk9IlN0ZXJlb0xpbmsiIFZhbHVlPSIxMDAgJSIvPjwv",
"UGFyYW1ldGVycz48L1ByZXNldD48UHJlc2V0IE5hbWU9IkZhY3RvcnkgRGVmYXVsdCIgVmVyc2lvbj0iMS44LjAiPjxQYXJhbWV0ZXJzPjxUdXBsZSBLZXk9IlBvd2Vy",
"IiBWYWx1ZT0iWWVzIi8+PFR1cGxlIEtleT0iVGhyZXNob2xkIiBWYWx1ZT0iMC4wMDAwMDAwMGRCIi8+PFR1cGxlIEtleT0iTWFrZXVwIiBWYWx1ZT0iMC4wMDAwMDAw",
"MGRCIi8+PFR1cGxlIEtleT0iUmFuZ2UiIFZhbHVlPSJGdWxsIi8+PFR1cGxlIEtleT0iQXR0YWNrIiBWYWx1ZT0iMSBtUyIvPjxUdXBsZSBLZXk9IlJlbGVhc2UiIFZh",
"bHVlPSIwLjYgUyIvPjxUdXBsZSBLZXk9IlJhdGlvIiBWYWx1ZT0iNCIvPjxUdXBsZSBLZXk9IkNvbXBJbiIgVmFsdWU9IlllcyIvPjxUdXBsZSBLZXk9IldldE1peCIg",
"VmFsdWU9IjEwMC4wMDAwMDAwMCUiLz48VHVwbGUgS2V5PSJQZWFrQ2xpcEluIiBWYWx1ZT0iTm8iLz48VHVwbGUgS2V5PSJTaWRlY2hhaW5IcCIgVmFsdWU9Ik9mZiIv",
"PjxUdXBsZSBLZXk9IkV4dFNpZGVjaGFpbkluIiBWYWx1ZT0iTm8iLz48VHVwbGUgS2V5PSJEY0Jsb2NrSW8iIFZhbHVlPSJObyIvPjxUdXBsZSBLZXk9IkJ5cGFzcyIg",
"VmFsdWU9Ik5vIi8+PFR1cGxlIEtleT0iVnVNZXRlciIgVmFsdWU9IjAuMDAwMDAwMDBkQiIvPjxUdXBsZSBLZXk9IlN0ZXJlb0xpbmsiIFZhbHVlPSIxMDAgJSIvPjwv",
"UGFyYW1ldGVycz48L1ByZXNldD48L1NvbmdQcmVzZXQ+PC9DeXRvbWljPgAAAAAAAAAAAAAAAAAAAAAASlVDRVByaXZhdGVEYXRhAAAAAAAAAAA=",
"AAAAAAAA",
],
"Valhalla Delay": [
"owDRY+5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAVQQAAAEAAAD//wAA",
"RQQAAAEAAABWc3RXAAAACAAAAAEAAAAAQ2NuSwAABC1GQkNoAAAAAmRMYXkAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADlVZDMiFQAwAA",
"PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4gPFZhbGhhbGxhRGVsYXkgcGx1Z2luVmVyc2lvbj0iMy4wLjB2MTQiIHByZXNldE5hbWU9IkRlZmF1",
"bHQiIE1peD0iMC41IiBEZWxheVN0eWxlPSIwLjAiIERlbGF5TFN5bmM9IjAuMjUiIERlbGF5TE5vdGU9IjAuMjAwMDAwMDAyOTgwMjMyMiIgRGVsYXlMX01zPSIwLjMw",
"MDAwMDAxMTkyMDkyOSIgRGVsYXlSU3luYz0iMC4yNSIgRGVsYXlSTm90ZT0iMC4yMDAwMDAwMDI5ODAyMzIyIiBEZWxheVJfTXM9IjAuMzAwMDAwMDExOTIwOTI5IiBE",
"ZWxheVNwcmVhZD0iMC41IiBEZWxheVNwYWNpbmc9IjAuNSIgRGVsYXlSYXRpbz0iMC42MTQxNDE0MDQ2Mjg3NTM3IiBSZXBlYXRzU3dlbGQ9IjEuMCIgVGFwQj0iMS4w",
"IiBUYXBDPSIxLjAiIFRhcEQ9IjEuMCIgRmVlZGJhY2s9IjAuMzQ5OTk5OTk0MDM5NTM1NSIgV2lkdGg9IjEuMCIgRHJpdmVJbj0iMC4wIiBBZ2U9IjAuNSIgRGlmZnVz",
"aW9uPSIwLjAiIERpZmZTaXplPSIxLjAiIExvd0N1dD0iMC4wIiBIaWdoQ3V0PSIxLjAiIE1vZFJhdGU9IjAuMjczODM0MTA5MzA2MzM1NCIgTW9kRGVwdGg9IjAuNSIg",
"V293cz0iMC41IiBGbHV0dGVyPSIwLjUiIEZyZXFTaGlmdD0iMC41IiBGcmVxRGV0dW5lPSIwLjU3OTk5OTk4MzMxMDY5OTUiIFBpdGNoU2hpZnQ9IjAuNSIgUGl0Y2gk",
"RGV0dW5lPSIwLjUiIE1vZGU9IjAuMDQxNjY2Njc3OTA4NDMwMSIgRXJhPSIwLjMzMzMzMzMzNDMyNjc0NDA4IiBEdWNraW5nPSIwLjAiIFJlc2VydmVkMj0iMC4wIiBS",
"ZXNlcnZlZDM9IjAuMCIgUmVzZXJ2ZWQ0PSIwLjAiIG1peExvY2s9IjAiIHVpV2lkdGg9Ijk0NSIgdWlIZWlnaHQ9IjQzNSIvPgAAAAAAAAAAABKVUNFUHJpdmF0ZURh",
"dGEAAQFCeXBhc3MAAQEDAB0AAAAAAAAASlVDRVByaXZhdGVEYXRhAAAAAAAAAAA=",
"AAAAAAAA",
],
}
def _build_plugin(self, plugin: PluginDef) -> Element: def _build_plugin(self, plugin: PluginDef) -> Element:
"""Build a VST Element inside FXCHAIN.""" """Build a VST Element inside FXCHAIN.
params_str = " ".join(str(v) for v in plugin.params.values()) if plugin.params else ""
vst = Element("VST", [plugin.name, plugin.path, str(plugin.index), "", *params_str.split(), "0", "0"]) VST3: <VST "VST3: PluginName (Vendor)" filename.vst3 0 "" uid{GUID} "">
return vst [preset_data_lines...]
VST2: <VST "VST: PluginName (Cockos)" filename.dll 0 "" uid{GUID} "">
"""
# VST3 plugins — identified by .vst3 extension
if plugin.path.endswith(".vst3"):
entry = self.VST3_REGISTRY.get(plugin.name)
if entry:
display_name, filename, uid_guid = entry
preset_data = self.VST3_PRESETS.get(plugin.name)
return vst3_element(display_name, filename, uid_guid, preset_data)
# Fallback: match by filename against registry entries
for registry_entry in self.VST3_REGISTRY.values():
_, reg_filename, uid_guid = registry_entry
if reg_filename == plugin.path:
display_name = plugin.name if plugin.name.startswith("VST3:") else f"VST3: {plugin.name}"
preset_data = self.VST3_PRESETS.get(plugin.name)
return vst3_element(display_name, plugin.path, uid_guid, preset_data)
# Final fallback: use plugin.name as-is
display_name = plugin.name if plugin.name.startswith("VST3:") else f"VST3: {plugin.name}"
return vst3_element(display_name, plugin.path)
# Built-in VST2 plugins (ReaEQ, ReaComp, etc.) — .dll format
dll_map = {
"ReaEQ": "reaeq.dll",
"ReaComp": "reacomp.dll",
"ReaVerbate": "reaverbate.dll",
"ReaDelay": "readelay.dll",
"ReaCast": "reacast.dll",
"ReaFIR": "reafir.dll",
"ReaGate": "reagate.dll",
"ReaLimit": "realimit.dll",
"ReaPitch": "reapitch.dll",
"ReaVerb": "reaverb.dll",
"ReaXComp": "reaxcomp.dll",
}
dll_name = dll_map.get(plugin.name, plugin.path)
param_slots = ["0"] * 19
return Element("VST", [plugin.name, dll_name, "0", "", *param_slots])
def _build_clip(self, clip: ClipDef) -> Element: def _build_clip(self, clip: ClipDef) -> Element:
"""Build an ITEM Element.""" """Build an ITEM Element."""

View File

@@ -25,50 +25,33 @@ def compose_via_builder(
This lets us test the compose logic without hitting the filesystem for samples. This lets us test the compose logic without hitting the filesystem for samples.
""" """
import json
from pathlib import Path as P
_ROOT = P(__file__).parent.parent
from src.composer.rhythm import get_notes from src.composer.rhythm import get_notes
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
genre_bar_map = {"reggaeton": 64, "trap": 32, "house": 64, "drill": 32} genre_path = _ROOT / "knowledge" / "genres" / f"{genre.lower()}_2009.json"
bar_count = genre_bar_map.get(genre.lower(), 48) with open(genre_path, "r", encoding="utf-8") as f:
genre_config = json.load(f)
# Drum tracks from scripts.compose import (
drum_tracks = [] build_section_tracks, create_return_tracks, EFFECT_ALIASES,
for role, generator_name in [ build_fx_chain, build_sampler_plugin,
("kick", "kick_main_notes"),
("snare", "snare_verse_notes"),
("hihat", "hihat_16th_notes"),
("perc", "perc_combo_notes"),
]:
note_dict = get_notes(generator_name, bar_count)
midi_notes = rhythm_to_midi(note_dict)
clip = ClipDef(
position=0.0,
length=bar_count * 4.0,
name=f"{role.capitalize()} Pattern",
midi_notes=midi_notes,
) )
drum_tracks.append(TrackDef(name=role.capitalize(), clips=[clip])) from src.selector import SampleSelector
# Melodic tracks (no selector — audio_path stays None) index_path = _ROOT / "data" / "sample_index.json"
for role, generator_fn in [ selector = SampleSelector(str(index_path))
("bass", bass_tresillo),
("lead", lead_hook), tracks, sections = build_section_tracks(genre_config, selector, key, bpm)
("chords", chords_block), return_tracks = create_return_tracks()
("pad", pad_sustain),
]:
note_list = generator_fn(key=key, bars=bar_count)
midi_notes = melodic_to_midi(note_list)
clip = ClipDef(
position=0.0,
length=bar_count * 4.0,
name=f"{role.capitalize()} MIDI",
midi_notes=midi_notes,
)
drum_tracks.append(TrackDef(name=role.capitalize(), clips=[clip]))
meta = SongMeta(bpm=bpm, key=key, title=f"{genre.capitalize()} Track") meta = SongMeta(bpm=bpm, key=key, title=f"{genre.capitalize()} Track")
return SongDefinition(meta=meta, tracks=drum_tracks) return SongDefinition(meta=meta, tracks=tracks + return_tracks, sections=sections)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -105,8 +88,8 @@ class TestComposeRppOutput:
assert output.exists(), f"Expected {output} to exist" assert output.exists(), f"Expected {output} to exist"
def test_compose_rpp_has_min_4_tracks(self, tmp_path): def test_compose_rpp_has_min_6_tracks(self, tmp_path):
"""The .rpp output contains at least 4 <TRACK blocks.""" """The .rpp output contains at least 6 <TRACK blocks (roles + 2 returns)."""
output = tmp_path / "track.rpp" output = tmp_path / "track.rpp"
with patch("scripts.compose.SampleSelector") as mock_selector_cls: with patch("scripts.compose.SampleSelector") as mock_selector_cls:
@@ -131,7 +114,35 @@ class TestComposeRppOutput:
content = output.read_text(encoding="utf-8") content = output.read_text(encoding="utf-8")
track_count = content.count("<TRACK") track_count = content.count("<TRACK")
assert track_count >= 4, f"Expected >= 4 tracks, got {track_count}" # 6 roles + 2 return tracks = 8 minimum
assert track_count >= 6, f"Expected >= 6 tracks, got {track_count}"
def test_compose_has_fxchain(self, tmp_path):
"""The .rpp output contains FXCHAIN elements."""
output = tmp_path / "track.rpp"
with patch("scripts.compose.SampleSelector") as mock_selector_cls:
mock_selector = MagicMock()
mock_selector.select_one.return_value = None
mock_selector_cls.return_value = mock_selector
from scripts.compose import main
import sys
original_argv = sys.argv
try:
sys.argv = [
"compose",
"--genre", "reggaeton",
"--bpm", "95",
"--key", "Am",
"--output", str(output),
]
main()
finally:
sys.argv = original_argv
content = output.read_text(encoding="utf-8")
assert "FXCHAIN" in content, "Expected FXCHAIN in output"
def test_compose_invalid_bpm_raises(self): def test_compose_invalid_bpm_raises(self):
"""main() with bpm=0 raises ValueError.""" """main() with bpm=0 raises ValueError."""
@@ -168,3 +179,45 @@ class TestComposeRppOutput:
main() main()
finally: finally:
sys.argv = original_argv sys.argv = original_argv
class TestSectionBuilderIntegration:
"""Test section builder integration with SongDefinition."""
def test_build_section_tracks_returns_tracks_and_sections(self):
"""build_section_tracks returns (tracks, sections) tuple."""
import json
from pathlib import Path as P
_ROOT = P(__file__).parent.parent
from scripts.compose import build_section_tracks
from src.selector import SampleSelector
genre_path = _ROOT / "knowledge" / "genres" / "reggaeton_2009.json"
with open(genre_path, "r", encoding="utf-8") as f:
genre_config = json.load(f)
index_path = _ROOT / "data" / "sample_index.json"
selector = SampleSelector(str(index_path))
tracks, sections = build_section_tracks(genre_config, selector, "Am", 95.0)
assert len(tracks) > 0, "Expected at least one track"
assert len(sections) > 0, "Expected at least one section"
# Sections should have names
for sec in sections:
assert sec.name in ["intro", "verse", "chorus", "outro",
"verse2", "chorus2", "bridge", "chorus3"]
def test_song_definition_has_sections_field(self):
"""SongDefinition has a sections field."""
from src.core.schema import SongDefinition, SongMeta, SectionDef
meta = SongMeta(bpm=95, key="Am")
song = SongDefinition(
meta=meta,
tracks=[],
sections=[SectionDef(name="intro", bars=4, energy=0.3)],
)
assert len(song.sections) == 1
assert song.sections[0].name == "intro"

View File

@@ -7,7 +7,7 @@ sys.path.insert(0, str(Path(__file__).parents[1]))
import pytest import pytest
import tempfile import tempfile
from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote from src.core.schema import SongDefinition, SongMeta, TrackDef, ClipDef, MidiNote, PluginDef
from src.reaper_builder import RPPBuilder from src.reaper_builder import RPPBuilder
@@ -174,3 +174,272 @@ class TestRPPBuilderMasterTrack:
assert "NAME master" in content assert "NAME master" in content
finally: finally:
Path(tmp_path).unlink(missing_ok=True) Path(tmp_path).unlink(missing_ok=True)
class TestRPPProjectFormat:
"""Test output matches the ground truth format from output/test_vst3.rpp."""
def test_header_version_765_win64(self):
"""REAPER_PROJECT line has version 7.65/win64 (not unquoted 6.0)."""
meta = SongMeta(bpm=95, key="Am", title="Test")
song = SongDefinition(meta=meta, tracks=[])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
first_line = content.split('\n', 1)[0]
# Version must be 7.65/win64, not 6.0
assert "7.65/win64" in first_line
# Must NOT contain the old 6.0 version
assert "6.0" not in first_line
finally:
Path(tmp_path).unlink(missing_ok=True)
def test_peakgain_and_panlaw_present(self):
"""Output contains PEAKGAIN and PANLAW lines from ground truth."""
meta = SongMeta(bpm=95, key="Am")
song = SongDefinition(meta=meta, tracks=[])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
assert "PEAKGAIN 1" in content
assert "PANLAW 1" in content
assert "SAMPLERATE 44100" in content
finally:
Path(tmp_path).unlink(missing_ok=True)
def test_track_has_all_default_attributes(self):
"""TRACK element contains all 25 default attributes from ground truth."""
meta = SongMeta(bpm=95, key="Am")
track = TrackDef(name="Test Track", clips=[])
song = SongDefinition(meta=meta, tracks=[track])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
# Key attributes that uniquely identify the ground truth format
assert "PEAKCOL 16576" in content
assert "BEAT -1" in content
assert "AUTOMODE 0" in content
assert "NCHAN 2" in content
assert "FX 1" in content
assert "TRACKID {" in content
assert "VU 64" in content
assert "INQ 0 0 0 0.5" in content
finally:
Path(tmp_path).unlink(missing_ok=True)
def test_fxchain_has_required_structure(self):
"""FXCHAIN block has WNDRECT, SHOW, BYPASS, FXID lines."""
meta = SongMeta(bpm=95, key="Am")
plugin = PluginDef(name="Serum2", path="Serum2.vst3", index=0)
track = TrackDef(name="Bass", clips=[], plugins=[plugin])
song = SongDefinition(meta=meta, tracks=[track])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
assert "WNDRECT 24 52 655 408" in content
assert "SHOW 0" in content
assert "DOCKED 0" in content
assert "BYPASS 0 0 0" in content
assert "FXID {" in content
finally:
Path(tmp_path).unlink(missing_ok=True)
def test_metronome_block_structure(self):
"""METRONOME is a parent element with proper children, not flat attributes."""
meta = SongMeta(bpm=95, key="Am")
song = SongDefinition(meta=meta, tracks=[])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
assert "<METRONOME" in content
assert "PATTERNSTR ABBB" in content
assert "SAMPLES \"\" \"\" \"\" \"\"" in content
finally:
Path(tmp_path).unlink(missing_ok=True)
def test_master_track_has_fxchain(self):
"""Master track has FXCHAIN block (MASTER_FX 1 requires it)."""
meta = SongMeta(bpm=95, key="Am")
song = SongDefinition(meta=meta, tracks=[])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
# Count FXCHAIN blocks - master + any user tracks
fxchain_count = content.count("<FXCHAIN")
assert fxchain_count >= 1, f"Expected at least 1 FXCHAIN, got {fxchain_count}"
# Master track FXCHAIN has master-specific FXID
assert "FXID {" in content
finally:
Path(tmp_path).unlink(missing_ok=True)
class TestVST3GUIDPresence:
"""Test that VST3 plugins output with uniqueid{GUID} tokens."""
def test_vst3_plugin_output_contains_guid(self):
"""VST3 element contains GUID from registry lookup."""
meta = SongMeta(bpm=95, key="Am", title="VST3 Test")
plugin = PluginDef(name="Serum2", path="Serum2.vst3", index=0)
track = TrackDef(name="Bass", clips=[], plugins=[plugin])
song = SongDefinition(meta=meta, tracks=[track])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
# Must contain the GUID token from VST3_REGISTRY["Serum2"]
assert "691258006{56534558667350736572756D20320000}" in content
# Must also contain correct display name and filename
assert "VST3: Serum 2 (Xfer Records)" in content
assert "Serum2.vst3" in content
finally:
Path(tmp_path).unlink(missing_ok=True)
def test_fabfilter_proq3_contains_guid(self):
"""FabFilter Pro-Q 3 outputs with correct GUID."""
meta = SongMeta(bpm=95, key="Am", title="VST3 Test")
plugin = PluginDef(name="FabFilter Pro-Q 3", path="FabFilter Pro-Q 3.vst3", index=0)
track = TrackDef(name="Lead", clips=[], plugins=[plugin])
song = SongDefinition(meta=meta, tracks=[track])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
# Must contain the GUID token from VST3_REGISTRY["FabFilter Pro-Q 3"]
assert "756089518{72C4DB717A4D459AB97E51745D84B39D}" in content
assert "VST3: Pro-Q 3 (FabFilter)" in content
assert "FabFilter Pro-Q 3.vst3" in content
finally:
Path(tmp_path).unlink(missing_ok=True)
class TestVST3PresetData:
"""Test that VST3 plugins include base64 preset data inside VST blocks."""
def test_serum2_vst_contains_preset_data(self):
"""Serum2 VST block contains base64 preset lines."""
meta = SongMeta(bpm=95, key="Am", title="VST3 Preset Test")
plugin = PluginDef(name="Serum2", path="Serum2.vst3", index=0)
track = TrackDef(name="Bass", clips=[], plugins=[plugin])
song = SongDefinition(meta=meta, tracks=[track])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
# Serum2 preset starts with this magic line (first base64 line)
assert "Z4R+ae5e7f4CAAAAAQAAAAAAAAACAAAAAAAAAAIAAAABAAAAAAAAAAIAAAAAAAAAbQgAAAEAAAAAAAAA" in content
# Last line of all presets is the same terminator
assert "AFByb2dyYW0gMQAAAAAA" in content
# A mid-preset line (line 2)
assert "zQQAAAEAAABYZmVySnNvbgC5AAAAAAAAAHsiY29tcG9uZW50IjoicHJvY2Vzc29yIiwiaGFzaCI6IjgxZTEyMWYxNGI2Y2IyYjA2YzMzMjQzZDk1ZDIxYWIxIiwicHJv" in content
finally:
Path(tmp_path).unlink(missing_ok=True)
def test_fabfilter_proq3_vst_contains_preset_data(self):
"""FabFilter Pro-Q 3 VST block contains base64 preset lines."""
meta = SongMeta(bpm=95, key="Am", title="VST3 Preset Test")
plugin = PluginDef(name="FabFilter Pro-Q 3", path="FabFilter Pro-Q 3.vst3", index=0)
track = TrackDef(name="Lead", clips=[], plugins=[plugin])
song = SongDefinition(meta=meta, tracks=[track])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
# Pro-Q 3 preset starts with this line
assert "rgIRLe5e7f4EAAAAAQAAAAAAAAACAAAAAAAAAAQAAAAAAAAACAAAAAAAAAACAAAAAQAAAAAAAAACAAAAAAAAAAoGAAABAAAAAAAAAA==" in content
assert "AFByb2dyYW0gMQAAAAAA" in content
finally:
Path(tmp_path).unlink(missing_ok=True)
def test_all_registry_plugins_have_preset_data(self):
"""All 10 VST3 plugins in VST3_REGISTRY have preset data."""
meta = SongMeta(bpm=95, key="Am", title="VST3 Preset Test")
# Use actual filenames from registry so _build_plugin recognizes them as VST3
plugins = [
PluginDef(name=name, path=entry[1], index=i)
for i, (name, entry) in enumerate(RPPBuilder.VST3_REGISTRY.items())
]
track = TrackDef(name="Test", clips=[], plugins=plugins)
song = SongDefinition(meta=meta, tracks=[track])
builder = RPPBuilder(song)
with tempfile.NamedTemporaryFile(
mode="w", suffix=".rpp", delete=False, encoding="utf-8"
) as f:
tmp_path = f.name
try:
builder.write(tmp_path)
content = Path(tmp_path).read_text(encoding="utf-8")
for name, preset_lines in RPPBuilder.VST3_PRESETS.items():
assert len(preset_lines) > 0, f"{name} has no preset lines"
# Check first preset line — most distinctive, no collision risk
first_line = preset_lines[0]
assert first_line in content, f"{name} preset line not found in output"
finally:
Path(tmp_path).unlink(missing_ok=True)

View File

@@ -0,0 +1,209 @@
"""Tests for section builder — SectionDef, build_fx_chain, effect alias mapping."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1]))
import pytest
from src.core.schema import SectionDef, PluginDef
class TestSectionDef:
"""Test SectionDef dataclass."""
def test_section_def_instantiation(self):
"""SectionDef creates with name, bars, energy."""
section = SectionDef(name="chorus", bars=8, energy=0.9)
assert section.name == "chorus"
assert section.bars == 8
assert section.energy == 0.9
# velocity_mult and vol_mult default to 1.0 (not derived from energy)
assert section.velocity_mult == 1.0
assert section.vol_mult == 1.0
def test_section_def_default_energy(self):
"""SectionDef defaults energy to 0.5, velocity_mult/vol_mult to 1.0."""
section = SectionDef(name="verse", bars=8)
assert section.energy == 0.5
assert section.velocity_mult == 1.0
assert section.vol_mult == 1.0
def test_section_def_custom_mults(self):
"""SectionDef accepts custom velocity_mult and vol_mult via __init__ args."""
section = SectionDef(
name="intro", bars=4, energy=0.3,
velocity_mult=0.4, vol_mult=0.6
)
assert section.velocity_mult == 0.4
assert section.vol_mult == 0.6
class TestVST3Effects:
"""Test VST3 premium plugin mappings."""
def test_vst3_effects_defined(self):
"""_VST3_EFFECTS maps effect names to VST3 plugins."""
from scripts.compose import _VST3_EFFECTS
assert "Pro-Q 3" in _VST3_EFFECTS
assert "Pro-C 2" in _VST3_EFFECTS
assert "Pro-R 2" in _VST3_EFFECTS
assert "Timeless 3" in _VST3_EFFECTS
def test_fruity_eq_maps_to_proq3(self):
"""Fruity Parametric EQ 2 → FabFilter Pro-Q 3 via normalization."""
from scripts.compose import _VST3_EFFECTS
# Fruity Parametric EQ 2 normalizes to Pro-Q 3
registry_key, filename = _VST3_EFFECTS["Pro-Q 3"]
assert registry_key == "FabFilter Pro-Q 3"
assert filename == "FabFilter Pro-Q 3.vst3"
def test_fruity_compressor_maps_to_proc2(self):
"""Fruity Compressor → FabFilter Pro-C 2 via normalization."""
from scripts.compose import _VST3_EFFECTS
registry_key, filename = _VST3_EFFECTS["Pro-C 2"]
assert registry_key == "FabFilter Pro-C 2"
assert filename == "FabFilter Pro-C 2.vst3"
def test_pro_r_maps_to_pror2(self):
"""Pro-R 2 → FabFilter Pro-R 2."""
from scripts.compose import _VST3_EFFECTS
registry_key, filename = _VST3_EFFECTS["Pro-R 2"]
assert registry_key == "FabFilter Pro-R 2"
assert filename == "FabFilter Pro-R 2.vst3"
def test_unknown_effect_returns_none(self):
"""Unknown effect names return no VST3 info."""
from scripts.compose import _VST3_EFFECTS
assert _VST3_EFFECTS.get("Some Unknown Plugin") is None
class TestBuildFxChain:
"""Test build_fx_chain function."""
def test_build_fx_chain_drums(self):
"""build_fx_chain returns PluginDef list for drums role."""
from scripts.compose import build_fx_chain
genre_config = {
"mix": {
"per_role": {
"drums": {
"effects": ["Fruity Parametric EQ 2", "Fruity Compressor"],
}
}
}
}
plugins = build_fx_chain("drums", genre_config, [])
assert len(plugins) == 2
# Fruity Parametric EQ 2 → Pro-Q 3
assert "FabFilter" in plugins[0].name
assert ".vst3" in plugins[0].path
# Fruity Compressor → Pro-C 2
assert "FabFilter" in plugins[1].name
def test_build_fx_chain_bass(self):
"""build_fx_chain returns PluginDef list for bass role."""
from scripts.compose import build_fx_chain
genre_config = {
"mix": {
"per_role": {
"bass": {
"effects": ["Fruity Parametric EQ 2", "Saturn 2"],
}
}
}
}
plugins = build_fx_chain("bass", genre_config, [])
assert len(plugins) == 2
# Saturn 2 → FabFilter Saturn 2
assert "Saturn" in plugins[1].name
def test_build_fx_chain_empty_effects(self):
"""build_fx_chain returns empty list when no effects configured."""
from scripts.compose import build_fx_chain
genre_config = {"mix": {"per_role": {}}}
plugins = build_fx_chain("drums", genre_config, [])
assert plugins == []
def test_build_fx_chain_unknown_effect_uses_name(self):
"""Unknown effect names are used as-is."""
from scripts.compose import build_fx_chain
genre_config = {
"mix": {
"per_role": {
"lead": {
"effects": ["Some Unknown FX"],
}
}
}
}
plugins = build_fx_chain("lead", genre_config, [])
# Unknown effects are skipped (not added to plugins)
assert len(plugins) == 0
class TestInstrumentPlugins:
"""Test instrument plugin helpers (Serum 2, Omnisphere)."""
def test_serum2_plugin_def(self):
"""serum2() returns PluginDef with registry key name."""
from scripts.compose import serum2
plugin = serum2()
assert plugin.name == "Serum2"
assert plugin.path == "Serum2.vst3"
assert plugin.index == 0
def test_omnisphere_plugin_def(self):
"""omnisphere() returns PluginDef with registry key name."""
from scripts.compose import omnisphere
plugin = omnisphere()
assert plugin.name == "Omnisphere"
assert plugin.path == "Omnisphere.vst3"
assert plugin.index == 0
class TestCreateReturnTracks:
"""Test create_return_tracks function."""
def test_create_return_tracks_returns_two(self):
"""create_return_tracks returns [Reverb, Delay] tracks."""
from scripts.compose import create_return_tracks
tracks = create_return_tracks()
assert len(tracks) == 2
assert tracks[0].name == "Reverb"
assert tracks[1].name == "Delay"
def test_reverb_track_has_pro_r2(self):
"""Reverb return track has FabFilter Pro-R 2 plugin."""
from scripts.compose import create_return_tracks
tracks = create_return_tracks()
reverb = tracks[0]
assert len(reverb.plugins) == 1
assert "FabFilter" in reverb.plugins[0].name
assert ".vst3" in reverb.plugins[0].path
def test_delay_track_has_timeless3(self):
"""Delay return track has FabFilter Timeless 3 plugin."""
from scripts.compose import create_return_tracks
tracks = create_return_tracks()
delay = tracks[1]
assert len(delay.plugins) == 1
assert "Timeless" in delay.plugins[0].name
assert ".vst3" in delay.plugins[0].path
def test_return_tracks_have_volume_0_7(self):
"""Return tracks have volume 0.7."""
from scripts.compose import create_return_tracks
tracks = create_return_tracks()
for t in tracks:
assert t.volume == 0.7