From 53741b48b636b19e0412cf6fd327c51197ff7653 Mon Sep 17 00:00:00 2001 From: renato97 Date: Sun, 3 May 2026 14:00:11 -0300 Subject: [PATCH] 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 --- scripts/compose.py | 402 ++++++++++++++---- src/core/schema.py | 21 + src/reaper_builder/__init__.py | 648 +++++++++++++++++++++++++++++- tests/test_compose_integration.py | 131 ++++-- tests/test_reaper_builder.py | 271 ++++++++++++- tests/test_section_builder.py | 209 ++++++++++ 6 files changed, 1541 insertions(+), 141 deletions(-) create mode 100644 tests/test_section_builder.py diff --git a/scripts/compose.py b/scripts/compose.py index 1c017fc..d41b47b 100644 --- a/scripts/compose.py +++ b/scripts/compose.py @@ -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 .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) diff --git a/src/core/schema.py b/src/core/schema.py index 2721673..c6fc2f8 100644 --- a/src/core/schema.py +++ b/src/core/schema.py @@ -170,6 +170,25 @@ class TrackDef: 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.0–1.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 class SongDefinition: """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") section_template: Section template name (default "standard") samples: Sample file map (name → filename) + sections: Section definitions in playback order """ meta: SongMeta @@ -193,6 +213,7 @@ class SongDefinition: progression_name: str = "i-VII-VI-VII" section_template: str = "standard" samples: dict[str, str] = field(default_factory=dict) + sections: list[SectionDef] = field(default_factory=list) # ------------------------------------------------------------------------- # Validation diff --git a/src/reaper_builder/__init__.py b/src/reaper_builder/__init__.py index 9abfb63..ba08ae2 100644 --- a/src/reaper_builder/__init__.py +++ b/src/reaper_builder/__init__.py @@ -14,6 +14,168 @@ from rpp import Element, dumps 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 (, , 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 # --------------------------------------------------------------------------- @@ -23,6 +185,40 @@ def _make_guid() -> str: 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): + + 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 # --------------------------------------------------------------------------- @@ -47,23 +243,51 @@ class RPPBuilder: OSError: If the file cannot be written. """ root = self._build_element() + content = dumps(root) + # CRITICAL 1: quote the version string in the header + # rpp library produces but REAPER needs quotes + content = content.replace(' Element: """Build the Element tree for the .rpp file.""" m = self.song.meta - # Project root - root = Element("REAPER_PROJECT", ["0.1", "6.0", str(int(uuid.uuid4().time))]) + # Project root — version from test_vst3.rpp line 1 + 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)]) # Master track - master = Element("TRACK", [_make_guid()]) + master_guid = _make_guid() + master = Element("TRACK", [master_guid]) master.append(['NAME', "master"]) 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) # User tracks @@ -73,35 +297,425 @@ class RPPBuilder: return root def _build_track(self, track: TrackDef) -> Element: - """Build a TRACK Element.""" - track_elem = Element("TRACK", [_make_guid()]) + """Build a TRACK Element with all default attributes from test_vst3.rpp.""" + track_guid = _make_guid() + track_elem = Element("TRACK", [f"{{{track_guid}}}"]) track_elem.append(["NAME", track.name]) - vol = track.volume - pan = track.pan - track_elem.append([f"VOLPAN", f"{vol:.6f}", f"{pan:.6f}", "-1", "-1", "1"]) + # 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 + pan = track.pan + 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: - track_elem.append(["COLOR", str(track.color)]) + # Override NCHAN based on track configuration + # 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: fxchain = Element("FXCHAIN", []) + for line in _FXCHAIN_HEADER: + fxchain.append([v for v in line]) for plugin in track.plugins: 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) + # 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) for clip in track.clips: track_elem.append(self._build_clip(clip)) 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: - """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"]) - return vst + """Build a VST Element inside FXCHAIN. + + VST3: + [preset_data_lines...] + VST2: + """ + # 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: """Build an ITEM Element.""" diff --git a/tests/test_compose_integration.py b/tests/test_compose_integration.py index 5d9eeb1..72b2d8a 100644 --- a/tests/test_compose_integration.py +++ b/tests/test_compose_integration.py @@ -25,50 +25,33 @@ def compose_via_builder( 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.melodic import bass_tresillo, lead_hook, chords_block, pad_sustain from src.composer.converters import rhythm_to_midi, melodic_to_midi - genre_bar_map = {"reggaeton": 64, "trap": 32, "house": 64, "drill": 32} - bar_count = genre_bar_map.get(genre.lower(), 48) + genre_path = _ROOT / "knowledge" / "genres" / f"{genre.lower()}_2009.json" + with open(genre_path, "r", encoding="utf-8") as f: + genre_config = json.load(f) - # Drum tracks - drum_tracks = [] - for role, generator_name in [ - ("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 scripts.compose import ( + build_section_tracks, create_return_tracks, EFFECT_ALIASES, + build_fx_chain, build_sampler_plugin, + ) + from src.selector import SampleSelector - # Melodic tracks (no selector — audio_path stays None) - for role, generator_fn in [ - ("bass", bass_tresillo), - ("lead", lead_hook), - ("chords", chords_block), - ("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])) + index_path = _ROOT / "data" / "sample_index.json" + selector = SampleSelector(str(index_path)) + + tracks, sections = build_section_tracks(genre_config, selector, key, bpm) + return_tracks = create_return_tracks() 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" - def test_compose_rpp_has_min_4_tracks(self, tmp_path): - """The .rpp output contains at least 4 = 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): """main() with bpm=0 raises ValueError.""" @@ -168,3 +179,45 @@ class TestComposeRppOutput: main() finally: 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" \ No newline at end of file diff --git a/tests/test_reaper_builder.py b/tests/test_reaper_builder.py index f9de456..900825f 100644 --- a/tests/test_reaper_builder.py +++ b/tests/test_reaper_builder.py @@ -7,7 +7,7 @@ sys.path.insert(0, str(Path(__file__).parents[1])) import pytest 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 @@ -174,3 +174,272 @@ class TestRPPBuilderMasterTrack: assert "NAME master" in content finally: 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 "= 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) diff --git a/tests/test_section_builder.py b/tests/test_section_builder.py new file mode 100644 index 0000000..7b0b531 --- /dev/null +++ b/tests/test_section_builder.py @@ -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 \ No newline at end of file