From 029fb92ea0d6a641ca450be267c3c69bbcdf2f28 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Thu, 28 May 2026 23:26:59 +1200 Subject: [PATCH 1/6] refactor(backend): migrate sample_synthesis.write_midi to symusic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops pretty_midi from the sample synthesis MIDI-write path; symusic becomes the single canonical MIDI library on the backend. The .mid file is a spec-conformant Standard MIDI file that DAWs parse identically, so the change is non-audible — the existing parity gate in tests/test_sample_synthesis.WriteMidiTests reads the symusic output back via pretty_midi and confirms the notes survive the round trip. Module docstring updated to call out the new writer + the parity contract for the next maintainer. 919 / 919 backend unittests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/backend/sample_synthesis.py | 43 +++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/apps/backend/sample_synthesis.py b/apps/backend/sample_synthesis.py index b81a1547..c18eedf9 100644 --- a/apps/backend/sample_synthesis.py +++ b/apps/backend/sample_synthesis.py @@ -10,8 +10,12 @@ don't need to branch on which backend was selected. The selected backend is recorded on `RenderResult.backend` so it can flow into the manifest. -MIDI artifacts are emitted via `pretty_midi`, which is already a hard dep, so -the user can drop a `.mid` into Ableton even if the audio render is rough. +MIDI artifacts are emitted via `symusic` (fast C++ core), so the user can drop +a `.mid` into Ableton even if the audio render is rough. The MIDI file is a +spec-conformant artifact that DAWs read as a fixed format — switching writers +is a non-audible change, but the test in +`tests/test_sample_synthesis.WriteMidiTests` round-trips via `pretty_midi` to +keep the parity contract honest. """ from __future__ import annotations @@ -23,8 +27,8 @@ from typing import Literal import numpy as np -import pretty_midi # type: ignore[import-untyped] import soundfile as sf +from symusic import Note, Score, Tempo, Track from sample_theory import ClipPlan @@ -111,24 +115,35 @@ def write_wav(samples: np.ndarray, *, path: Path, sample_rate: int = SAMPLE_RATE def write_midi(plan: ClipPlan, *, path: Path) -> None: - """Emit a MIDI file from a ClipPlan so users can audition in Ableton.""" - pm = pretty_midi.PrettyMIDI(initial_tempo=plan.tempo_bpm) - inst = pretty_midi.Instrument(program=plan.program) + """Emit a MIDI file from a ClipPlan so users can audition in Ableton. + + Uses ``symusic`` (C++ core) so the backend has a single canonical MIDI + library; the output is a spec-conformant Standard MIDI file that any DAW + parses identically. Tempo is emitted at ``t=0`` to match the static plan, + and the GM program is set on the track header so Ableton picks the right + default sound. + """ + score = Score(480, ttype="Second") + score.tempos.append(Tempo(time=0.0, qpm=float(plan.tempo_bpm), ttype="Second")) + track = Track( + name="audition", program=int(plan.program), is_drum=False, ttype="Second" + ) beats_per_second = plan.tempo_bpm / 60.0 for note in plan.notes: start_seconds = note.start_beat / beats_per_second - end_seconds = (note.start_beat + note.duration_beats) / beats_per_second - inst.notes.append( - pretty_midi.Note( - velocity=int(np.clip(note.velocity, 1, 127)), + duration_seconds = max(0.0, note.duration_beats / beats_per_second) + track.notes.append( + Note( + time=float(start_seconds), + duration=float(duration_seconds), pitch=int(np.clip(note.pitch_midi, 0, 127)), - start=float(start_seconds), - end=float(end_seconds), + velocity=int(np.clip(note.velocity, 1, 127)), + ttype="Second", ) ) - pm.instruments.append(inst) + score.tracks.append(track) path.parent.mkdir(parents=True, exist_ok=True) - pm.write(str(path)) + score.dump_midi(str(path)) # --- FluidSynth path -------------------------------------------------------- # From bac7728348872dc37e4881fd88018c76e4acf8e2 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Thu, 28 May 2026 23:30:13 +1200 Subject: [PATCH 2/6] refactor(backend): migrate research MIDI parsers off pretty_midi to symusic Research-only path: polyphonic_evaluation.summarize_midi_file and scripts/import_midi_to_ground_truth.py now parse MIDI via symusic.Score(path).to("Second") instead of pretty_midi.PrettyMIDI. Test fixture writers in test_polyphonic_evaluation.py and test_phase1_evaluation_transcription.py likewise build their tiny .mid inputs through symusic. Drops three pretty_midi importers; only test_sample_synthesis.py keeps pretty_midi as the deliberate parity reader for write_midi's output. 919 / 919 backend unittests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/backend/polyphonic_evaluation.py | 16 +++++++---- .../scripts/import_midi_to_ground_truth.py | 20 +++++++------ .../test_phase1_evaluation_transcription.py | 22 ++++++++++----- .../tests/test_polyphonic_evaluation.py | 28 ++++++++++++------- 4 files changed, 54 insertions(+), 32 deletions(-) diff --git a/apps/backend/polyphonic_evaluation.py b/apps/backend/polyphonic_evaluation.py index 78131924..aaf37c5e 100644 --- a/apps/backend/polyphonic_evaluation.py +++ b/apps/backend/polyphonic_evaluation.py @@ -17,8 +17,8 @@ from pathlib import Path from typing import Any, Callable -import pretty_midi import soundfile as sf +from symusic import Score REPO_DIR = Path(__file__).resolve().parent DEFAULT_OUTPUT_DIR = REPO_DIR / ".runtime" / "polyphonic_eval" @@ -53,8 +53,10 @@ def build_manual_scorecard(existing: dict[str, Any] | None = None) -> dict[str, def summarize_midi_file(midi_path: Path, audio_duration_seconds: float) -> dict[str, Any]: - midi_data = pretty_midi.PrettyMIDI(str(midi_path)) - notes = [note for instrument in midi_data.instruments for note in instrument.notes] + # Loaded scores arrive in Tick units; convert to Second so note.time and + # note.duration are seconds, matching the metric definitions below. + score = Score(midi_path).to("Second") + notes = [note for track in score.tracks for note in track.notes] if len(notes) == 0: return { "noteCount": 0, @@ -72,11 +74,13 @@ def summarize_midi_file(midi_path: Path, audio_duration_seconds: float) -> dict[ pitch_values: list[int] = [] events: list[tuple[float, int]] = [] for note in notes: - duration = max(0.0, float(note.end) - float(note.start)) + start = float(note.time) + duration = max(0.0, float(note.duration)) + end = start + duration total_note_duration += duration pitch_values.append(int(note.pitch)) - events.append((float(note.start), 1)) - events.append((float(note.end), -1)) + events.append((start, 1)) + events.append((end, -1)) events.sort(key=lambda item: (item[0], item[1])) max_polyphony = 0 diff --git a/apps/backend/scripts/import_midi_to_ground_truth.py b/apps/backend/scripts/import_midi_to_ground_truth.py index e75a1f59..0e678af6 100755 --- a/apps/backend/scripts/import_midi_to_ground_truth.py +++ b/apps/backend/scripts/import_midi_to_ground_truth.py @@ -13,16 +13,16 @@ import sys from pathlib import Path -import pretty_midi +from symusic import Score -def _flatten_notes(midi: pretty_midi.PrettyMIDI, offset_seconds: float) -> list[dict]: +def _flatten_notes(score: Score, offset_seconds: float) -> list[dict]: + """Flatten all notes in a Second-unit Score into the ground-truth shape.""" notes: list[dict] = [] - for instrument in midi.instruments: - for note in instrument.notes: - start = float(note.start) + offset_seconds - end = float(note.end) + offset_seconds - duration = max(0.0, end - start) + for track in score.tracks: + for note in track.notes: + start = float(note.time) + offset_seconds + duration = max(0.0, float(note.duration)) notes.append( { "pitchMidi": int(note.pitch), @@ -108,12 +108,14 @@ def main() -> None: raise SystemExit(2) try: - midi = pretty_midi.PrettyMIDI(str(args.midi_path)) + # Score loads as Tick by default; convert so note.time / note.duration + # are in seconds for the ground-truth schema. + score = Score(args.midi_path).to("Second") except Exception as exc: print(f"error: failed to parse MIDI: {exc}", file=sys.stderr) raise SystemExit(2) from exc - notes = _flatten_notes(midi, args.offset_seconds) + notes = _flatten_notes(score, args.offset_seconds) overlaps = _detect_overlaps(notes) if len(overlaps) > 0: diff --git a/apps/backend/tests/test_phase1_evaluation_transcription.py b/apps/backend/tests/test_phase1_evaluation_transcription.py index a5496d77..dd7aa020 100644 --- a/apps/backend/tests/test_phase1_evaluation_transcription.py +++ b/apps/backend/tests/test_phase1_evaluation_transcription.py @@ -13,7 +13,7 @@ import unittest from pathlib import Path -import pretty_midi +from symusic import Note, Score, Tempo, Track from phase1_evaluation import ( DEFAULT_MANIFEST_PATH, @@ -272,14 +272,22 @@ def fake_runner(_path: Path, _flags: list[str]) -> dict: class ImportMidiScriptTests(unittest.TestCase): def _write_midi(self, path: Path, notes: list[tuple[int, float, float]]) -> None: - midi = pretty_midi.PrettyMIDI() - instrument = pretty_midi.Instrument(program=0) + score = Score(480, ttype="Second") + score.tempos.append(Tempo(time=0.0, qpm=120.0, ttype="Second")) + track = Track(name="ref", program=0, ttype="Second") for pitch, start, end in notes: - instrument.notes.append( - pretty_midi.Note(velocity=90, pitch=pitch, start=start, end=end) + duration = max(0.0, end - start) + track.notes.append( + Note( + time=float(start), + duration=float(duration), + pitch=int(pitch), + velocity=90, + ttype="Second", + ) ) - midi.instruments.append(instrument) - midi.write(str(path)) + score.tracks.append(track) + score.dump_midi(str(path)) def _invoke(self, midi_path: Path, *extra_args: str) -> subprocess.CompletedProcess: return subprocess.run( diff --git a/apps/backend/tests/test_polyphonic_evaluation.py b/apps/backend/tests/test_polyphonic_evaluation.py index c99bf481..bea645fc 100644 --- a/apps/backend/tests/test_polyphonic_evaluation.py +++ b/apps/backend/tests/test_polyphonic_evaluation.py @@ -6,8 +6,8 @@ from pathlib import Path import numpy as np -import pretty_midi import soundfile as sf +from symusic import Note, Score, Tempo, Track from polyphonic_evaluation import ( build_manual_scorecard, @@ -28,19 +28,27 @@ def _write_wav(path: Path, duration_seconds: float = 1.0, sample_rate: int = 220 def _write_midi(path: Path, note_specs: list[tuple[int, float, float]]) -> None: - midi = pretty_midi.PrettyMIDI() - instrument = pretty_midi.Instrument(program=0) + """Build a tiny seconds-unit Score and dump as MIDI. + + Mirrors the polyphonic-eval candidate output shape: one track, one + program, notes at `(start, end)` in seconds with constant velocity 90. + """ + score = Score(480, ttype="Second") + score.tempos.append(Tempo(time=0.0, qpm=120.0, ttype="Second")) + track = Track(name="candidate", program=0, ttype="Second") for pitch, start, end in note_specs: - instrument.notes.append( - pretty_midi.Note( + duration = max(0.0, end - start) + track.notes.append( + Note( + time=float(start), + duration=float(duration), + pitch=int(pitch), velocity=90, - pitch=pitch, - start=start, - end=end, + ttype="Second", ) ) - midi.instruments.append(instrument) - midi.write(str(path)) + score.tracks.append(track) + score.dump_midi(str(path)) class PolyphonicEvaluationTests(unittest.TestCase): From 2b01d51b746cf4c3a84e6d946bee0bfbbdb35c5a Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Thu, 28 May 2026 23:31:18 +1200 Subject: [PATCH 3/6] =?UTF-8?q?docs(backend):=20drop=20PR-E=20(symusic-bea?= =?UTF-8?q?t=204th=20method)=20=E2=80=94=20out=20of=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consolidation plan called for adding symusic-beat as an observational fourth method in beat_evaluation.py. Probing symusic's API shows get_beats / get_downbeats operate on symbolic Score tempo maps, not on audio — symusic is not an audio-analysis library and has nothing to offer this harness, which takes audio paths. Recording the rationale in the file's header docstring so a future maintainer doesn't re-do the same investigation. The pre-registered beat_this vs kick_accent gate (ADOPT_MARGIN = 0.10) is unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/backend/beat_evaluation.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/backend/beat_evaluation.py b/apps/backend/beat_evaluation.py index 99250f65..5734c8a0 100644 --- a/apps/backend/beat_evaluation.py +++ b/apps/backend/beat_evaluation.py @@ -16,6 +16,15 @@ beat_this and mir_eval are OPTIONAL: imported lazily/guarded so stride-vs-kick_accent runs in the product venv with zero new dependencies (metrics fall back to a hand-rolled F-measure when mir_eval is absent). + +Note on symusic (post-PR-A/D investigation): symusic's ``Score.get_beats`` / +``get_downbeats`` derive beats from a symbolic score's *tempo map and time +signatures*, not from audio. This harness operates on audio inputs, so symusic +has nothing to offer as an additional method here. The original consolidation +plan (PR-E) called for adding ``symusic-beat`` as an observational fourth +method — it was dropped after the probe confirmed symusic is not an +audio-analysis library. The pre-registered ``beat_this`` vs ``kick_accent`` +gate is unaffected. """ import json From 1a2b5adbd123fbe60646fbdec9d74c00cf8abc57 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Fri, 29 May 2026 00:18:11 +1200 Subject: [PATCH 4/6] feat(backend): Live 12 catalogue + Phase 2 output validator Extract gluon/AbletonLive12_MIDIRemoteScripts via stdlib `ast` into a checked-in catalogue of (device, parameter) tuples, then gate every Phase 2 Gemini recommendation against it. Recommendations whose device is unknown, whose parameter is not on the device (after a difflib fuzzy_resolve attempt), or whose phase1Fields citation is missing/empty get dropped from `abletonRecommendations` / `mixAndMasterChain` / `secretSauce.workflowSteps`, with structured `RECOMMENDATION_REJECTED` events emitted onto the existing `validationWarnings` channel. Fuzzy rewrites emit a `PARAMETER_REWRITTEN` event carrying original + resolved + requestId so nothing is rewritten silently. Why: Phase 2 was emitting Live 12 device/parameter recommendations with no machine-checkable proof the target was real. The upstream MIDI Remote Scripts are the canonical map of what Live 12 exposes; turning that into data closes the failure mode where Gemini hallucinated device names, abbreviated parameter names, or out-of-range values that the user only discovered when trying to apply them in Live. Notable details: * The numeric range gate is wired but inert when min/max are absent. Static-source extraction does not carry ranges (they live on runtime `Live.DeviceParameter` objects); reserved for future enrichment. * The source catalogue covers NATIVE Live 12 only; eight MAX_FOR_LIVE devices in the curated prompt catalog are explicitly out of scope per GOAL and fall through to the existing `_validate_phase2_catalog_entry` warning-only check. * Hand-mapped `displayName` + `category` per class lets the validator accept both `Eq8` (canonical Live class) and `EQ Eight` (Ableton UI label) without forcing the caller to pick one. A regression test asserts every native device in `prompts/live12_device_catalog.json` is recognized by `Live12Catalogue.has_device()`. * The gate code lives in a sibling `phase2_catalogue_gates.py` module re-exported by `server_phase2.py` -- keeps it stdlib-only so unit tests run without the fastapi/essentia import chain. 50 new tests, all green. The existing 39 fastapi-dependent tests the catalogue work could plausibly touch (test_phase2_citation_paths, test_phase2_grammar_fix, test_phase2_prompt_catalog, test_server_phase2) also pass post-refactor. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/backend/live12_catalogue.py | 420 + apps/backend/phase2_catalogue_gates.py | 378 + apps/backend/server.py | 16 + apps/backend/server_phase2.py | 1 + .../live12_catalogue/expected_saturator.json | 34 + .../three_device_catalogue.json | 40 + .../upstream/bank_definitions_fixture.py | 64 + apps/backend/tests/test_live12_catalogue.py | 343 + .../tests/test_phase2_validator_catalogue.py | 459 + data/live12_catalogue.json | 8144 +++++++++++++++++ data/live12_catalogue.schema.json | 135 + scripts/build_live12_catalogue.py | 598 ++ 12 files changed, 10632 insertions(+) create mode 100644 apps/backend/live12_catalogue.py create mode 100644 apps/backend/phase2_catalogue_gates.py create mode 100644 apps/backend/tests/fixtures/live12_catalogue/expected_saturator.json create mode 100644 apps/backend/tests/fixtures/live12_catalogue/three_device_catalogue.json create mode 100644 apps/backend/tests/fixtures/live12_catalogue/upstream/bank_definitions_fixture.py create mode 100644 apps/backend/tests/test_live12_catalogue.py create mode 100644 apps/backend/tests/test_phase2_validator_catalogue.py create mode 100644 data/live12_catalogue.json create mode 100644 data/live12_catalogue.schema.json create mode 100755 scripts/build_live12_catalogue.py diff --git a/apps/backend/live12_catalogue.py b/apps/backend/live12_catalogue.py new file mode 100644 index 00000000..003d92e6 --- /dev/null +++ b/apps/backend/live12_catalogue.py @@ -0,0 +1,420 @@ +"""Live 12 addressable-target catalogue loader and lookup. + +The catalogue is generated by `scripts/build_live12_catalogue.py` from the +upstream `gluon/AbletonLive12_MIDIRemoteScripts` source and lives at +`data/live12_catalogue.json` at the repo root. This module loads that JSON +once, validates it against the published schema (stdlib-only check — see +`_validate_catalogue_shape`), and exposes a `Live12Catalogue` lookup API used +by the Phase 2 output validator. + +The catalogue keys are Live's internal device class names (e.g. `Saturator`, +`Eq8`). Each device entry also carries a hand-mapped `displayName` (the Ableton +UI label — e.g. `EQ Eight` for `Eq8`), and `has_device` accepts either form, +case-insensitively. Parameter names are exact-match (Live exposes them +verbatim through its remote-script bank tables); `fuzzy_resolve` provides a +heuristic escape hatch for minor typos. + +Static-source extraction does NOT carry `type/min/max/unit/default`. The +`ParamSpec` dataclass has slots for them, and the validator's range gate is +wired but inert when min/max are absent. Reserved for future enrichment from +runtime introspection — see `scripts/build_live12_catalogue.py` extraction +notes. +""" + +from __future__ import annotations + +import difflib +import json +import re +import threading +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +CATALOGUE_FILENAME = "live12_catalogue.json" + +# The canonical catalogue lives at `/data/live12_catalogue.json`. +# This module is at `/apps/backend/live12_catalogue.py`, so two +# parents up gives the repo root. +_REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_CATALOGUE_PATH = _REPO_ROOT / "data" / CATALOGUE_FILENAME + +_VALID_CATEGORIES = frozenset({"instrument", "audio_effect", "midi_effect", "rack"}) +_VALID_PARAM_TYPES = frozenset({"float", "int", "bool", "enum"}) +_COMMIT_RE = re.compile(r"^[0-9a-f]{7,40}$") +_FUZZY_MATCH_CUTOFF = 0.7 + + +class CatalogueShapeError(RuntimeError): + """Raised when the catalogue JSON does not match the published schema.""" + + +@dataclass(frozen=True) +class ParamSpec: + """Canonical metadata for a Live device parameter. + + `name` is always present. Other fields are optional and currently never + populated by the static-source generator — they are reserved for future + enrichment from runtime introspection. + """ + + name: str + type: str | None = None + min: float | None = None + max: float | None = None + unit: str | None = None + default: Any = None + values: tuple[str, ...] | None = None + + def has_range(self) -> bool: + return self.min is not None and self.max is not None + + def in_range(self, value: float) -> bool: + """Range-membership check. Caller is responsible for converting Gemini's + string-typed `value` to a number before calling. Returns True when + either bound is absent — the gate is inert when range is unknown.""" + if self.min is not None and value < self.min: + return False + if self.max is not None and value > self.max: + return False + return True + + +@dataclass(frozen=True) +class DeviceEntry: + class_name: str + display_name: str + category: str + parameters: tuple[ParamSpec, ...] + _parameters_by_name: dict[str, ParamSpec] + _parameter_names: tuple[str, ...] + + def parameter_names(self) -> tuple[str, ...]: + return self._parameter_names + + def parameter_spec(self, parameter: str) -> ParamSpec | None: + return self._parameters_by_name.get(parameter) + + +class Live12Catalogue: + """Read-only Live 12 device/parameter lookup. + + Build via `Live12Catalogue.load_default()` to get the singleton backed by + `data/live12_catalogue.json`, or via `Live12Catalogue.from_path(p)` / + `Live12Catalogue.from_dict(d)` for tests. + + Lookup methods: + has_device(name) -- case-insensitive `class` or `displayName` + has_parameter(d, p) -- exact-match on canonical parameter name + parameter_spec(d, p) -- returns ParamSpec | None + fuzzy_resolve(d, p) -- (canonical_device, canonical_parameter) | None + """ + + schema_version: str + source_commit: str + source_url: str + source_files: tuple[str, ...] + license_note: str + generated_at: str + extraction_notes: str + devices: tuple[DeviceEntry, ...] + + def __init__( + self, + *, + schema_version: str, + source_commit: str, + source_url: str, + source_files: tuple[str, ...], + license_note: str, + generated_at: str, + extraction_notes: str, + devices: tuple[DeviceEntry, ...], + ) -> None: + self.schema_version = schema_version + self.source_commit = source_commit + self.source_url = source_url + self.source_files = source_files + self.license_note = license_note + self.generated_at = generated_at + self.extraction_notes = extraction_notes + self.devices = devices + # `_lookup_keys` keys are lowercased class names AND lowercased display + # names; values are the canonical class name. Display-name aliases let + # the validator accept both Gemini's UI-name citations ("EQ Eight") and + # Live's internal class names ("Eq8") without forcing the caller to + # pick one. + lookup: dict[str, str] = {} + for device in devices: + lookup.setdefault(device.class_name.lower(), device.class_name) + lookup.setdefault(device.display_name.lower(), device.class_name) + self._lookup_keys = lookup + self._devices_by_class = {device.class_name: device for device in devices} + + # ----- construction ----- + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Live12Catalogue": + _validate_catalogue_shape(data) + devices: list[DeviceEntry] = [] + for raw in data["devices"]: + params: list[ParamSpec] = [] + for raw_param in raw["parameters"]: + params.append(_build_param_spec(raw_param)) + param_tuple = tuple(params) + by_name = {p.name: p for p in param_tuple} + devices.append( + DeviceEntry( + class_name=raw["class"], + display_name=raw["displayName"], + category=raw["category"], + parameters=param_tuple, + _parameters_by_name=by_name, + _parameter_names=tuple(p.name for p in param_tuple), + ) + ) + return cls( + schema_version=data["schema_version"], + source_commit=data["source_commit"], + source_url=data["source_url"], + source_files=tuple(data["source_files"]), + license_note=data["license_note"], + generated_at=data["generated_at"], + extraction_notes=data["extraction_notes"], + devices=tuple(devices), + ) + + @classmethod + def from_path(cls, path: Path | str) -> "Live12Catalogue": + path = Path(path) + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise CatalogueShapeError( + f"Live 12 catalogue not found at {path}. " + f"Run `scripts/build_live12_catalogue.py --source `." + ) from exc + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise CatalogueShapeError( + f"Live 12 catalogue at {path} is not valid JSON: {exc}" + ) from exc + return cls.from_dict(data) + + @classmethod + def load_default(cls) -> "Live12Catalogue": + return _default_singleton() + + # ----- lookup ----- + + def has_device(self, name: str) -> bool: + if not isinstance(name, str): + return False + return name.strip().lower() in self._lookup_keys + + def canonical_device(self, name: str) -> str | None: + if not isinstance(name, str): + return None + return self._lookup_keys.get(name.strip().lower()) + + def device(self, name: str) -> DeviceEntry | None: + canonical = self.canonical_device(name) + if canonical is None: + return None + return self._devices_by_class[canonical] + + def has_parameter(self, device: str, parameter: str) -> bool: + entry = self.device(device) + if entry is None: + return False + if not isinstance(parameter, str): + return False + return parameter in entry._parameters_by_name + + def parameter_spec(self, device: str, parameter: str) -> ParamSpec | None: + entry = self.device(device) + if entry is None: + return None + if not isinstance(parameter, str): + return None + return entry.parameter_spec(parameter) + + def fuzzy_resolve( + self, device: str, parameter: str + ) -> tuple[str, str] | None: + """Heuristic resolution for minor parameter-name typos + (e.g. `HiCut` -> `High Cut Freq`). Returns (canonical_device, + canonical_parameter) on a confident match, else None. + + The device side is resolved via the existing case-insensitive + `class`/`displayName` map (no fuzz). Only the parameter side is + fuzzy-matched, against the device's known parameter names with a + difflib cutoff of 0.7. Exact-matches are returned immediately so a + valid parameter that happens to be close to another name is not + accidentally rewritten. + """ + entry = self.device(device) + if entry is None: + return None + if not isinstance(parameter, str): + return None + stripped = parameter.strip() + if not stripped: + return None + if stripped in entry._parameters_by_name: + return (entry.class_name, stripped) + matches = difflib.get_close_matches( + stripped, + entry._parameter_names, + n=1, + cutoff=_FUZZY_MATCH_CUTOFF, + ) + if not matches: + return None + return (entry.class_name, matches[0]) + + +# ----- module-level singleton ----- + +_singleton_lock = threading.Lock() +_singleton: Live12Catalogue | None = None + + +def _default_singleton() -> Live12Catalogue: + global _singleton + if _singleton is not None: + return _singleton + with _singleton_lock: + if _singleton is None: + _singleton = Live12Catalogue.from_path(DEFAULT_CATALOGUE_PATH) + return _singleton + + +def reset_default_singleton_for_tests() -> None: + """Test-only escape hatch: clear the cached default so a subsequent + `load_default()` re-reads the on-disk catalogue. Not used by product code.""" + global _singleton + with _singleton_lock: + _singleton = None + + +# ----- schema validation (stdlib-only, structurally matches the JSON Schema) ----- + +def _validate_catalogue_shape(data: Any) -> None: + if not isinstance(data, dict): + raise CatalogueShapeError("Catalogue root must be an object.") + + required_top = ( + "schema_version", + "source_commit", + "source_url", + "source_files", + "license_note", + "generated_at", + "extraction_notes", + "devices", + ) + for key in required_top: + if key not in data: + raise CatalogueShapeError(f"Catalogue is missing required key '{key}'.") + + if data["schema_version"] != "1": + raise CatalogueShapeError( + f"Unsupported catalogue schema_version {data['schema_version']!r}; expected '1'." + ) + if not isinstance(data["source_commit"], str) or not _COMMIT_RE.match( + data["source_commit"] + ): + raise CatalogueShapeError( + f"source_commit must be a 7-40 char lowercase hex SHA; got {data['source_commit']!r}." + ) + for str_key in ("source_url", "license_note", "generated_at", "extraction_notes"): + value = data[str_key] + if not isinstance(value, str) or not value: + raise CatalogueShapeError(f"{str_key} must be a non-empty string.") + source_files = data["source_files"] + if ( + not isinstance(source_files, list) + or not source_files + or not all(isinstance(p, str) and p for p in source_files) + ): + raise CatalogueShapeError( + "source_files must be a non-empty list of non-empty strings." + ) + + devices = data["devices"] + if not isinstance(devices, list) or not devices: + raise CatalogueShapeError("devices must be a non-empty list.") + + seen_classes: set[str] = set() + for index, raw in enumerate(devices): + _validate_device_shape(raw, index, seen_classes) + + +def _validate_device_shape(raw: Any, index: int, seen_classes: set[str]) -> None: + if not isinstance(raw, dict): + raise CatalogueShapeError(f"devices[{index}] must be an object.") + for key in ("class", "category", "displayName", "parameters"): + if key not in raw: + raise CatalogueShapeError(f"devices[{index}] missing '{key}'.") + class_name = raw["class"] + if not isinstance(class_name, str) or not class_name: + raise CatalogueShapeError(f"devices[{index}].class must be a non-empty string.") + if class_name in seen_classes: + raise CatalogueShapeError( + f"devices[{index}].class {class_name!r} is duplicated." + ) + seen_classes.add(class_name) + if raw["category"] not in _VALID_CATEGORIES: + raise CatalogueShapeError( + f"devices[{index}].category {raw['category']!r} not in {sorted(_VALID_CATEGORIES)}." + ) + display_name = raw["displayName"] + if not isinstance(display_name, str) or not display_name: + raise CatalogueShapeError( + f"devices[{index}].displayName must be a non-empty string." + ) + parameters = raw["parameters"] + if not isinstance(parameters, list): + raise CatalogueShapeError( + f"devices[{index}].parameters must be a list (may be empty)." + ) + for p_index, raw_param in enumerate(parameters): + _validate_parameter_shape(raw_param, index, p_index) + + +def _validate_parameter_shape(raw: Any, device_index: int, p_index: int) -> None: + location = f"devices[{device_index}].parameters[{p_index}]" + if not isinstance(raw, dict): + raise CatalogueShapeError(f"{location} must be an object.") + name = raw.get("name") + if not isinstance(name, str) or not name: + raise CatalogueShapeError(f"{location}.name must be a non-empty string.") + if "type" in raw and raw["type"] not in _VALID_PARAM_TYPES: + raise CatalogueShapeError( + f"{location}.type {raw['type']!r} not in {sorted(_VALID_PARAM_TYPES)}." + ) + for numeric in ("min", "max"): + if numeric in raw and not isinstance(raw[numeric], (int, float)): + raise CatalogueShapeError(f"{location}.{numeric} must be numeric.") + if "unit" in raw and not isinstance(raw["unit"], str): + raise CatalogueShapeError(f"{location}.unit must be a string.") + if "values" in raw: + values = raw["values"] + if not isinstance(values, list) or not all(isinstance(v, str) for v in values): + raise CatalogueShapeError(f"{location}.values must be a list of strings.") + + +def _build_param_spec(raw: dict[str, Any]) -> ParamSpec: + values = raw.get("values") + return ParamSpec( + name=raw["name"], + type=raw.get("type"), + min=raw.get("min"), + max=raw.get("max"), + unit=raw.get("unit"), + default=raw.get("default"), + values=tuple(values) if isinstance(values, list) else None, + ) diff --git a/apps/backend/phase2_catalogue_gates.py b/apps/backend/phase2_catalogue_gates.py new file mode 100644 index 00000000..1203b2fa --- /dev/null +++ b/apps/backend/phase2_catalogue_gates.py @@ -0,0 +1,378 @@ +"""Live 12 source-catalogue gates that drop or rewrite Phase 2 recommendations. + +The Phase 2 Gemini handler emits recommendations as `{device, parameter, value, +phase1Fields}` records inside `mixAndMasterChain`, `abletonRecommendations`, +and `secretSauce.workflowSteps`. The curated `prompts/live12_device_catalog.json` +check in `server_phase2._validate_phase2_catalog_entry` is WARNING-only -- it +flags recommendations whose device or parameter is not in the prompt-side +curated catalog but never drops them. + +This module adds a stricter gate backed by the source-extracted +`data/live12_catalogue.json` (generated by `scripts/build_live12_catalogue.py` +from the upstream MIDI Remote Scripts). The contract is: + + 1. Reject if `device` is not in the source catalogue. + 2. Reject if `parameter` is not on the device -- but first try + `Live12Catalogue.fuzzy_resolve`; if it resolves, REWRITE the record's + `parameter` and emit a structured `PARAMETER_REWRITTEN` event with + original + resolved + requestId so nothing is rewritten silently. + 3. Reject if numeric `value` falls outside the catalogue range. The gate is + intentionally inert when `ParamSpec.min/max` are absent because static + source extraction does not carry ranges; reserved for future enrichment + from runtime introspection. + 4. Reject if `phase1Fields` is missing or empty. Unresolved cited paths + (paths that don't resolve against the measurement payload) remain + WARNING-only via `_validate_phase2_citation_paths`; this gate addresses + the harder failure of citing nothing at all. + +Every rejection emits a structured `RECOMMENDATION_REJECTED` event carrying +`reason`, `device`, `parameter`, `path`, and `requestId`. The function returns +the event list; the caller stitches them into the existing +`validationWarnings` channel. The phase2 result is MUTATED in place to drop +rejected entries -- the goal's "never swallow silently" requirement is met by +the emitted events, not by surviving rejected output. + +Living separately from `server_phase2.py` keeps the gate code free of the +FastAPI / pydantic import chain so unit tests can exercise it in pure stdlib. +""" + +from __future__ import annotations + +import json +import re +from math import isfinite +from typing import Any + +from live12_catalogue import Live12Catalogue + + +_CATALOGUE_REJECT_REASON_DEVICE_UNKNOWN = "device_unknown" +_CATALOGUE_REJECT_REASON_PARAMETER_UNKNOWN = "parameter_unknown" +_CATALOGUE_REJECT_REASON_VALUE_OUT_OF_RANGE = "value_out_of_range" +_CATALOGUE_REJECT_REASON_CITATION_MISSING = "citation_missing" + +_VALUE_NUMBER_RE = re.compile(r"-?\d+(?:\.\d+)?") + + +def _as_record(value: Any) -> dict[str, Any] | None: + if not value or not isinstance(value, dict): + return None + return value + + +def _stringify_warning_value(value: Any) -> str: + if isinstance(value, str): + return value + try: + return json.dumps(value, ensure_ascii=False, sort_keys=True) + except TypeError: + return str(value) + + +def _coerce_value_to_number(raw: Any) -> float | None: + """Pull a finite numeric value out of Gemini's `value` field. + + Gemini emits `value` as a free-form string ("4.5", "-12 dB", "10ms", + "1:4", "auto"). The range gate only fires when we can extract a single + finite number; anything ambiguous (compound ratios, slashes, "auto", etc.) + passes through without a range check rather than producing a false-positive + rejection. + """ + if isinstance(raw, bool): # bool is a subclass of int -- exclude. + return None + if isinstance(raw, (int, float)) and isfinite(float(raw)): + return float(raw) + if not isinstance(raw, str): + return None + if ":" in raw: + return None # ratios like "1:4" cannot be one number + matches = _VALUE_NUMBER_RE.findall(raw) + if len(matches) != 1: + return None + try: + return float(matches[0]) + except ValueError: + return None + + +def _build_recommendation_rejected_event( + *, + path: str, + reason: str, + message: str, + device: str, + parameter: str, + request_id: str, + value: Any = None, +) -> dict[str, Any]: + event: dict[str, Any] = { + "code": "RECOMMENDATION_REJECTED", + "path": path, + "message": message, + "reason": reason, + "device": device, + "parameter": parameter, + "requestId": request_id, + } + if value is not None: + event["value"] = _stringify_warning_value(value) + return event + + +def _build_parameter_rewritten_event( + *, + path: str, + device: str, + original_parameter: str, + resolved_parameter: str, + request_id: str, +) -> dict[str, Any]: + return { + "code": "PARAMETER_REWRITTEN", + "path": path, + "message": ( + f"Rewrote parameter {original_parameter!r} to " + f"{resolved_parameter!r} on device {device!r} via Live 12 catalogue " + "fuzzy resolution." + ), + "device": device, + "originalParameter": original_parameter, + "resolvedParameter": resolved_parameter, + "requestId": request_id, + } + + +def _catalogue_check_record( + *, + catalogue: Live12Catalogue, + record: dict[str, Any], + base_path: str, + request_id: str, + require_citation: bool, + events: list[dict[str, Any]], +) -> bool: + """Run device/parameter/range/citation gates against one record. + + Returns True when the record is accepted (and may have been mutated in + place by a fuzzy rewrite), False when the record must be dropped. + Emitted events go onto `events`. + """ + device_raw = record.get("device") + parameter_raw = record.get("parameter") + device = device_raw.strip() if isinstance(device_raw, str) else "" + parameter = parameter_raw.strip() if isinstance(parameter_raw, str) else "" + + if not catalogue.has_device(device): + events.append( + _build_recommendation_rejected_event( + path=base_path, + reason=_CATALOGUE_REJECT_REASON_DEVICE_UNKNOWN, + message=( + f"Device {device!r} is not in the Live 12 source catalogue. " + "Reject before surfacing the recommendation to the user." + ), + device=device, + parameter=parameter, + request_id=request_id, + ) + ) + return False + + canonical_device = catalogue.canonical_device(device) or device + effective_parameter = parameter + + if not catalogue.has_parameter(canonical_device, parameter): + resolution = catalogue.fuzzy_resolve(canonical_device, parameter) + if resolution is None: + events.append( + _build_recommendation_rejected_event( + path=base_path, + reason=_CATALOGUE_REJECT_REASON_PARAMETER_UNKNOWN, + message=( + f"Parameter {parameter!r} is not on device " + f"{canonical_device!r} in the Live 12 source catalogue, " + "and fuzzy resolution found no close match." + ), + device=canonical_device, + parameter=parameter, + request_id=request_id, + ) + ) + return False + resolved_device, resolved_parameter = resolution + if resolved_parameter != parameter: + events.append( + _build_parameter_rewritten_event( + path=f"{base_path}.parameter", + device=resolved_device, + original_parameter=parameter, + resolved_parameter=resolved_parameter, + request_id=request_id, + ) + ) + record["parameter"] = resolved_parameter + effective_parameter = resolved_parameter + + spec = catalogue.parameter_spec(canonical_device, effective_parameter) + if spec is not None and spec.has_range(): + numeric_value = _coerce_value_to_number(record.get("value")) + if numeric_value is not None and not spec.in_range(numeric_value): + events.append( + _build_recommendation_rejected_event( + path=base_path, + reason=_CATALOGUE_REJECT_REASON_VALUE_OUT_OF_RANGE, + message=( + f"Value {numeric_value} for {canonical_device!r}.{effective_parameter!r} " + f"is outside the catalogue range [{spec.min}, {spec.max}]." + ), + device=canonical_device, + parameter=effective_parameter, + request_id=request_id, + value=record.get("value"), + ) + ) + return False + + if require_citation: + cited = record.get("phase1Fields") + non_empty = isinstance(cited, list) and any( + isinstance(s, str) and s.strip() for s in cited + ) + if not non_empty: + events.append( + _build_recommendation_rejected_event( + path=f"{base_path}.phase1Fields", + reason=_CATALOGUE_REJECT_REASON_CITATION_MISSING, + message=( + "Recommendation has no Phase 1 citation. The chain of " + "custody invariant requires every Gemini recommendation " + "to cite at least one Phase 1 measurement field." + ), + device=canonical_device, + parameter=effective_parameter, + request_id=request_id, + ) + ) + return False + + return True + + +def _apply_catalogue_gates_to_list( + *, + catalogue: Live12Catalogue, + phase2_result: dict[str, Any], + list_key: str, + base_path_prefix: str, + request_id: str, + require_citation: bool, + events: list[dict[str, Any]], +) -> None: + items = phase2_result.get(list_key) + if not isinstance(items, list) or not items: + return + surviving: list[Any] = [] + for index, item in enumerate(items): + record = _as_record(item) + if record is None: + # Pass through opaque entries -- shape salvage handled them already. + surviving.append(item) + continue + base_path = f"{base_path_prefix}[{index}]" + if _catalogue_check_record( + catalogue=catalogue, + record=record, + base_path=base_path, + request_id=request_id, + require_citation=require_citation, + events=events, + ): + surviving.append(record) + phase2_result[list_key] = surviving + + +def _apply_catalogue_gates_to_workflow_steps( + *, + catalogue: Live12Catalogue, + phase2_result: dict[str, Any], + request_id: str, + events: list[dict[str, Any]], +) -> None: + secret_sauce = _as_record(phase2_result.get("secretSauce")) + if secret_sauce is None: + return + steps = secret_sauce.get("workflowSteps") + if not isinstance(steps, list) or not steps: + return + surviving: list[Any] = [] + for index, item in enumerate(steps): + record = _as_record(item) + if record is None: + surviving.append(item) + continue + base_path = f"secretSauce.workflowSteps[{index}]" + # secretSauce workflow steps cite Phase 1 via `phase1Fields` too -- the + # schema requires the array, so the citation gate applies. + if _catalogue_check_record( + catalogue=catalogue, + record=record, + base_path=base_path, + request_id=request_id, + require_citation=True, + events=events, + ): + surviving.append(record) + secret_sauce["workflowSteps"] = surviving + phase2_result["secretSauce"] = secret_sauce + + +def apply_live12_catalogue_gates( + phase2_result: dict[str, Any], + *, + request_id: str, + catalogue: Live12Catalogue | None = None, +) -> list[dict[str, Any]]: + """Mutate `phase2_result` in place, dropping recommendations that fail the + Live 12 catalogue gates and rewriting parameters resolved via fuzzy match. + + Returns a list of structured events (warning-shaped dicts) describing every + rejection and rewrite. The caller stitches them into the existing + `validationWarnings` channel. The mutation is intentional so the + user-facing payload contains only accepted recommendations. + + The catalogue is loaded from `data/live12_catalogue.json` via + `Live12Catalogue.load_default()` when not injected explicitly (tests pass a + test catalogue here). + """ + if not isinstance(phase2_result, dict): + return [] + if catalogue is None: + catalogue = Live12Catalogue.load_default() + events: list[dict[str, Any]] = [] + # mixAndMasterChain items have a `phase1Fields` array per the schema -- + # citation gate applies. Same for abletonRecommendations. + _apply_catalogue_gates_to_list( + catalogue=catalogue, + phase2_result=phase2_result, + list_key="mixAndMasterChain", + base_path_prefix="mixAndMasterChain", + request_id=request_id, + require_citation=True, + events=events, + ) + _apply_catalogue_gates_to_list( + catalogue=catalogue, + phase2_result=phase2_result, + list_key="abletonRecommendations", + base_path_prefix="abletonRecommendations", + request_id=request_id, + require_citation=True, + events=events, + ) + _apply_catalogue_gates_to_workflow_steps( + catalogue=catalogue, + phase2_result=phase2_result, + request_id=request_id, + events=events, + ) + return events diff --git a/apps/backend/server.py b/apps/backend/server.py index 3346d929..d7cff8f8 100644 --- a/apps/backend/server.py +++ b/apps/backend/server.py @@ -142,6 +142,7 @@ _validate_phase2_catalog_entry, _validate_phase2_citation_paths, _validate_phase2_semantics, + apply_live12_catalogue_gates, ) import server_samples @@ -1620,11 +1621,26 @@ def _generate_files_api() -> Any: if profile_id == "producer_summary" and interpretation_result is not None else [] ) + # Live 12 source-catalogue gates run last so they see (a) the salvaged + # / coerced shape, (b) the post-rename measurement field names that + # the citation walker uses. They MUTATE `interpretation_result` by + # dropping recommendations that fail device/parameter/range/citation + # checks; the events surface as warning-shaped validationWarnings so + # the operator-facing diagnostic log keeps a complete trail. + catalogue_gate_warnings = ( + apply_live12_catalogue_gates( + interpretation_result, + request_id=request_id, + ) + if profile_id == "producer_summary" and interpretation_result is not None + else [] + ) validation_warnings = ( parse_validation_warnings + style_profile_warnings + semantic_validation_warnings + citation_path_warnings + + catalogue_gate_warnings ) diagnostics = _build_diagnostics( response_ready_at=_current_time(), diff --git a/apps/backend/server_phase2.py b/apps/backend/server_phase2.py index 0258cc11..f24542cd 100644 --- a/apps/backend/server_phase2.py +++ b/apps/backend/server_phase2.py @@ -9,6 +9,7 @@ from analysis_runtime import AnalysisRuntime, UnsupportedPitchNoteModeError from audio_mime import canonical_audio_mime +from phase2_catalogue_gates import apply_live12_catalogue_gates # re-exported from server_phase1 import ( _coerce_nullable_number, _coerce_nullable_string, diff --git a/apps/backend/tests/fixtures/live12_catalogue/expected_saturator.json b/apps/backend/tests/fixtures/live12_catalogue/expected_saturator.json new file mode 100644 index 00000000..2c60c681 --- /dev/null +++ b/apps/backend/tests/fixtures/live12_catalogue/expected_saturator.json @@ -0,0 +1,34 @@ +{ + "schema_version": "1", + "source_commit": "e83d5192f321b24eb9daab843ac49a2d95d862b1", + "source_url": "https://github.com/gluon/AbletonLive12_MIDIRemoteScripts", + "source_files": ["Push2/custom_bank_definitions.py"], + "license_note": "Upstream is a decompiled redistribution of Ableton's MIDI Remote Scripts. ASA does not ship any upstream source; this catalogue records only the device-class and parameter-name metadata extracted via static AST parsing, used as a Phase 2 output-validation gate.", + "generated_at": "2026-05-28T00:00:00Z", + "extraction_notes": "Hand-authored Saturator fixture used for JSON Schema and Live12Catalogue regression tests. Names are taken verbatim from the upstream BANK_DEFINITIONS['Saturator'] entry. type/min/max/unit/default are intentionally omitted because they are not present in static source — they live on runtime Live.DeviceParameter objects.", + "devices": [ + { + "class": "Saturator", + "category": "audio_effect", + "displayName": "Saturator", + "parameters": [ + {"name": "Base"}, + {"name": "Color"}, + {"name": "Depth"}, + {"name": "Drive"}, + {"name": "Dry/Wet"}, + {"name": "Frequency"}, + {"name": "Output"}, + {"name": "Soft Clip"}, + {"name": "Type"}, + {"name": "WS Curve"}, + {"name": "WS Damp"}, + {"name": "WS Depth"}, + {"name": "WS Drive"}, + {"name": "WS Lin"}, + {"name": "WS Period"}, + {"name": "Width"} + ] + } + ] +} diff --git a/apps/backend/tests/fixtures/live12_catalogue/three_device_catalogue.json b/apps/backend/tests/fixtures/live12_catalogue/three_device_catalogue.json new file mode 100644 index 00000000..6d89f6bd --- /dev/null +++ b/apps/backend/tests/fixtures/live12_catalogue/three_device_catalogue.json @@ -0,0 +1,40 @@ +{ + "schema_version": "1", + "source_commit": "0000000000000000000000000000000000000000", + "source_url": "https://example.invalid/three-device-fixture", + "source_files": ["tests/fixtures/live12_catalogue/three_device_catalogue.json"], + "license_note": "Synthetic test fixture; not derived from upstream.", + "generated_at": "2026-05-28T00:00:00Z", + "extraction_notes": "Three hand-authored device entries: Saturator (audio_effect, no ranges — exercises name-only path), Eq8 (audio_effect, displayName alias exercises case-insensitive UI-name lookup), Operator (instrument, one parameter carries a synthetic range to exercise the range gate).", + "devices": [ + { + "class": "Saturator", + "category": "audio_effect", + "displayName": "Saturator", + "parameters": [ + {"name": "Drive"}, + {"name": "Output"}, + {"name": "Dry/Wet"} + ] + }, + { + "class": "Eq8", + "category": "audio_effect", + "displayName": "EQ Eight", + "parameters": [ + {"name": "1 Frequency A"}, + {"name": "1 Gain A"}, + {"name": "1 Resonance A"} + ] + }, + { + "class": "Operator", + "category": "instrument", + "displayName": "Operator", + "parameters": [ + {"name": "Volume", "type": "float", "min": -36.0, "max": 6.0, "unit": "dB", "default": 0.0}, + {"name": "Algorithm"} + ] + } + ] +} diff --git a/apps/backend/tests/fixtures/live12_catalogue/upstream/bank_definitions_fixture.py b/apps/backend/tests/fixtures/live12_catalogue/upstream/bank_definitions_fixture.py new file mode 100644 index 00000000..f197f0cc --- /dev/null +++ b/apps/backend/tests/fixtures/live12_catalogue/upstream/bank_definitions_fixture.py @@ -0,0 +1,64 @@ +# Test fixture mirroring the shape of the upstream +# `Push2/custom_bank_definitions.py` (gluon/AbletonLive12_MIDIRemoteScripts). +# This is a static-source fixture: the generator parses it via `ast` and +# never executes it, so the `IndexedDict`, `BANK_*_KEY`, and `use(...)` +# symbols are intentionally undefined here. The generator extracts the +# device/parameter metadata purely from the AST shape. +from __future__ import absolute_import + +OPTIONS_KEY = "Options" + +RACK_BANKS = IndexedDict(( + ( + "Macros", + {BANK_PARAMETERS_KEY: ("Macro 1", "Macro 2", "Macro 3", "Macro 4")}, + ), +)) + +BANK_DEFINITIONS = { + "AudioEffectGroupDevice": RACK_BANKS, + "Saturator": IndexedDict(( + ( + BANK_MAIN_KEY, + { + BANK_PARAMETERS_KEY: ("Type", "Drive", "Base", "Frequency", "Width", "Depth", "Output", "Dry/Wet"), + OPTIONS_KEY: ("", "Color", "", "", "", "Soft Clip", ""), + }, + ), + ( + "Waveshaper", + {BANK_PARAMETERS_KEY: ("Type", "WS Drive", "WS Curve", "WS Depth", "WS Lin", "WS Damp", "WS Period", "Dry/Wet")}, + ), + )), + "Eq8": IndexedDict(( + ( + BANK_MAIN_KEY, + { + BANK_PARAMETERS_KEY: ( + "1 Filter Type A", + "1 Frequency A", + "1 Gain A", + "1 Resonance A", + "Band", + "Eq Mode", + "Edit Mode", + "Oversampling", + ), + }, + ), + )), + "Operator": IndexedDict(( + ( + BANK_MAIN_KEY, + { + BANK_PARAMETERS_KEY: ( + "Algorithm", + use("Osc A Wave").with_name("Wave").if_parameter("Oscillator").has_value("Osc A").else_use("Osc B Wave").with_name("Wave"), + use("Osc A Coarse").if_parameter("Oscillator").has_value("Osc A").else_use("Osc B Coarse"), + "Volume", + ), + OPTIONS_KEY: ("", "", "", ""), + }, + ), + )), +} diff --git a/apps/backend/tests/test_live12_catalogue.py b/apps/backend/tests/test_live12_catalogue.py new file mode 100644 index 00000000..fac3ee2d --- /dev/null +++ b/apps/backend/tests/test_live12_catalogue.py @@ -0,0 +1,343 @@ +"""Unit tests for the Live 12 addressable-target catalogue. + +Three layers covered here: + +1. `Live12Catalogue` API surface against the hand-authored 3-device fixture + (`tests/fixtures/live12_catalogue/three_device_catalogue.json`). +2. Generator behavior against the vendored 2-file fixture + (`tests/fixtures/live12_catalogue/upstream/bank_definitions_fixture.py` + + `tests/fixtures/live12_catalogue/expected_saturator.json`). +3. Regression safety net: every device named in the curated + `prompts/live12_device_catalog.json` is recognized by the generated + `data/live12_catalogue.json` -- guards the validator from silently dropping + a Gemini recommendation whose device exists in the prompt-side curated + catalog but is missing from the proof-gate source catalogue. +""" + +from __future__ import annotations + +import importlib.util +import json +import unittest +from pathlib import Path + +from live12_catalogue import ( + CatalogueShapeError, + Live12Catalogue, + DEFAULT_CATALOGUE_PATH, +) + + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "live12_catalogue" +_THREE_DEVICE_FIXTURE = _FIXTURE_DIR / "three_device_catalogue.json" +_FIXTURE_BANK_DEFINITIONS_PY = ( + _FIXTURE_DIR / "upstream" / "bank_definitions_fixture.py" +) +_FIXTURE_EXPECTED_SATURATOR = _FIXTURE_DIR / "expected_saturator.json" +_CURATED_CATALOG = ( + Path(__file__).resolve().parents[1] / "prompts" / "live12_device_catalog.json" +) + + +def _load_generator_module(): + """Load `scripts/build_live12_catalogue.py` without putting `scripts/` on + the package path permanently — keeps the generator clearly out of the + backend's normal import surface.""" + spec = importlib.util.spec_from_file_location( + "build_live12_catalogue", + _REPO_ROOT / "scripts" / "build_live12_catalogue.py", + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class Live12CatalogueApiTests(unittest.TestCase): + """Covers has_device / has_parameter / parameter_spec / fuzzy_resolve.""" + + def setUp(self) -> None: + self.catalogue = Live12Catalogue.from_path(_THREE_DEVICE_FIXTURE) + + def test_loads_all_three_devices(self): + classes = sorted(d.class_name for d in self.catalogue.devices) + self.assertEqual(classes, ["Eq8", "Operator", "Saturator"]) + + def test_has_device_exact_class(self): + self.assertTrue(self.catalogue.has_device("Saturator")) + self.assertTrue(self.catalogue.has_device("Eq8")) + self.assertTrue(self.catalogue.has_device("Operator")) + + def test_has_device_case_insensitive(self): + self.assertTrue(self.catalogue.has_device("saturator")) + self.assertTrue(self.catalogue.has_device("SATURATOR")) + self.assertTrue(self.catalogue.has_device(" saturator ")) + + def test_has_device_resolves_display_name_alias(self): + # "Eq8" is the canonical class; "EQ Eight" is the Ableton UI label. + # Gemini cites the UI form; the catalogue must accept both. + self.assertTrue(self.catalogue.has_device("EQ Eight")) + self.assertTrue(self.catalogue.has_device("eq eight")) + + def test_has_device_rejects_unknown(self): + self.assertFalse(self.catalogue.has_device("Saturation Color")) + self.assertFalse(self.catalogue.has_device("")) + self.assertFalse(self.catalogue.has_device(None)) # type: ignore[arg-type] + + def test_canonical_device_returns_class_name_for_display_alias(self): + self.assertEqual(self.catalogue.canonical_device("EQ Eight"), "Eq8") + self.assertEqual(self.catalogue.canonical_device("Saturator"), "Saturator") + self.assertIsNone(self.catalogue.canonical_device("Nope")) + + def test_has_parameter_exact_match(self): + self.assertTrue(self.catalogue.has_parameter("Saturator", "Drive")) + self.assertTrue(self.catalogue.has_parameter("EQ Eight", "1 Frequency A")) + self.assertTrue(self.catalogue.has_parameter("Operator", "Volume")) + + def test_has_parameter_rejects_unknown_parameter(self): + self.assertFalse(self.catalogue.has_parameter("Saturator", "HiCut")) + self.assertFalse(self.catalogue.has_parameter("Saturator", "Drives")) + + def test_has_parameter_rejects_unknown_device(self): + self.assertFalse(self.catalogue.has_parameter("Nope", "Drive")) + + def test_parameter_spec_returns_full_metadata_when_known(self): + spec = self.catalogue.parameter_spec("Operator", "Volume") + self.assertIsNotNone(spec) + assert spec is not None + self.assertEqual(spec.type, "float") + self.assertEqual(spec.min, -36.0) + self.assertEqual(spec.max, 6.0) + self.assertEqual(spec.unit, "dB") + self.assertEqual(spec.default, 0.0) + self.assertTrue(spec.has_range()) + # Range gate exercises in_range with both bounds present. + self.assertTrue(spec.in_range(-12.0)) + self.assertFalse(spec.in_range(-40.0)) + self.assertFalse(spec.in_range(12.0)) + + def test_parameter_spec_returns_name_only_when_no_range(self): + spec = self.catalogue.parameter_spec("Saturator", "Drive") + self.assertIsNotNone(spec) + assert spec is not None + self.assertEqual(spec.name, "Drive") + self.assertIsNone(spec.min) + self.assertIsNone(spec.max) + self.assertFalse(spec.has_range()) + # in_range with no bounds is True — range gate is inert when unknown. + self.assertTrue(spec.in_range(100.0)) + self.assertTrue(spec.in_range(-100.0)) + + def test_parameter_spec_returns_none_for_unknown(self): + self.assertIsNone(self.catalogue.parameter_spec("Saturator", "Nope")) + self.assertIsNone(self.catalogue.parameter_spec("Nope", "Drive")) + + def test_fuzzy_resolve_handles_minor_typo(self): + # "Drives" -> "Drive" (single trailing char) + resolution = self.catalogue.fuzzy_resolve("Saturator", "Drives") + self.assertEqual(resolution, ("Saturator", "Drive")) + + def test_fuzzy_resolve_handles_display_name_device(self): + resolution = self.catalogue.fuzzy_resolve("EQ Eight", "1 Frequencies A") + self.assertEqual(resolution, ("Eq8", "1 Frequency A")) + + def test_fuzzy_resolve_returns_exact_match_unchanged(self): + resolution = self.catalogue.fuzzy_resolve("Saturator", "Drive") + self.assertEqual(resolution, ("Saturator", "Drive")) + + def test_fuzzy_resolve_returns_none_for_far_match(self): + # "TotalGarbage" is too far from any of {"Drive", "Output", "Dry/Wet"}. + self.assertIsNone(self.catalogue.fuzzy_resolve("Saturator", "TotalGarbage")) + + def test_fuzzy_resolve_returns_none_for_unknown_device(self): + self.assertIsNone(self.catalogue.fuzzy_resolve("Nope", "Drive")) + + +class CatalogueShapeValidationTests(unittest.TestCase): + """Shape errors raise CatalogueShapeError with useful messages.""" + + def test_rejects_missing_required_top_level_key(self): + data = json.loads(_THREE_DEVICE_FIXTURE.read_text(encoding="utf-8")) + data.pop("schema_version") + with self.assertRaises(CatalogueShapeError) as ctx: + Live12Catalogue.from_dict(data) + self.assertIn("schema_version", str(ctx.exception)) + + def test_rejects_wrong_schema_version(self): + data = json.loads(_THREE_DEVICE_FIXTURE.read_text(encoding="utf-8")) + data["schema_version"] = "2" + with self.assertRaises(CatalogueShapeError): + Live12Catalogue.from_dict(data) + + def test_rejects_bad_commit_sha(self): + data = json.loads(_THREE_DEVICE_FIXTURE.read_text(encoding="utf-8")) + data["source_commit"] = "not-a-sha" + with self.assertRaises(CatalogueShapeError): + Live12Catalogue.from_dict(data) + + def test_rejects_unknown_category(self): + data = json.loads(_THREE_DEVICE_FIXTURE.read_text(encoding="utf-8")) + data["devices"][0]["category"] = "exotic" + with self.assertRaises(CatalogueShapeError): + Live12Catalogue.from_dict(data) + + def test_rejects_duplicate_class(self): + data = json.loads(_THREE_DEVICE_FIXTURE.read_text(encoding="utf-8")) + data["devices"].append(dict(data["devices"][0])) + with self.assertRaises(CatalogueShapeError): + Live12Catalogue.from_dict(data) + + +class CatalogueGeneratorTests(unittest.TestCase): + """Generator unit tests against the vendored 2-file fixture.""" + + @classmethod + def setUpClass(cls) -> None: + cls.module = _load_generator_module() + cls.fixture_text = _FIXTURE_BANK_DEFINITIONS_PY.read_text(encoding="utf-8") + cls.expected_saturator = json.loads( + _FIXTURE_EXPECTED_SATURATOR.read_text(encoding="utf-8") + ) + + def _build(self) -> dict: + return self.module.build_catalogue_from_text( + bank_definitions_text=self.fixture_text, + source_commit="deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + generated_at="2026-05-28T00:00:00Z", + ) + + def test_extracts_expected_device_count(self): + catalogue = self._build() + # The fixture defines 4 device classes: AudioEffectGroupDevice (RACK), + # Saturator, Eq8, Operator. + self.assertEqual(len(catalogue["devices"]), 4) + class_names = {d["class"] for d in catalogue["devices"]} + self.assertEqual( + class_names, + {"AudioEffectGroupDevice", "Saturator", "Eq8", "Operator"}, + ) + + def test_extracts_expected_saturator_parameter_count(self): + catalogue = self._build() + sat = next(d for d in catalogue["devices"] if d["class"] == "Saturator") + expected = {p["name"] for p in self.expected_saturator["devices"][0]["parameters"]} + actual = {p["name"] for p in sat["parameters"]} + self.assertEqual(actual, expected) + + def test_handles_use_chains(self): + catalogue = self._build() + operator = next(d for d in catalogue["devices"] if d["class"] == "Operator") + names = {p["name"] for p in operator["parameters"]} + # `use("Osc A Wave").with_name("Wave").if_parameter("Oscillator")...` + # use+if_parameter+else_use args are parameter names; with_name and + # has_value args are NOT. + self.assertIn("Osc A Wave", names) + self.assertIn("Osc B Wave", names) + self.assertIn("Oscillator", names) + # "Wave" was a with_name() argument and "Osc A" was a has_value() + # argument — neither should be in the parameter set. + self.assertNotIn("Wave", names) + self.assertNotIn("Osc A", names) + + def test_handles_rack_banks_reference(self): + catalogue = self._build() + rack = next( + d for d in catalogue["devices"] if d["class"] == "AudioEffectGroupDevice" + ) + names = sorted(p["name"] for p in rack["parameters"]) + self.assertEqual(names, ["Macro 1", "Macro 2", "Macro 3", "Macro 4"]) + + def test_display_name_and_category_applied(self): + catalogue = self._build() + eq = next(d for d in catalogue["devices"] if d["class"] == "Eq8") + self.assertEqual(eq["displayName"], "EQ Eight") + self.assertEqual(eq["category"], "audio_effect") + operator = next(d for d in catalogue["devices"] if d["class"] == "Operator") + self.assertEqual(operator["category"], "instrument") + rack = next( + d for d in catalogue["devices"] if d["class"] == "AudioEffectGroupDevice" + ) + self.assertEqual(rack["category"], "rack") + + def test_generator_output_is_canonical_sorted(self): + catalogue = self._build() + class_names = [d["class"] for d in catalogue["devices"]] + self.assertEqual(class_names, sorted(class_names)) + for device in catalogue["devices"]: + param_names = [p["name"] for p in device["parameters"]] + self.assertEqual(param_names, sorted(param_names)) + + def test_generator_output_validates_against_catalogue_loader(self): + catalogue = self._build() + # The catalogue module's stdlib validator is the schema enforcer. + # If the generator output drifts from the schema, this fails. + Live12Catalogue.from_dict(catalogue) + + +class DefaultCataloguePathTests(unittest.TestCase): + """The on-disk catalogue at data/live12_catalogue.json must load cleanly + and cover the upstream's full device set.""" + + def test_default_catalogue_loads(self): + catalogue = Live12Catalogue.from_path(DEFAULT_CATALOGUE_PATH) + self.assertGreater(len(catalogue.devices), 50) + + def test_default_catalogue_includes_expected_devices(self): + catalogue = Live12Catalogue.from_path(DEFAULT_CATALOGUE_PATH) + for expected_class in ( + "Saturator", + "Eq8", + "Operator", + "GlueCompressor", + "Compressor2", + "AutoFilter", + "Reverb", + ): + self.assertTrue( + catalogue.has_device(expected_class), + f"default catalogue missing canonical class {expected_class!r}", + ) + + def test_default_catalogue_recognizes_curated_native_catalog_devices(self): + """Regression safety net: every NATIVE device in the prompt-side + curated catalog (`prompts/live12_device_catalog.json`) must be + recognized by `Live12Catalogue.has_device()` in the generated source + catalogue. + + Without this, the validator could silently drop a Gemini + recommendation whose native device exists in the prompt-side curated + catalog but is missing from the proof-gate source catalogue. If this + fails, update the `DISPLAY_NAMES` map in + `scripts/build_live12_catalogue.py` so the source catalogue + alias-resolves the curated catalog's device name to a canonical Live + class. + + Max for Live devices are explicitly out of scope per GOAL: the upstream + MIDI Remote Scripts do not catalog M4L devices, and the validator + falls back to the existing curated check for them. They are filtered + out of this comparison. + """ + catalogue = Live12Catalogue.from_path(DEFAULT_CATALOGUE_PATH) + curated = json.loads(_CURATED_CATALOG.read_text(encoding="utf-8")) + missing: list[str] = [] + for device in curated["devices"]: + if device.get("family") == "MAX_FOR_LIVE": + continue + name = device["name"] + if not catalogue.has_device(name): + missing.append(name) + self.assertEqual( + missing, + [], + ( + "These NATIVE curated devices are not recognized by the source " + "catalogue (data/live12_catalogue.json). Add a displayName " + "alias in scripts/build_live12_catalogue.py and regenerate: " + + ", ".join(missing) + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/backend/tests/test_phase2_validator_catalogue.py b/apps/backend/tests/test_phase2_validator_catalogue.py new file mode 100644 index 00000000..0344772f --- /dev/null +++ b/apps/backend/tests/test_phase2_validator_catalogue.py @@ -0,0 +1,459 @@ +"""Integration tests for the Live 12 catalogue gates in +`server_phase2.apply_live12_catalogue_gates`. + +The synthetic Phase 2 result here intentionally bundles one recommendation in +each of the four categories the goal calls out: + + 1. Valid recommendation — accepted, no event. + 2. Hallucinated device — rejected, RECOMMENDATION_REJECTED with + reason=device_unknown. + 3. Fixable parameter typo — rewritten, PARAMETER_REWRITTEN with original + + resolved + requestId. + 4. Out-of-range value (on a parameter that DOES carry a spec.min/max in the + fixture) — rejected, RECOMMENDATION_REJECTED with + reason=value_out_of_range. + +A fifth case covers the citation gate (missing/empty phase1Fields → +RECOMMENDATION_REJECTED with reason=citation_missing). +""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from typing import Any + +from live12_catalogue import Live12Catalogue +from phase2_catalogue_gates import apply_live12_catalogue_gates + + +_THREE_DEVICE_FIXTURE = ( + Path(__file__).resolve().parent + / "fixtures" + / "live12_catalogue" + / "three_device_catalogue.json" +) + + +def _make_recommendation( + *, + device: str, + parameter: str, + value: str, + phase1_fields: list[str] | None = None, +) -> dict[str, Any]: + """Build the minimum-viable Phase 2 `abletonRecommendations` item.""" + return { + "device": device, + "deviceFamily": "NATIVE", + "trackContext": "Drums", + "workflowStage": "MIX", + "category": "DYNAMICS", + "parameter": parameter, + "value": value, + "reason": "test reason", + "advancedTip": "test advanced tip", + "phase1Fields": phase1_fields if phase1_fields is not None else ["bpm"], + } + + +class CatalogueGatesIntegrationTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.catalogue = Live12Catalogue.from_path(_THREE_DEVICE_FIXTURE) + + def _apply(self, phase2_result: dict[str, Any], *, request_id: str = "req-test-42"): + events = apply_live12_catalogue_gates( + phase2_result, + request_id=request_id, + catalogue=self.catalogue, + ) + return events + + # ----- Valid recommendation ----- + + def test_valid_recommendation_passes_unchanged(self): + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="Saturator", + parameter="Drive", + value="6.0", + phase1_fields=["bpm"], + ), + ], + } + events = self._apply(phase2) + self.assertEqual(events, []) + self.assertEqual(len(phase2["abletonRecommendations"]), 1) + self.assertEqual(phase2["abletonRecommendations"][0]["parameter"], "Drive") + + def test_display_name_device_passes_unchanged(self): + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="EQ Eight", + parameter="1 Frequency A", + value="120", + phase1_fields=["bpm"], + ), + ], + } + events = self._apply(phase2) + self.assertEqual(events, []) + self.assertEqual(len(phase2["abletonRecommendations"]), 1) + + # ----- Hallucinated device ----- + + def test_hallucinated_device_is_rejected(self): + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="Saturation Color", + parameter="Drive", + value="6.0", + ), + ], + } + events = self._apply(phase2) + self.assertEqual(phase2["abletonRecommendations"], []) + self.assertEqual(len(events), 1) + event = events[0] + self.assertEqual(event["code"], "RECOMMENDATION_REJECTED") + self.assertEqual(event["reason"], "device_unknown") + self.assertEqual(event["device"], "Saturation Color") + self.assertEqual(event["requestId"], "req-test-42") + self.assertEqual(event["path"], "abletonRecommendations[0]") + + # ----- Fixable parameter typo ----- + + def test_fixable_parameter_typo_is_rewritten_with_event(self): + # Saturator catalogue has "Drive" (close to "Drives" typo). + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="Saturator", + parameter="Drives", + value="6.0", + ), + ], + } + events = self._apply(phase2) + # Recommendation survives — fuzzy resolution accepted. + self.assertEqual(len(phase2["abletonRecommendations"]), 1) + self.assertEqual(phase2["abletonRecommendations"][0]["parameter"], "Drive") + # A single PARAMETER_REWRITTEN event was emitted, original + resolved + # + requestId all present. + self.assertEqual(len(events), 1) + event = events[0] + self.assertEqual(event["code"], "PARAMETER_REWRITTEN") + self.assertEqual(event["device"], "Saturator") + self.assertEqual(event["originalParameter"], "Drives") + self.assertEqual(event["resolvedParameter"], "Drive") + self.assertEqual(event["requestId"], "req-test-42") + self.assertEqual(event["path"], "abletonRecommendations[0].parameter") + + def test_parameter_unresolvable_typo_is_rejected(self): + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="Saturator", + parameter="TotalGarbage", + value="6.0", + ), + ], + } + events = self._apply(phase2) + self.assertEqual(phase2["abletonRecommendations"], []) + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["code"], "RECOMMENDATION_REJECTED") + self.assertEqual(events[0]["reason"], "parameter_unknown") + self.assertEqual(events[0]["device"], "Saturator") + self.assertEqual(events[0]["parameter"], "TotalGarbage") + + # ----- Out-of-range value ----- + + def test_out_of_range_value_is_rejected(self): + # Operator/Volume in the fixture: type=float, min=-36.0, max=6.0. + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="Operator", + parameter="Volume", + value="42 dB", + ), + ], + } + events = self._apply(phase2) + self.assertEqual(phase2["abletonRecommendations"], []) + self.assertEqual(len(events), 1) + event = events[0] + self.assertEqual(event["code"], "RECOMMENDATION_REJECTED") + self.assertEqual(event["reason"], "value_out_of_range") + self.assertEqual(event["device"], "Operator") + self.assertEqual(event["parameter"], "Volume") + self.assertEqual(event["value"], "42 dB") + + def test_in_range_value_passes(self): + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="Operator", + parameter="Volume", + value="-12.0 dB", + ), + ], + } + events = self._apply(phase2) + self.assertEqual(events, []) + self.assertEqual(len(phase2["abletonRecommendations"]), 1) + + def test_range_gate_is_inert_when_spec_lacks_bounds(self): + # Saturator/Drive in the fixture: name only, no min/max -- range gate + # must NOT fire, even on absurd values. + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="Saturator", + parameter="Drive", + value="9999.0", + ), + ], + } + events = self._apply(phase2) + self.assertEqual(events, []) + self.assertEqual(len(phase2["abletonRecommendations"]), 1) + + def test_unparseable_value_passes_range_gate(self): + # "auto" cannot be coerced to a number — the gate stays silent rather + # than producing a false-positive rejection. + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="Operator", + parameter="Volume", + value="auto", + ), + ], + } + events = self._apply(phase2) + self.assertEqual(events, []) + self.assertEqual(len(phase2["abletonRecommendations"]), 1) + + # ----- Citation gate ----- + + def test_missing_phase1_fields_is_rejected(self): + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="Saturator", + parameter="Drive", + value="6.0", + phase1_fields=[], + ), + ], + } + events = self._apply(phase2) + self.assertEqual(phase2["abletonRecommendations"], []) + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["code"], "RECOMMENDATION_REJECTED") + self.assertEqual(events[0]["reason"], "citation_missing") + self.assertEqual(events[0]["path"], "abletonRecommendations[0].phase1Fields") + + def test_whitespace_only_phase1_fields_is_rejected(self): + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="Saturator", + parameter="Drive", + value="6.0", + phase1_fields=["", " "], + ), + ], + } + events = self._apply(phase2) + self.assertEqual(phase2["abletonRecommendations"], []) + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["reason"], "citation_missing") + + # ----- Mixed: all four cases at once ----- + + def test_mixed_recommendations_drop_only_failures(self): + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="Saturator", parameter="Drive", value="3.0", + ), + _make_recommendation( + device="Saturation Color", parameter="Drive", value="3.0", + ), + _make_recommendation( + device="Saturator", parameter="Drives", value="3.0", + ), + _make_recommendation( + device="Operator", parameter="Volume", value="42 dB", + ), + ], + } + events = self._apply(phase2) + # Survivors: index 0 (valid) and index 2 (rewritten "Drives" -> "Drive"). + self.assertEqual(len(phase2["abletonRecommendations"]), 2) + self.assertEqual(phase2["abletonRecommendations"][0]["parameter"], "Drive") + self.assertEqual(phase2["abletonRecommendations"][1]["parameter"], "Drive") + + codes = sorted(e["code"] for e in events) + reasons = sorted( + e.get("reason", "") for e in events if e["code"] == "RECOMMENDATION_REJECTED" + ) + self.assertEqual( + codes, + ["PARAMETER_REWRITTEN", "RECOMMENDATION_REJECTED", "RECOMMENDATION_REJECTED"], + ) + self.assertEqual(reasons, ["device_unknown", "value_out_of_range"]) + # Base paths still reference the ORIGINAL indices in Gemini's response + # so the operator log can correlate which slot was dropped. + rejection_paths = sorted( + e["path"] for e in events if e["code"] == "RECOMMENDATION_REJECTED" + ) + self.assertEqual( + rejection_paths, + ["abletonRecommendations[1]", "abletonRecommendations[3]"], + ) + + # ----- mixAndMasterChain receives the same gates ----- + + def test_mix_and_master_chain_is_gated(self): + phase2 = { + "mixAndMasterChain": [ + { + "order": 1, + "device": "Saturator", + "deviceFamily": "NATIVE", + "trackContext": "Master", + "workflowStage": "MASTER", + "parameter": "Drives", # fuzzy rewrite to "Drive" + "value": "3.0", + "reason": "test", + "phase1Fields": ["bpm"], + }, + { + "order": 2, + "device": "Saturation Color", # hallucination + "deviceFamily": "NATIVE", + "trackContext": "Master", + "workflowStage": "MASTER", + "parameter": "Drive", + "value": "3.0", + "reason": "test", + "phase1Fields": ["bpm"], + }, + ], + } + events = self._apply(phase2) + self.assertEqual(len(phase2["mixAndMasterChain"]), 1) + self.assertEqual(phase2["mixAndMasterChain"][0]["parameter"], "Drive") + rewrite_paths = sorted( + e["path"] for e in events if e["code"] == "PARAMETER_REWRITTEN" + ) + self.assertEqual(rewrite_paths, ["mixAndMasterChain[0].parameter"]) + rejection_paths = sorted( + e["path"] for e in events if e["code"] == "RECOMMENDATION_REJECTED" + ) + self.assertEqual(rejection_paths, ["mixAndMasterChain[1]"]) + + # ----- secretSauce.workflowSteps ----- + + def test_workflow_steps_are_gated(self): + phase2 = { + "secretSauce": { + "title": "Test", + "explanation": "Test", + "implementationSteps": ["Step 1"], + "workflowSteps": [ + { + "step": 1, + "trackContext": "Drums", + "device": "Saturator", + "parameter": "Drives", # fuzzy rewrite + "value": "3.0", + "instruction": "Test", + "measurementJustification": "Test", + "phase1Fields": ["bpm"], + }, + { + "step": 2, + "trackContext": "Drums", + "device": "Saturator", + "parameter": "Drive", + "value": "3.0", + "instruction": "Test", + "measurementJustification": "Test", + "phase1Fields": [], # missing citation + }, + ], + }, + } + events = self._apply(phase2) + steps = phase2["secretSauce"]["workflowSteps"] + self.assertEqual(len(steps), 1) + self.assertEqual(steps[0]["parameter"], "Drive") + codes = sorted(e["code"] for e in events) + self.assertEqual(codes, ["PARAMETER_REWRITTEN", "RECOMMENDATION_REJECTED"]) + rewrite = next(e for e in events if e["code"] == "PARAMETER_REWRITTEN") + rejection = next(e for e in events if e["code"] == "RECOMMENDATION_REJECTED") + self.assertEqual(rewrite["path"], "secretSauce.workflowSteps[0].parameter") + self.assertEqual(rejection["path"], "secretSauce.workflowSteps[1].phase1Fields") + self.assertEqual(rejection["reason"], "citation_missing") + + # ----- request_id is always present on events ----- + + def test_every_event_carries_request_id(self): + phase2 = { + "abletonRecommendations": [ + _make_recommendation(device="Nope", parameter="Drive", value="1"), + _make_recommendation(device="Saturator", parameter="Drives", value="1"), + ], + } + events = self._apply(phase2, request_id="custom-req-id-001") + self.assertGreater(len(events), 0) + for event in events: + self.assertEqual(event["requestId"], "custom-req-id-001") + + # ----- Idempotency / empty / opaque input ----- + + def test_empty_phase2_result_produces_no_events(self): + phase2: dict[str, Any] = {} + events = self._apply(phase2) + self.assertEqual(events, []) + + def test_non_dict_input_is_safe(self): + events = apply_live12_catalogue_gates( + "not a dict", # type: ignore[arg-type] + request_id="req-test-42", + catalogue=self.catalogue, + ) + self.assertEqual(events, []) + + def test_loads_default_catalogue_when_not_injected(self): + """Smoke test: production code path loads `data/live12_catalogue.json` + via `Live12Catalogue.load_default()` when no catalogue is injected. + This ensures the on-disk catalogue meets the validator's expectations + end-to-end.""" + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="Saturator", + parameter="Drive", + value="6.0", + phase1_fields=["bpm"], + ), + ], + } + # No `catalogue=` kwarg — falls back to load_default(). + events = apply_live12_catalogue_gates(phase2, request_id="req-default-load") + self.assertEqual(events, []) + self.assertEqual(len(phase2["abletonRecommendations"]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/data/live12_catalogue.json b/data/live12_catalogue.json new file mode 100644 index 00000000..2e37e839 --- /dev/null +++ b/data/live12_catalogue.json @@ -0,0 +1,8144 @@ +{ + "schema_version": "1", + "source_commit": "e83d5192f321b24eb9daab843ac49a2d95d862b1", + "source_url": "https://github.com/gluon/AbletonLive12_MIDIRemoteScripts", + "source_files": [ + "Push2/custom_bank_definitions.py", + "Move/custom_bank_definitions.py" + ], + "license_note": "Upstream is a decompiled redistribution of Ableton's MIDI Remote Scripts. ASA does not ship any upstream source; this catalogue records only the device-class and parameter-name metadata extracted via static AST parsing, used as a Phase 2 output-validation gate.", + "generated_at": "2026-05-28T11:53:35Z", + "extraction_notes": "Generated from the upstream Live 12 MIDI Remote Scripts via stdlib ast. Push2/custom_bank_definitions.py contributes the BANK_DEFINITIONS dict literal; Move/custom_bank_definitions.py contributes additional CUSTOM_BANK_DEFINITIONS[] = IndexedDict(...) subscript assignments (including Live 12-new devices like AutoShift). Per-class parameter sets are the union across all source files. Parameter names are taken from BANK_PARAMETERS_KEY and OPTIONS_KEY tuples and from use(...)/else_use(...)/if_parameter(...) call arguments. type/min/max/unit/default are intentionally omitted because they are not present in static source; they live on runtime Live.DeviceParameter objects.", + "devices": [ + { + "class": "Amp", + "category": "audio_effect", + "displayName": "Amp", + "parameters": [ + { + "name": "Amp Type" + }, + { + "name": "Bass" + }, + { + "name": "Dry/Wet" + }, + { + "name": "Dual Mono" + }, + { + "name": "Gain" + }, + { + "name": "Middle" + }, + { + "name": "Presence" + }, + { + "name": "Treble" + }, + { + "name": "Volume" + } + ] + }, + { + "class": "AudioEffectGroupDevice", + "category": "rack", + "displayName": "Audio Effect Rack", + "parameters": [ + { + "name": "Macro 1" + }, + { + "name": "Macro 2" + }, + { + "name": "Macro 3" + }, + { + "name": "Macro 4" + }, + { + "name": "Macro 5" + }, + { + "name": "Macro 6" + }, + { + "name": "Macro 7" + }, + { + "name": "Macro 8" + } + ] + }, + { + "class": "AutoFilter", + "category": "audio_effect", + "displayName": "Auto Filter", + "parameters": [ + { + "name": "Drive" + }, + { + "name": "Env. Attack" + }, + { + "name": "Env. Modulation" + }, + { + "name": "Env. Release" + }, + { + "name": "Filter Circuit - BP/NO/Morph" + }, + { + "name": "Filter Circuit - LP/HP" + }, + { + "name": "Filter Type" + }, + { + "name": "Filter Type (Legacy)" + }, + { + "name": "Frequency" + }, + { + "name": "LFO Amount" + }, + { + "name": "LFO Frequency" + }, + { + "name": "LFO Offset" + }, + { + "name": "LFO Phase" + }, + { + "name": "LFO Quantize On" + }, + { + "name": "LFO Quantize Rate" + }, + { + "name": "LFO Spin" + }, + { + "name": "LFO Stereo Mode" + }, + { + "name": "LFO Sync" + }, + { + "name": "LFO Sync Rate" + }, + { + "name": "LFO Waveform" + }, + { + "name": "Morph" + }, + { + "name": "Resonance" + }, + { + "name": "Resonance (Legacy)" + }, + { + "name": "S/C Gain" + }, + { + "name": "S/C Mix" + }, + { + "name": "S/C On" + }, + { + "name": "Slope" + } + ] + }, + { + "class": "AutoFilter2", + "category": "audio_effect", + "displayName": "Auto Filter (Move)", + "parameters": [ + { + "name": "Control" + }, + { + "name": "Drive" + }, + { + "name": "Dry/Wet" + }, + { + "name": "Filter Morph" + }, + { + "name": "Filter Type" + }, + { + "name": "Formant" + }, + { + "name": "Frequency" + }, + { + "name": "LFO 16th" + }, + { + "name": "LFO Amount" + }, + { + "name": "LFO Freq" + }, + { + "name": "LFO Rate" + }, + { + "name": "LFO T Mode" + }, + { + "name": "LFO Time" + }, + { + "name": "LFO Wave" + }, + { + "name": "Pitch" + }, + { + "name": "Resonance" + } + ] + }, + { + "class": "AutoPan", + "category": "audio_effect", + "displayName": "Auto Pan-Tremolo", + "parameters": [ + { + "name": "Amount" + }, + { + "name": "Frequency" + }, + { + "name": "Invert" + }, + { + "name": "LFO Type" + }, + { + "name": "Offset" + }, + { + "name": "Phase" + }, + { + "name": "Shape" + }, + { + "name": "Spin" + }, + { + "name": "Stereo Mode" + }, + { + "name": "Sync Rate" + }, + { + "name": "Waveform" + }, + { + "name": "Width (Random)" + } + ] + }, + { + "class": "AutoPan2", + "category": "audio_effect", + "displayName": "Auto Pan-Tremolo (Move)", + "parameters": [ + { + "name": "16th" + }, + { + "name": "Amount" + }, + { + "name": "Attack Time" + }, + { + "name": "Dyn Mod" + }, + { + "name": "Frequency" + }, + { + "name": "Harmonic" + }, + { + "name": "Invert" + }, + { + "name": "Mode" + }, + { + "name": "Panning Shape" + }, + { + "name": "Phase" + }, + { + "name": "Rate" + }, + { + "name": "Spin" + }, + { + "name": "Stereo Mode" + }, + { + "name": "Time" + }, + { + "name": "Time Mode" + }, + { + "name": "Tremolo Shape" + }, + { + "name": "Waveform" + } + ] + }, + { + "class": "AutoShift", + "category": "audio_effect", + "displayName": "Auto Shift", + "parameters": [ + { + "name": "Dry/Wet" + }, + { + "name": "Formant Follow" + }, + { + "name": "Formant Shift" + }, + { + "name": "Pitch Fine" + }, + { + "name": "Pitch Scale Deg." + }, + { + "name": "Pitch St." + }, + { + "name": "Root" + }, + { + "name": "Scale" + }, + { + "name": "Smooth Time" + }, + { + "name": "Strength" + }, + { + "name": "Vibrato Amt" + }, + { + "name": "Vibrato Rate" + } + ] + }, + { + "class": "BeatRepeat", + "category": "audio_effect", + "displayName": "Beat Repeat", + "parameters": [ + { + "name": "Chance" + }, + { + "name": "Decay" + }, + { + "name": "Filter" + }, + { + "name": "Filter Freq" + }, + { + "name": "Filter Width" + }, + { + "name": "Gate" + }, + { + "name": "Grid" + }, + { + "name": "Interval" + }, + { + "name": "Mix Type" + }, + { + "name": "Offset" + }, + { + "name": "Pitch" + }, + { + "name": "Pitch Decay" + }, + { + "name": "Repeat" + }, + { + "name": "Triplets" + }, + { + "name": "Variation" + }, + { + "name": "Variation Type" + }, + { + "name": "Volume" + } + ] + }, + { + "class": "Cabinet", + "category": "audio_effect", + "displayName": "Cabinet", + "parameters": [ + { + "name": "Cabinet Type" + }, + { + "name": "Dry/Wet" + }, + { + "name": "Dual Mono" + }, + { + "name": "Microphone Position" + }, + { + "name": "Microphone Type" + } + ] + }, + { + "class": "ChannelEq", + "category": "audio_effect", + "displayName": "Channel EQ", + "parameters": [ + { + "name": "Gain" + }, + { + "name": "High Gain" + }, + { + "name": "Highpass On" + }, + { + "name": "Low Gain" + }, + { + "name": "Mid Freq" + }, + { + "name": "Mid Gain" + }, + { + "name": "Output" + } + ] + }, + { + "class": "Chorus", + "category": "audio_effect", + "displayName": "Chorus", + "parameters": [ + { + "name": "Delay 1 HiPass" + }, + { + "name": "Delay 1 Time" + }, + { + "name": "Delay 2 Mode" + }, + { + "name": "Delay 2 Time" + }, + { + "name": "Dry/Wet" + }, + { + "name": "Feedback" + }, + { + "name": "LFO Amount" + }, + { + "name": "LFO Extend On" + }, + { + "name": "LFO Rate" + }, + { + "name": "Link On" + }, + { + "name": "Polarity" + } + ] + }, + { + "class": "Chorus2", + "category": "audio_effect", + "displayName": "Chorus-Ensemble", + "parameters": [ + { + "name": "Amount" + }, + { + "name": "Dry/Wet" + }, + { + "name": "FB Inv" + }, + { + "name": "Feedback" + }, + { + "name": "Gain" + }, + { + "name": "HP Freq" + }, + { + "name": "HPF Freq" + }, + { + "name": "High Pass" + }, + { + "name": "Mode" + }, + { + "name": "Offset" + }, + { + "name": "Rate" + }, + { + "name": "Shape" + }, + { + "name": "Warmth" + }, + { + "name": "Width" + } + ] + }, + { + "class": "Collision", + "category": "instrument", + "displayName": "Collision", + "parameters": [ + { + "name": "LFO 1" + }, + { + "name": "LFO 1 Amt A" + }, + { + "name": "LFO 1 Amt B" + }, + { + "name": "LFO 1 Depth" + }, + { + "name": "LFO 1 Depth < Vel" + }, + { + "name": "LFO 1 Dest A" + }, + { + "name": "LFO 1 Dest B" + }, + { + "name": "LFO 1 Offset" + }, + { + "name": "LFO 1 Rate" + }, + { + "name": "LFO 1 Rate < Key" + }, + { + "name": "LFO 1 Retrig" + }, + { + "name": "LFO 1 Shape" + }, + { + "name": "LFO 1 Sync" + }, + { + "name": "LFO 1 Sync Rate" + }, + { + "name": "LFO 2" + }, + { + "name": "LFO 2 Amt A" + }, + { + "name": "LFO 2 Amt B" + }, + { + "name": "LFO 2 Depth" + }, + { + "name": "LFO 2 Depth < Vel" + }, + { + "name": "LFO 2 Dest A" + }, + { + "name": "LFO 2 Dest B" + }, + { + "name": "LFO 2 Offset" + }, + { + "name": "LFO 2 Rate" + }, + { + "name": "LFO 2 Rate < Key" + }, + { + "name": "LFO 2 Retrig" + }, + { + "name": "LFO 2 Shape" + }, + { + "name": "LFO 2 Sync" + }, + { + "name": "LFO 2 Sync Rate" + }, + { + "name": "LFO Select" + }, + { + "name": "MW Amt A" + }, + { + "name": "MW Amt B" + }, + { + "name": "MW Dest A" + }, + { + "name": "MW Dest B" + }, + { + "name": "Mallet" + }, + { + "name": "Mallet Noise Amount" + }, + { + "name": "Mallet Noise Amount < Key" + }, + { + "name": "Mallet Noise Amount < Vel" + }, + { + "name": "Mallet Noise Color" + }, + { + "name": "Mallet Stiffness" + }, + { + "name": "Mallet Stiffness < Key" + }, + { + "name": "Mallet Stiffness < Vel" + }, + { + "name": "Mallet Volume" + }, + { + "name": "Mallet Volume < Key" + }, + { + "name": "Mallet Volume < Vel" + }, + { + "name": "Mod Dest" + }, + { + "name": "Mod Source" + }, + { + "name": "Noise" + }, + { + "name": "Noise Attack" + }, + { + "name": "Noise Decay" + }, + { + "name": "Noise Filter Freq" + }, + { + "name": "Noise Filter Q" + }, + { + "name": "Noise Filter Type" + }, + { + "name": "Noise Freq < Key" + }, + { + "name": "Noise Freq < Vel" + }, + { + "name": "Noise Release" + }, + { + "name": "Noise Sustain" + }, + { + "name": "Noise Volume" + }, + { + "name": "Noise Volume < Key" + }, + { + "name": "Noise Volume < Vel" + }, + { + "name": "Note PB Range" + }, + { + "name": "PB Amt A" + }, + { + "name": "PB Dest A" + }, + { + "name": "PB Range" + }, + { + "name": "Press Amt A" + }, + { + "name": "Press Amt B" + }, + { + "name": "Press Dest A" + }, + { + "name": "Press Dest B" + }, + { + "name": "Res 1" + }, + { + "name": "Res 1 Bleed" + }, + { + "name": "Res 1 Brightness" + }, + { + "name": "Res 1 Decay" + }, + { + "name": "Res 1 Decay < Key" + }, + { + "name": "Res 1 Decay < Vel" + }, + { + "name": "Res 1 Fine Tune" + }, + { + "name": "Res 1 Hit" + }, + { + "name": "Res 1 Hit < Random" + }, + { + "name": "Res 1 Inharmonics" + }, + { + "name": "Res 1 Inharmonics < Vel" + }, + { + "name": "Res 1 Listening L" + }, + { + "name": "Res 1 Listening R" + }, + { + "name": "Res 1 Material" + }, + { + "name": "Res 1 Material < Key" + }, + { + "name": "Res 1 Material < Vel" + }, + { + "name": "Res 1 Opening" + }, + { + "name": "Res 1 Pan" + }, + { + "name": "Res 1 Pan < Key" + }, + { + "name": "Res 1 Pitch Env." + }, + { + "name": "Res 1 Pitch Env. < Vel" + }, + { + "name": "Res 1 Pitch Env. Time" + }, + { + "name": "Res 1 Quality" + }, + { + "name": "Res 1 Radius" + }, + { + "name": "Res 1 Ratio" + }, + { + "name": "Res 1 Tune" + }, + { + "name": "Res 1 Tune < Key" + }, + { + "name": "Res 1 Type" + }, + { + "name": "Res 1 Volume" + }, + { + "name": "Res 2" + }, + { + "name": "Res 2 Bleed" + }, + { + "name": "Res 2 Brightness" + }, + { + "name": "Res 2 Decay" + }, + { + "name": "Res 2 Decay < Key" + }, + { + "name": "Res 2 Decay < Vel" + }, + { + "name": "Res 2 Fine Tune" + }, + { + "name": "Res 2 Hit" + }, + { + "name": "Res 2 Hit < Random" + }, + { + "name": "Res 2 Inharmonics" + }, + { + "name": "Res 2 Inharmonics < Vel" + }, + { + "name": "Res 2 Listening L" + }, + { + "name": "Res 2 Listening R" + }, + { + "name": "Res 2 Material" + }, + { + "name": "Res 2 Material < Key" + }, + { + "name": "Res 2 Material < Vel" + }, + { + "name": "Res 2 Opening" + }, + { + "name": "Res 2 Pan" + }, + { + "name": "Res 2 Pan < Key" + }, + { + "name": "Res 2 Pitch Env." + }, + { + "name": "Res 2 Pitch Env. < Vel" + }, + { + "name": "Res 2 Pitch Env. Time" + }, + { + "name": "Res 2 Quality" + }, + { + "name": "Res 2 Radius" + }, + { + "name": "Res 2 Ratio" + }, + { + "name": "Res 2 Tune" + }, + { + "name": "Res 2 Tune < Key" + }, + { + "name": "Res 2 Type" + }, + { + "name": "Res 2 Volume" + }, + { + "name": "Resonator" + }, + { + "name": "Slide Amt A" + }, + { + "name": "Slide Amt B" + }, + { + "name": "Slide Dest A" + }, + { + "name": "Slide Dest B" + }, + { + "name": "Structure" + }, + { + "name": "Volume" + } + ] + }, + { + "class": "Compressor2", + "category": "audio_effect", + "displayName": "Compressor", + "parameters": [ + { + "name": "Attack" + }, + { + "name": "Auto Release" + }, + { + "name": "Dry/Wet" + }, + { + "name": "Expansion Ratio" + }, + { + "name": "Input Channel" + }, + { + "name": "Input Type" + }, + { + "name": "Knee" + }, + { + "name": "Listen" + }, + { + "name": "Makeup" + }, + { + "name": "Model" + }, + { + "name": "Output Gain" + }, + { + "name": "Position" + }, + { + "name": "Ratio" + }, + { + "name": "Release" + }, + { + "name": "S/C EQ Freq" + }, + { + "name": "S/C EQ Gain" + }, + { + "name": "S/C EQ On" + }, + { + "name": "S/C EQ Q" + }, + { + "name": "S/C EQ Type" + }, + { + "name": "S/C Gain" + }, + { + "name": "S/C Mix" + }, + { + "name": "Sidechain" + }, + { + "name": "Threshold" + } + ] + }, + { + "class": "Corpus", + "category": "audio_effect", + "displayName": "Corpus", + "parameters": [ + { + "name": "Bleed" + }, + { + "name": "Brightness" + }, + { + "name": "Decay" + }, + { + "name": "Dry Wet" + }, + { + "name": "Filter On/Off" + }, + { + "name": "Fine" + }, + { + "name": "Gain" + }, + { + "name": "Hit" + }, + { + "name": "Inharmonics" + }, + { + "name": "LFO Amount" + }, + { + "name": "LFO On/Off" + }, + { + "name": "LFO Rate" + }, + { + "name": "LFO Shape" + }, + { + "name": "LFO Stereo Mode" + }, + { + "name": "LFO Sync" + }, + { + "name": "LFO Sync Rate" + }, + { + "name": "Listening L" + }, + { + "name": "Listening R" + }, + { + "name": "MIDI Frequency" + }, + { + "name": "MIDI Mode" + }, + { + "name": "Material" + }, + { + "name": "Mid Freq" + }, + { + "name": "Note Off" + }, + { + "name": "Off Decay" + }, + { + "name": "Offset" + }, + { + "name": "Opening" + }, + { + "name": "PB Range" + }, + { + "name": "Phase" + }, + { + "name": "Radius" + }, + { + "name": "Ratio" + }, + { + "name": "Resonance Type" + }, + { + "name": "Resonator Quality" + }, + { + "name": "Spin" + }, + { + "name": "Spread" + }, + { + "name": "Transpose" + }, + { + "name": "Tune" + }, + { + "name": "Width" + } + ] + }, + { + "class": "Delay", + "category": "audio_effect", + "displayName": "Delay", + "parameters": [ + { + "name": "Channel" + }, + { + "name": "Delay Mode" + }, + { + "name": "Dly < Mod" + }, + { + "name": "Dry/Wet" + }, + { + "name": "Feedback" + }, + { + "name": "Filter" + }, + { + "name": "Filter < Mod" + }, + { + "name": "Filter Freq" + }, + { + "name": "Filter Width" + }, + { + "name": "Freeze" + }, + { + "name": "L 16th" + }, + { + "name": "L Delay Sync" + }, + { + "name": "L Offset" + }, + { + "name": "L Sync" + }, + { + "name": "L Sync Enum" + }, + { + "name": "L Time" + }, + { + "name": "LFO > Delay" + }, + { + "name": "LFO > Filter" + }, + { + "name": "LFO Freq" + }, + { + "name": "Link Switch" + }, + { + "name": "Mod Freq" + }, + { + "name": "Ping Pong" + }, + { + "name": "R 16th" + }, + { + "name": "R Delay Sync" + }, + { + "name": "R Offset" + }, + { + "name": "R Sync" + }, + { + "name": "R Sync Enum" + }, + { + "name": "R Time" + } + ] + }, + { + "class": "Drift", + "category": "instrument", + "displayName": "Drift", + "parameters": [ + { + "name": "Cyc Env Hold" + }, + { + "name": "Cyc Env Rate" + }, + { + "name": "Cyc Env Ratio" + }, + { + "name": "Cyc Env Synced" + }, + { + "name": "Cyc Env Tilt" + }, + { + "name": "Cyc Env Time" + }, + { + "name": "Cyc Env Time Mode" + }, + { + "name": "Drift" + }, + { + "name": "Env 1 Attack" + }, + { + "name": "Env 1 Decay" + }, + { + "name": "Env 1 Release" + }, + { + "name": "Env 1 Sustain" + }, + { + "name": "Env 2 Attack" + }, + { + "name": "Env 2 Cyc On" + }, + { + "name": "Env 2 Decay" + }, + { + "name": "Env 2 Release" + }, + { + "name": "Env 2 Sustain" + }, + { + "name": "Glide Time" + }, + { + "name": "HP Freq" + }, + { + "name": "Key > LPF" + }, + { + "name": "LFO Amt" + }, + { + "name": "LFO Mod Amt" + }, + { + "name": "LFO Mod Src" + }, + { + "name": "LFO Rate" + }, + { + "name": "LFO Ratio" + }, + { + "name": "LFO Retrig" + }, + { + "name": "LFO Retrig On" + }, + { + "name": "LFO Synced" + }, + { + "name": "LFO Time" + }, + { + "name": "LFO Time Mode" + }, + { + "name": "LFO Wave" + }, + { + "name": "LP Freq" + }, + { + "name": "LP Mod Amt 1" + }, + { + "name": "LP Mod Amt 2" + }, + { + "name": "LP Mod Src 1" + }, + { + "name": "LP Mod Src 2" + }, + { + "name": "LP Res" + }, + { + "name": "LP Reso" + }, + { + "name": "LP Type" + }, + { + "name": "Legato" + }, + { + "name": "Legato On" + }, + { + "name": "Mod Dest" + }, + { + "name": "Mod Dest 1" + }, + { + "name": "Mod Dest 2" + }, + { + "name": "Mod Dest 3" + }, + { + "name": "Mod Matrix Amt 1" + }, + { + "name": "Mod Matrix Amt 2" + }, + { + "name": "Mod Matrix Amt 3" + }, + { + "name": "Mod Slot" + }, + { + "name": "Mod Source 1" + }, + { + "name": "Mod Source 2" + }, + { + "name": "Mod Source 3" + }, + { + "name": "Noise" + }, + { + "name": "Noise Flt" + }, + { + "name": "Noise Gain" + }, + { + "name": "Noise On" + }, + { + "name": "Note PB" + }, + { + "name": "Osc 1" + }, + { + "name": "Osc 1 Flt" + }, + { + "name": "Osc 1 Flt On" + }, + { + "name": "Osc 1 Gain" + }, + { + "name": "Osc 1 Oct" + }, + { + "name": "Osc 1 Shape" + }, + { + "name": "Osc 1 Shape Mod Amt" + }, + { + "name": "Osc 1 Wave" + }, + { + "name": "Osc 2" + }, + { + "name": "Osc 2 Detune" + }, + { + "name": "Osc 2 Flt" + }, + { + "name": "Osc 2 Flt On" + }, + { + "name": "Osc 2 Gain" + }, + { + "name": "Osc 2 Oct" + }, + { + "name": "Osc 2 Shape" + }, + { + "name": "Osc 2 Wave" + }, + { + "name": "Osc Retrig" + }, + { + "name": "Osc Retrig On" + }, + { + "name": "Osc Select" + }, + { + "name": "PB Range" + }, + { + "name": "Pitch Mod Amt 1" + }, + { + "name": "Pitch Mod Amt 2" + }, + { + "name": "Pitch Mod Src 1" + }, + { + "name": "Pitch Mod Src 2" + }, + { + "name": "Shape Mod Src" + }, + { + "name": "Spread" + }, + { + "name": "Strength" + }, + { + "name": "Thickness" + }, + { + "name": "Transpose" + }, + { + "name": "Vel > Vol" + }, + { + "name": "Voice Count" + }, + { + "name": "Voice Mode" + }, + { + "name": "Volume" + } + ] + }, + { + "class": "DrumBuss", + "category": "audio_effect", + "displayName": "Drum Buss", + "parameters": [ + { + "name": "Boom Amt" + }, + { + "name": "Boom Audition" + }, + { + "name": "Boom Decay" + }, + { + "name": "Boom Freq" + }, + { + "name": "Compressor" + }, + { + "name": "Crunch" + }, + { + "name": "Damping Freq" + }, + { + "name": "Drive" + }, + { + "name": "Drive Type" + }, + { + "name": "Dry/Wet" + }, + { + "name": "Output Gain" + }, + { + "name": "Transients" + }, + { + "name": "Trim" + } + ] + }, + { + "class": "DrumCell", + "category": "instrument", + "displayName": "Drum Sampler", + "parameters": [ + { + "name": "8-Bit Flt Decay" + }, + { + "name": "8-Bit Rate" + }, + { + "name": "Attack" + }, + { + "name": "Decay" + }, + { + "name": "Detune" + }, + { + "name": "Env Mode" + }, + { + "name": "FM Amt" + }, + { + "name": "FM Freq" + }, + { + "name": "FX Type" + }, + { + "name": "Filter" + }, + { + "name": "Filter Freq" + }, + { + "name": "Filter Gain" + }, + { + "name": "Filter Res" + }, + { + "name": "Filter Type" + }, + { + "name": "Flt Freq" + }, + { + "name": "Flt Reso" + }, + { + "name": "Flt Type" + }, + { + "name": "Grain Size" + }, + { + "name": "Hold" + }, + { + "name": "Length" + }, + { + "name": "Loop Length" + }, + { + "name": "Loop Offset" + }, + { + "name": "Mod Amt" + }, + { + "name": "Mod Dest" + }, + { + "name": "Noise Amt" + }, + { + "name": "Noise Color" + }, + { + "name": "Pan" + }, + { + "name": "Pitch Env Amt" + }, + { + "name": "Pitch Env Decay" + }, + { + "name": "Punch Amt" + }, + { + "name": "Punch Release" + }, + { + "name": "RM Amt" + }, + { + "name": "RM Freq" + }, + { + "name": "Select" + }, + { + "name": "Start" + }, + { + "name": "Stretch Factor" + }, + { + "name": "Sub Amt" + }, + { + "name": "Sub Freq" + }, + { + "name": "Transpose" + }, + { + "name": "Vel > Vol" + }, + { + "name": "Volume" + } + ] + }, + { + "class": "DrumGroupDevice", + "category": "rack", + "displayName": "Drum Rack", + "parameters": [ + { + "name": "Macro 1" + }, + { + "name": "Macro 2" + }, + { + "name": "Macro 3" + }, + { + "name": "Macro 4" + }, + { + "name": "Macro 5" + }, + { + "name": "Macro 6" + }, + { + "name": "Macro 7" + }, + { + "name": "Macro 8" + } + ] + }, + { + "class": "Echo", + "category": "audio_effect", + "displayName": "Echo", + "parameters": [ + { + "name": "Channel Mode" + }, + { + "name": "Channel Toggle" + }, + { + "name": "Clip Dry" + }, + { + "name": "Dly < Mod" + }, + { + "name": "Dry Wet" + }, + { + "name": "Duck" + }, + { + "name": "Duck Release" + }, + { + "name": "Duck Thr" + }, + { + "name": "Env Mix" + }, + { + "name": "Feedback" + }, + { + "name": "Filter" + }, + { + "name": "Filter On" + }, + { + "name": "Flt < Mod" + }, + { + "name": "Gate" + }, + { + "name": "Gate Release" + }, + { + "name": "Gate Thr" + }, + { + "name": "HP Freq" + }, + { + "name": "HP Res" + }, + { + "name": "Input Gain" + }, + { + "name": "Invert" + }, + { + "name": "L 16th" + }, + { + "name": "L Division" + }, + { + "name": "L Offset" + }, + { + "name": "L Sync" + }, + { + "name": "L Sync Mode" + }, + { + "name": "L Time" + }, + { + "name": "L/R Switch" + }, + { + "name": "LP Freq" + }, + { + "name": "LP Res" + }, + { + "name": "Link" + }, + { + "name": "M Sync" + }, + { + "name": "M/S Switch" + }, + { + "name": "Mod 4x" + }, + { + "name": "Mod Freq" + }, + { + "name": "Mod Phase" + }, + { + "name": "Mod Rate" + }, + { + "name": "Mod Sync" + }, + { + "name": "Mod Wave" + }, + { + "name": "Noise" + }, + { + "name": "Noise Amt" + }, + { + "name": "Noise Mrph" + }, + { + "name": "Output Gain" + }, + { + "name": "R 16th" + }, + { + "name": "R Division" + }, + { + "name": "R Offset" + }, + { + "name": "R Sync" + }, + { + "name": "R Sync Mode" + }, + { + "name": "R Time" + }, + { + "name": "Repitch" + }, + { + "name": "Reverb Decay" + }, + { + "name": "Reverb Level" + }, + { + "name": "Reverb Loc" + }, + { + "name": "S Sync" + }, + { + "name": "Stereo Width" + }, + { + "name": "Wobble" + }, + { + "name": "Wobble Amt" + }, + { + "name": "Wobble Mrph" + } + ] + }, + { + "class": "Eq8", + "category": "audio_effect", + "displayName": "EQ Eight", + "parameters": [ + { + "name": "1 Filter On A" + }, + { + "name": "1 Filter On B" + }, + { + "name": "1 Filter Type A" + }, + { + "name": "1 Filter Type B" + }, + { + "name": "1 Frequency A" + }, + { + "name": "1 Frequency B" + }, + { + "name": "1 Gain A" + }, + { + "name": "1 Gain B" + }, + { + "name": "1 Resonance A" + }, + { + "name": "1 Resonance B" + }, + { + "name": "2 Filter On A" + }, + { + "name": "2 Filter On B" + }, + { + "name": "2 Filter Type A" + }, + { + "name": "2 Filter Type B" + }, + { + "name": "2 Frequency A" + }, + { + "name": "2 Frequency B" + }, + { + "name": "2 Gain A" + }, + { + "name": "2 Gain B" + }, + { + "name": "2 Resonance A" + }, + { + "name": "2 Resonance B" + }, + { + "name": "3 Filter On A" + }, + { + "name": "3 Filter On B" + }, + { + "name": "3 Filter Type A" + }, + { + "name": "3 Filter Type B" + }, + { + "name": "3 Frequency A" + }, + { + "name": "3 Frequency B" + }, + { + "name": "3 Gain A" + }, + { + "name": "3 Gain B" + }, + { + "name": "3 Resonance A" + }, + { + "name": "3 Resonance B" + }, + { + "name": "4 Filter On A" + }, + { + "name": "4 Filter On B" + }, + { + "name": "4 Filter Type A" + }, + { + "name": "4 Filter Type B" + }, + { + "name": "4 Frequency A" + }, + { + "name": "4 Frequency B" + }, + { + "name": "4 Gain A" + }, + { + "name": "4 Gain B" + }, + { + "name": "4 Resonance A" + }, + { + "name": "4 Resonance B" + }, + { + "name": "5 Filter On A" + }, + { + "name": "5 Filter On B" + }, + { + "name": "5 Filter Type A" + }, + { + "name": "5 Filter Type B" + }, + { + "name": "5 Frequency A" + }, + { + "name": "5 Frequency B" + }, + { + "name": "5 Gain A" + }, + { + "name": "5 Gain B" + }, + { + "name": "5 Resonance A" + }, + { + "name": "5 Resonance B" + }, + { + "name": "6 Filter On A" + }, + { + "name": "6 Filter On B" + }, + { + "name": "6 Filter Type A" + }, + { + "name": "6 Filter Type B" + }, + { + "name": "6 Frequency A" + }, + { + "name": "6 Frequency B" + }, + { + "name": "6 Gain A" + }, + { + "name": "6 Gain B" + }, + { + "name": "6 Resonance A" + }, + { + "name": "6 Resonance B" + }, + { + "name": "7 Filter On A" + }, + { + "name": "7 Filter On B" + }, + { + "name": "7 Filter Type A" + }, + { + "name": "7 Filter Type B" + }, + { + "name": "7 Frequency A" + }, + { + "name": "7 Frequency B" + }, + { + "name": "7 Gain A" + }, + { + "name": "7 Gain B" + }, + { + "name": "7 Resonance A" + }, + { + "name": "7 Resonance B" + }, + { + "name": "8 Filter On A" + }, + { + "name": "8 Filter On B" + }, + { + "name": "8 Filter Type A" + }, + { + "name": "8 Filter Type B" + }, + { + "name": "8 Frequency A" + }, + { + "name": "8 Frequency B" + }, + { + "name": "8 Gain A" + }, + { + "name": "8 Gain B" + }, + { + "name": "8 Resonance A" + }, + { + "name": "8 Resonance B" + }, + { + "name": "Adaptive Q" + }, + { + "name": "Band" + }, + { + "name": "Edit Mode" + }, + { + "name": "Eq Mode" + }, + { + "name": "Left/Right" + }, + { + "name": "Mid/Side" + }, + { + "name": "Output Gain" + }, + { + "name": "Oversampling" + }, + { + "name": "Scale" + } + ] + }, + { + "class": "Erosion", + "category": "audio_effect", + "displayName": "Erosion", + "parameters": [ + { + "name": "Amount" + }, + { + "name": "Frequency" + }, + { + "name": "Mode" + }, + { + "name": "Width" + } + ] + }, + { + "class": "Erosion2", + "category": "audio_effect", + "displayName": "Erosion (Move)", + "parameters": [ + { + "name": "Amount" + }, + { + "name": "Filter Width" + }, + { + "name": "Frequency" + }, + { + "name": "Noise Blend" + }, + { + "name": "Stereo Width" + } + ] + }, + { + "class": "FilterDelay", + "category": "audio_effect", + "displayName": "Filter Delay", + "parameters": [ + { + "name": "1 Beat Delay" + }, + { + "name": "1 Beat Swing" + }, + { + "name": "1 Delay Mode" + }, + { + "name": "1 Feedback" + }, + { + "name": "1 Filter Freq" + }, + { + "name": "1 Filter Width" + }, + { + "name": "1 Pan" + }, + { + "name": "1 Time Delay" + }, + { + "name": "1 Volume" + }, + { + "name": "2 Beat Delay" + }, + { + "name": "2 Beat Swing" + }, + { + "name": "2 Delay Mode" + }, + { + "name": "2 Feedback" + }, + { + "name": "2 Filter Freq" + }, + { + "name": "2 Filter Width" + }, + { + "name": "2 Pan" + }, + { + "name": "2 Time Delay" + }, + { + "name": "2 Volume" + }, + { + "name": "3 Beat Delay" + }, + { + "name": "3 Beat Swing" + }, + { + "name": "3 Delay Mode" + }, + { + "name": "3 Feedback" + }, + { + "name": "3 Filter Freq" + }, + { + "name": "3 Filter Width" + }, + { + "name": "3 Pan" + }, + { + "name": "3 Time Delay" + }, + { + "name": "3 Volume" + }, + { + "name": "Chan Select" + }, + { + "name": "Dry" + }, + { + "name": "L Channel" + }, + { + "name": "L Filter" + }, + { + "name": "L Sync" + }, + { + "name": "L+R Channel" + }, + { + "name": "L+R Filter" + }, + { + "name": "L+R Sync" + }, + { + "name": "R Channel" + }, + { + "name": "R Filter" + }, + { + "name": "R Sync" + } + ] + }, + { + "class": "FilterEQ3", + "category": "audio_effect", + "displayName": "EQ Three", + "parameters": [ + { + "name": "FreqHi" + }, + { + "name": "FreqLo" + }, + { + "name": "GainHi" + }, + { + "name": "GainLo" + }, + { + "name": "GainMid" + }, + { + "name": "High" + }, + { + "name": "Low" + }, + { + "name": "Mid" + }, + { + "name": "Slope" + } + ] + }, + { + "class": "Flanger", + "category": "audio_effect", + "displayName": "Flanger", + "parameters": [ + { + "name": "Delay Time" + }, + { + "name": "Dry/Wet" + }, + { + "name": "Env. Attack" + }, + { + "name": "Env. Modulation" + }, + { + "name": "Env. Release" + }, + { + "name": "Feedback" + }, + { + "name": "Frequency" + }, + { + "name": "Hi Pass" + }, + { + "name": "LFO Amount" + }, + { + "name": "LFO Offset" + }, + { + "name": "LFO Phase" + }, + { + "name": "LFO Spin" + }, + { + "name": "LFO Stereo Mode" + }, + { + "name": "LFO Waveform" + }, + { + "name": "LFO Width (Random)" + }, + { + "name": "Polarity" + }, + { + "name": "Sync" + }, + { + "name": "Sync Rate" + } + ] + }, + { + "class": "FrequencyShifter", + "category": "audio_effect", + "displayName": "Frequency Shifter", + "parameters": [ + { + "name": "Coarse" + }, + { + "name": "Drive" + }, + { + "name": "Drive On/Off" + }, + { + "name": "Dry/Wet" + }, + { + "name": "Fine" + }, + { + "name": "LFO Amount" + }, + { + "name": "LFO Frequency" + }, + { + "name": "LFO Offset" + }, + { + "name": "LFO Phase" + }, + { + "name": "LFO Spin" + }, + { + "name": "LFO Stereo Mode" + }, + { + "name": "LFO Waveform" + }, + { + "name": "LFO Width (Random)" + }, + { + "name": "Mode" + }, + { + "name": "Ring Mod Frequency" + }, + { + "name": "Sync" + }, + { + "name": "Sync Rate" + }, + { + "name": "Wide" + } + ] + }, + { + "class": "Gate", + "category": "audio_effect", + "displayName": "Gate", + "parameters": [ + { + "name": "Attack" + }, + { + "name": "FlipMode" + }, + { + "name": "Floor" + }, + { + "name": "Hold" + }, + { + "name": "LookAhead" + }, + { + "name": "Release" + }, + { + "name": "Return" + }, + { + "name": "S/C EQ Freq" + }, + { + "name": "S/C EQ Gain" + }, + { + "name": "S/C EQ On" + }, + { + "name": "S/C EQ Q" + }, + { + "name": "S/C EQ Type" + }, + { + "name": "S/C Gain" + }, + { + "name": "S/C Listen" + }, + { + "name": "S/C Mix" + }, + { + "name": "S/C On" + }, + { + "name": "Threshold" + } + ] + }, + { + "class": "GlueCompressor", + "category": "audio_effect", + "displayName": "Glue Compressor", + "parameters": [ + { + "name": "Attack" + }, + { + "name": "Dry/Wet" + }, + { + "name": "Makeup" + }, + { + "name": "Peak Clip In" + }, + { + "name": "Range" + }, + { + "name": "Ratio" + }, + { + "name": "Release" + }, + { + "name": "S/C EQ Freq" + }, + { + "name": "S/C EQ Gain" + }, + { + "name": "S/C EQ On" + }, + { + "name": "S/C EQ Q" + }, + { + "name": "S/C EQ Type" + }, + { + "name": "S/C Gain" + }, + { + "name": "S/C Mix" + }, + { + "name": "S/C On" + }, + { + "name": "Threshold" + } + ] + }, + { + "class": "GrainDelay", + "category": "audio_effect", + "displayName": "Grain Delay", + "parameters": [ + { + "name": "Beat Delay" + }, + { + "name": "Beat Swing" + }, + { + "name": "Delay Mode" + }, + { + "name": "DryWet" + }, + { + "name": "Feedback" + }, + { + "name": "Frequency" + }, + { + "name": "Pitch" + }, + { + "name": "Random" + }, + { + "name": "Spray" + }, + { + "name": "Sync" + }, + { + "name": "Time Delay" + } + ] + }, + { + "class": "Hybrid", + "category": "audio_effect", + "displayName": "Hybrid Reverb", + "parameters": [ + { + "name": "Algo Delay" + }, + { + "name": "Algo Type" + }, + { + "name": "Band" + }, + { + "name": "Bass Mono" + }, + { + "name": "Blend" + }, + { + "name": "DH Bass X" + }, + { + "name": "DH BassMult" + }, + { + "name": "DH Shape" + }, + { + "name": "Damping" + }, + { + "name": "Decay" + }, + { + "name": "Diffusion" + }, + { + "name": "Dry/Wet" + }, + { + "name": "EQ" + }, + { + "name": "EQ High Freq" + }, + { + "name": "EQ High Gain" + }, + { + "name": "EQ High Slope" + }, + { + "name": "EQ High Type" + }, + { + "name": "EQ Low Freq" + }, + { + "name": "EQ Low Gain" + }, + { + "name": "EQ Low Slope" + }, + { + "name": "EQ Low Type" + }, + { + "name": "EQ On" + }, + { + "name": "EQ P1 Freq" + }, + { + "name": "EQ P1 Gain" + }, + { + "name": "EQ P1 Q" + }, + { + "name": "EQ P2 Freq" + }, + { + "name": "EQ P2 Gain" + }, + { + "name": "EQ P2 Q" + }, + { + "name": "EQ Pre Algo" + }, + { + "name": "Freeze" + }, + { + "name": "Freeze In" + }, + { + "name": "High Type Switch" + }, + { + "name": "IR" + }, + { + "name": "IR Category" + }, + { + "name": "Ir Attack Time" + }, + { + "name": "Ir Decay Time" + }, + { + "name": "Ir Size Factor" + }, + { + "name": "Low Type Switch" + }, + { + "name": "Modulation" + }, + { + "name": "Ms Sync Switch" + }, + { + "name": "P.Dly 16th" + }, + { + "name": "P.Dly Fb 16th" + }, + { + "name": "P.Dly Fb Time" + }, + { + "name": "P.Dly Sync" + }, + { + "name": "P.Dly Time" + }, + { + "name": "Pr High Mult" + }, + { + "name": "Pr Low Mult" + }, + { + "name": "Pr X Over" + }, + { + "name": "Pre Algo" + }, + { + "name": "Qz Distance" + }, + { + "name": "Qz Low Damp" + }, + { + "name": "Routing Eq Off" + }, + { + "name": "Routing Eq On PreAlgo Off" + }, + { + "name": "Routing Eq On PreAlgo On" + }, + { + "name": "Section" + }, + { + "name": "Send Gain" + }, + { + "name": "Sh Pitch Shift" + }, + { + "name": "Sh Shimmer" + }, + { + "name": "Shape" + }, + { + "name": "Size" + }, + { + "name": "Ti Phase" + }, + { + "name": "Ti Rate" + }, + { + "name": "Ti Tide" + }, + { + "name": "Ti Waveform" + }, + { + "name": "Vintage Copy" + }, + { + "name": "Width" + } + ] + }, + { + "class": "InstrumentGroupDevice", + "category": "rack", + "displayName": "Instrument Rack", + "parameters": [ + { + "name": "Macro 1" + }, + { + "name": "Macro 2" + }, + { + "name": "Macro 3" + }, + { + "name": "Macro 4" + }, + { + "name": "Macro 5" + }, + { + "name": "Macro 6" + }, + { + "name": "Macro 7" + }, + { + "name": "Macro 8" + } + ] + }, + { + "class": "InstrumentImpulse", + "category": "instrument", + "displayName": "Impulse", + "parameters": [ + { + "name": "1 Envelope Decay" + }, + { + "name": "1 Envelope Type" + }, + { + "name": "1 Filter <- Random" + }, + { + "name": "1 Filter <- Vel" + }, + { + "name": "1 Filter Freq" + }, + { + "name": "1 Filter Res" + }, + { + "name": "1 Filter Type" + }, + { + "name": "1 Pan" + }, + { + "name": "1 Pan <- Random" + }, + { + "name": "1 Pan <- Vel" + }, + { + "name": "1 Saturator Drive" + }, + { + "name": "1 Start" + }, + { + "name": "1 Stretch Factor" + }, + { + "name": "1 Stretch Mode" + }, + { + "name": "1 Transpose" + }, + { + "name": "1 Volume" + }, + { + "name": "1 Volume <- Vel" + }, + { + "name": "2 Envelope Decay" + }, + { + "name": "2 Envelope Type" + }, + { + "name": "2 Filter <- Random" + }, + { + "name": "2 Filter <- Vel" + }, + { + "name": "2 Filter Freq" + }, + { + "name": "2 Filter Res" + }, + { + "name": "2 Filter Type" + }, + { + "name": "2 Pan" + }, + { + "name": "2 Pan <- Random" + }, + { + "name": "2 Pan <- Vel" + }, + { + "name": "2 Saturator Drive" + }, + { + "name": "2 Start" + }, + { + "name": "2 Stretch Factor" + }, + { + "name": "2 Stretch Mode" + }, + { + "name": "2 Transpose" + }, + { + "name": "2 Volume" + }, + { + "name": "2 Volume <- Vel" + }, + { + "name": "3 Envelope Decay" + }, + { + "name": "3 Envelope Type" + }, + { + "name": "3 Filter <- Random" + }, + { + "name": "3 Filter <- Vel" + }, + { + "name": "3 Filter Freq" + }, + { + "name": "3 Filter Res" + }, + { + "name": "3 Filter Type" + }, + { + "name": "3 Pan" + }, + { + "name": "3 Pan <- Random" + }, + { + "name": "3 Pan <- Vel" + }, + { + "name": "3 Saturator Drive" + }, + { + "name": "3 Start" + }, + { + "name": "3 Stretch Factor" + }, + { + "name": "3 Stretch Mode" + }, + { + "name": "3 Transpose" + }, + { + "name": "3 Volume" + }, + { + "name": "3 Volume <- Vel" + }, + { + "name": "4 Envelope Decay" + }, + { + "name": "4 Envelope Type" + }, + { + "name": "4 Filter <- Random" + }, + { + "name": "4 Filter <- Vel" + }, + { + "name": "4 Filter Freq" + }, + { + "name": "4 Filter Res" + }, + { + "name": "4 Filter Type" + }, + { + "name": "4 Pan" + }, + { + "name": "4 Pan <- Random" + }, + { + "name": "4 Pan <- Vel" + }, + { + "name": "4 Saturator Drive" + }, + { + "name": "4 Start" + }, + { + "name": "4 Stretch Factor" + }, + { + "name": "4 Stretch Mode" + }, + { + "name": "4 Transpose" + }, + { + "name": "4 Volume" + }, + { + "name": "4 Volume <- Vel" + }, + { + "name": "5 Envelope Decay" + }, + { + "name": "5 Envelope Type" + }, + { + "name": "5 Filter <- Random" + }, + { + "name": "5 Filter <- Vel" + }, + { + "name": "5 Filter Freq" + }, + { + "name": "5 Filter Res" + }, + { + "name": "5 Filter Type" + }, + { + "name": "5 Pan" + }, + { + "name": "5 Pan <- Random" + }, + { + "name": "5 Pan <- Vel" + }, + { + "name": "5 Saturator Drive" + }, + { + "name": "5 Start" + }, + { + "name": "5 Stretch Factor" + }, + { + "name": "5 Stretch Mode" + }, + { + "name": "5 Transpose" + }, + { + "name": "5 Volume" + }, + { + "name": "5 Volume <- Vel" + }, + { + "name": "6 Envelope Decay" + }, + { + "name": "6 Envelope Type" + }, + { + "name": "6 Filter <- Random" + }, + { + "name": "6 Filter <- Vel" + }, + { + "name": "6 Filter Freq" + }, + { + "name": "6 Filter Res" + }, + { + "name": "6 Filter Type" + }, + { + "name": "6 Pan" + }, + { + "name": "6 Pan <- Random" + }, + { + "name": "6 Pan <- Vel" + }, + { + "name": "6 Saturator Drive" + }, + { + "name": "6 Start" + }, + { + "name": "6 Stretch Factor" + }, + { + "name": "6 Stretch Mode" + }, + { + "name": "6 Transpose" + }, + { + "name": "6 Volume" + }, + { + "name": "6 Volume <- Vel" + }, + { + "name": "7 Envelope Decay" + }, + { + "name": "7 Envelope Type" + }, + { + "name": "7 Filter <- Random" + }, + { + "name": "7 Filter <- Vel" + }, + { + "name": "7 Filter Freq" + }, + { + "name": "7 Filter Res" + }, + { + "name": "7 Filter Type" + }, + { + "name": "7 Pan" + }, + { + "name": "7 Pan <- Random" + }, + { + "name": "7 Pan <- Vel" + }, + { + "name": "7 Saturator Drive" + }, + { + "name": "7 Start" + }, + { + "name": "7 Stretch Factor" + }, + { + "name": "7 Stretch Mode" + }, + { + "name": "7 Transpose" + }, + { + "name": "7 Volume" + }, + { + "name": "7 Volume <- Vel" + }, + { + "name": "8 Envelope Decay" + }, + { + "name": "8 Envelope Type" + }, + { + "name": "8 Filter <- Random" + }, + { + "name": "8 Filter <- Vel" + }, + { + "name": "8 Filter Freq" + }, + { + "name": "8 Filter Res" + }, + { + "name": "8 Filter Type" + }, + { + "name": "8 Pan" + }, + { + "name": "8 Pan <- Random" + }, + { + "name": "8 Pan <- Vel" + }, + { + "name": "8 Saturator Drive" + }, + { + "name": "8 Start" + }, + { + "name": "8 Stretch Factor" + }, + { + "name": "8 Stretch Mode" + }, + { + "name": "8 Transpose" + }, + { + "name": "8 Volume" + }, + { + "name": "8 Volume <- Vel" + }, + { + "name": "Global Time" + }, + { + "name": "Global Transpose" + }, + { + "name": "Global Volume" + } + ] + }, + { + "class": "InstrumentMeld", + "category": "instrument", + "displayName": "Meld", + "parameters": [ + { + "name": "Envelope View" + }, + { + "name": "Filter A" + }, + { + "name": "Filter B" + }, + { + "name": "Glide A" + }, + { + "name": "Glide B" + }, + { + "name": "LFO 1 A Retrigger" + }, + { + "name": "LFO 1 A Sync" + }, + { + "name": "LFO 1 B Retrigger" + }, + { + "name": "LFO 1 B Sync" + }, + { + "name": "LFO 2 A Sync" + }, + { + "name": "LFO 2 B Sync" + }, + { + "name": "LFO1 Effect" + }, + { + "name": "Limiter" + }, + { + "name": "Link Envelopes" + }, + { + "name": "MeldVoice_Drive" + }, + { + "name": "MeldVoice_EngineA_AmpEnvelope_LoopMode" + }, + { + "name": "MeldVoice_EngineA_AmpEnvelope_Slopes_Attack" + }, + { + "name": "MeldVoice_EngineA_AmpEnvelope_Slopes_Decay" + }, + { + "name": "MeldVoice_EngineA_AmpEnvelope_Slopes_Release" + }, + { + "name": "MeldVoice_EngineA_AmpEnvelope_Sustain" + }, + { + "name": "MeldVoice_EngineA_AmpEnvelope_Times_Attack" + }, + { + "name": "MeldVoice_EngineA_AmpEnvelope_Times_Decay" + }, + { + "name": "MeldVoice_EngineA_AmpEnvelope_Times_Release" + }, + { + "name": "MeldVoice_EngineA_FilterEnvelope_LoopMode" + }, + { + "name": "MeldVoice_EngineA_FilterEnvelope_Slopes_Release" + }, + { + "name": "MeldVoice_EngineA_FilterEnvelope_Times_Attack" + }, + { + "name": "MeldVoice_EngineA_FilterEnvelope_Times_Decay" + }, + { + "name": "MeldVoice_EngineA_FilterEnvelope_Times_Release" + }, + { + "name": "MeldVoice_EngineA_FilterEnvelope_Values_Sustain" + }, + { + "name": "MeldVoice_EngineA_Filter_FilterType" + }, + { + "name": "MeldVoice_EngineA_Filter_Frequency" + }, + { + "name": "MeldVoice_EngineA_Filter_Macro1" + }, + { + "name": "MeldVoice_EngineA_Filter_Macro2" + }, + { + "name": "MeldVoice_EngineA_GlideTime" + }, + { + "name": "MeldVoice_EngineA_Lfo1_GeneratorMacro1" + }, + { + "name": "MeldVoice_EngineA_Lfo1_GeneratorMacro2" + }, + { + "name": "MeldVoice_EngineA_Lfo1_GeneratorType" + }, + { + "name": "MeldVoice_EngineA_Lfo1_PhaseOffset" + }, + { + "name": "MeldVoice_EngineA_Lfo1_Rate" + }, + { + "name": "MeldVoice_EngineA_Lfo1_Sync" + }, + { + "name": "MeldVoice_EngineA_Lfo1_SyncedRate" + }, + { + "name": "MeldVoice_EngineA_Lfo1_Transformer1Macro" + }, + { + "name": "MeldVoice_EngineA_Lfo1_Transformer1Type" + }, + { + "name": "MeldVoice_EngineA_Lfo1_Transformer2Macro" + }, + { + "name": "MeldVoice_EngineA_Lfo1_Transformer2Type" + }, + { + "name": "MeldVoice_EngineA_Lfo2_Rate" + }, + { + "name": "MeldVoice_EngineA_Lfo2_Sync" + }, + { + "name": "MeldVoice_EngineA_Lfo2_SyncedRate" + }, + { + "name": "MeldVoice_EngineA_Lfo2_Waveform" + }, + { + "name": "MeldVoice_EngineA_Oscillator_Macro1" + }, + { + "name": "MeldVoice_EngineA_Oscillator_Macro2" + }, + { + "name": "MeldVoice_EngineA_Oscillator_OscillatorType" + }, + { + "name": "MeldVoice_EngineA_Oscillator_Pitch_Detune" + }, + { + "name": "MeldVoice_EngineA_Oscillator_Pitch_Transpose" + }, + { + "name": "MeldVoice_EngineA_Pan" + }, + { + "name": "MeldVoice_EngineA_ToneFilter" + }, + { + "name": "MeldVoice_EngineA_Volume" + }, + { + "name": "MeldVoice_EngineBDelay" + }, + { + "name": "MeldVoice_EngineB_AmpEnvelope_LoopMode" + }, + { + "name": "MeldVoice_EngineB_AmpEnvelope_Slopes_Attack" + }, + { + "name": "MeldVoice_EngineB_AmpEnvelope_Slopes_Decay" + }, + { + "name": "MeldVoice_EngineB_AmpEnvelope_Slopes_Release" + }, + { + "name": "MeldVoice_EngineB_AmpEnvelope_Sustain" + }, + { + "name": "MeldVoice_EngineB_AmpEnvelope_Times_Attack" + }, + { + "name": "MeldVoice_EngineB_AmpEnvelope_Times_Decay" + }, + { + "name": "MeldVoice_EngineB_AmpEnvelope_Times_Release" + }, + { + "name": "MeldVoice_EngineB_FilterEnvelope_LoopMode" + }, + { + "name": "MeldVoice_EngineB_FilterEnvelope_Slopes_Release" + }, + { + "name": "MeldVoice_EngineB_FilterEnvelope_Times_Release" + }, + { + "name": "MeldVoice_EngineB_FilterEnvelope_Values_Sustain" + }, + { + "name": "MeldVoice_EngineB_Filter_FilterType" + }, + { + "name": "MeldVoice_EngineB_Filter_Frequency" + }, + { + "name": "MeldVoice_EngineB_Filter_Macro1" + }, + { + "name": "MeldVoice_EngineB_Filter_Macro2" + }, + { + "name": "MeldVoice_EngineB_GlideTime" + }, + { + "name": "MeldVoice_EngineB_Lfo1_GeneratorMacro1" + }, + { + "name": "MeldVoice_EngineB_Lfo1_GeneratorMacro2" + }, + { + "name": "MeldVoice_EngineB_Lfo1_GeneratorType" + }, + { + "name": "MeldVoice_EngineB_Lfo1_PhaseOffset" + }, + { + "name": "MeldVoice_EngineB_Lfo1_Rate" + }, + { + "name": "MeldVoice_EngineB_Lfo1_Sync" + }, + { + "name": "MeldVoice_EngineB_Lfo1_SyncedRate" + }, + { + "name": "MeldVoice_EngineB_Lfo1_Transformer1Macro" + }, + { + "name": "MeldVoice_EngineB_Lfo1_Transformer1Type" + }, + { + "name": "MeldVoice_EngineB_Lfo1_Transformer2Macro" + }, + { + "name": "MeldVoice_EngineB_Lfo1_Transformer2Type" + }, + { + "name": "MeldVoice_EngineB_Lfo2_Rate" + }, + { + "name": "MeldVoice_EngineB_Lfo2_Sync" + }, + { + "name": "MeldVoice_EngineB_Lfo2_SyncedRate" + }, + { + "name": "MeldVoice_EngineB_Lfo2_Waveform" + }, + { + "name": "MeldVoice_EngineB_Oscillator_Macro1" + }, + { + "name": "MeldVoice_EngineB_Oscillator_Macro2" + }, + { + "name": "MeldVoice_EngineB_Oscillator_OscillatorType" + }, + { + "name": "MeldVoice_EngineB_Oscillator_Pitch_Detune" + }, + { + "name": "MeldVoice_EngineB_Oscillator_Pitch_Transpose" + }, + { + "name": "MeldVoice_EngineB_Pan" + }, + { + "name": "MeldVoice_EngineB_ToneFilter" + }, + { + "name": "MeldVoice_EngineB_Volume" + }, + { + "name": "MeldVoice_VoiceSpreadAmount" + }, + { + "name": "Mono Poly" + }, + { + "name": "Osc A" + }, + { + "name": "Osc B" + }, + { + "name": "Poly Voices" + }, + { + "name": "Selected Engine" + }, + { + "name": "Selected Env" + }, + { + "name": "Stack Voices" + }, + { + "name": "Volume" + } + ] + }, + { + "class": "InstrumentVector", + "category": "instrument", + "displayName": "Wavetable", + "parameters": [ + { + "name": "Add to Matrix" + }, + { + "name": "Amp A Slope" + }, + { + "name": "Amp Attack" + }, + { + "name": "Amp D Slope" + }, + { + "name": "Amp Decay" + }, + { + "name": "Amp Env Mod Amount" + }, + { + "name": "Amp Env View" + }, + { + "name": "Amp Loop Mode" + }, + { + "name": "Amp R Slope" + }, + { + "name": "Amp Release" + }, + { + "name": "Amp Sustain" + }, + { + "name": "Back" + }, + { + "name": "Current Mod Target" + }, + { + "name": "Env 2 A Slope" + }, + { + "name": "Env 2 Attack" + }, + { + "name": "Env 2 D Slope" + }, + { + "name": "Env 2 Decay" + }, + { + "name": "Env 2 Final" + }, + { + "name": "Env 2 Initial" + }, + { + "name": "Env 2 Loop Mode" + }, + { + "name": "Env 2 Mod Amount" + }, + { + "name": "Env 2 Peak" + }, + { + "name": "Env 2 R Slope" + }, + { + "name": "Env 2 Release" + }, + { + "name": "Env 2 Sustain" + }, + { + "name": "Env 3 A Slope" + }, + { + "name": "Env 3 Attack" + }, + { + "name": "Env 3 D Slope" + }, + { + "name": "Env 3 Decay" + }, + { + "name": "Env 3 Final" + }, + { + "name": "Env 3 Initial" + }, + { + "name": "Env 3 Loop Mode" + }, + { + "name": "Env 3 Mod Amount" + }, + { + "name": "Env 3 Peak" + }, + { + "name": "Env 3 R Slope" + }, + { + "name": "Env 3 Release" + }, + { + "name": "Env 3 Sustain" + }, + { + "name": "Envelopes" + }, + { + "name": "Filter" + }, + { + "name": "Filter 1 BP/NO/Morph" + }, + { + "name": "Filter 1 Drive" + }, + { + "name": "Filter 1 Freq" + }, + { + "name": "Filter 1 LP/HP" + }, + { + "name": "Filter 1 Morph" + }, + { + "name": "Filter 1 On" + }, + { + "name": "Filter 1 Res" + }, + { + "name": "Filter 1 Slope" + }, + { + "name": "Filter 1 Type" + }, + { + "name": "Filter 2 BP/NO/Morph" + }, + { + "name": "Filter 2 Drive" + }, + { + "name": "Filter 2 Freq" + }, + { + "name": "Filter 2 LP/HP" + }, + { + "name": "Filter 2 Morph" + }, + { + "name": "Filter 2 On" + }, + { + "name": "Filter 2 Res" + }, + { + "name": "Filter 2 Slope" + }, + { + "name": "Filter 2 Type" + }, + { + "name": "Filter Routing" + }, + { + "name": "Filter Switch" + }, + { + "name": "Glide" + }, + { + "name": "Global Mod Amount" + }, + { + "name": "Go to Amp Env" + }, + { + "name": "Go to Env 2" + }, + { + "name": "Go to Env 3" + }, + { + "name": "Go to LFO 1" + }, + { + "name": "Go to LFO 2" + }, + { + "name": "Internal Filter" + }, + { + "name": "LFO" + }, + { + "name": "LFO 1 Amount" + }, + { + "name": "LFO 1 Attack Time" + }, + { + "name": "LFO 1 Phase Offset" + }, + { + "name": "LFO 1 Rate" + }, + { + "name": "LFO 1 Retrigger" + }, + { + "name": "LFO 1 S. Rate" + }, + { + "name": "LFO 1 Shape" + }, + { + "name": "LFO 1 Shaping" + }, + { + "name": "LFO 1 Sync" + }, + { + "name": "LFO 2 Amount" + }, + { + "name": "LFO 2 Attack Time" + }, + { + "name": "LFO 2 Phase Offset" + }, + { + "name": "LFO 2 Rate" + }, + { + "name": "LFO 2 Retrigger" + }, + { + "name": "LFO 2 S. Rate" + }, + { + "name": "LFO 2 Shape" + }, + { + "name": "LFO 2 Shaping" + }, + { + "name": "LFO 2 Sync" + }, + { + "name": "Lfo 1 Mod Amount" + }, + { + "name": "Lfo 2 Mod Amount" + }, + { + "name": "MIDI Aftertouch Mod Amount" + }, + { + "name": "MIDI Mod Wheel Mod Amount" + }, + { + "name": "MIDI Note Mod Amount" + }, + { + "name": "MIDI Pitch Bend Mod Amount" + }, + { + "name": "MIDI Random On Note On" + }, + { + "name": "MIDI Velocity Mod Amount" + }, + { + "name": "Mod Env View" + }, + { + "name": "Modulation Target Names" + }, + { + "name": "Mono On" + }, + { + "name": "Osc" + }, + { + "name": "Osc 1 Category" + }, + { + "name": "Osc 1 Effect 1" + }, + { + "name": "Osc 1 Effect 2" + }, + { + "name": "Osc 1 Effect Type" + }, + { + "name": "Osc 1 Gain" + }, + { + "name": "Osc 1 Pitch" + }, + { + "name": "Osc 1 Pos" + }, + { + "name": "Osc 1 Table" + }, + { + "name": "Osc 2 Category" + }, + { + "name": "Osc 2 Effect 1" + }, + { + "name": "Osc 2 Effect 2" + }, + { + "name": "Osc 2 Effect Type" + }, + { + "name": "Osc 2 Gain" + }, + { + "name": "Osc 2 Pitch" + }, + { + "name": "Osc 2 Pos" + }, + { + "name": "Osc 2 Table" + }, + { + "name": "Oscillator" + }, + { + "name": "Poly Voices" + }, + { + "name": "Sub" + }, + { + "name": "Sub Gain" + }, + { + "name": "Sub Tone" + }, + { + "name": "Sub Transpose" + }, + { + "name": "Time" + }, + { + "name": "Transpose" + }, + { + "name": "Unison Amount" + }, + { + "name": "Unison Mode" + }, + { + "name": "Unison Voices" + }, + { + "name": "Volume" + } + ] + }, + { + "class": "Limiter", + "category": "audio_effect", + "displayName": "Limiter", + "parameters": [ + { + "name": "Auto" + }, + { + "name": "Ceiling" + }, + { + "name": "Gain" + }, + { + "name": "Link Channels" + }, + { + "name": "Lookahead" + }, + { + "name": "Release time" + } + ] + }, + { + "class": "Looper", + "category": "audio_effect", + "displayName": "Looper", + "parameters": [ + { + "name": "Feedback" + }, + { + "name": "Monitor" + }, + { + "name": "Quantization" + }, + { + "name": "Reverse" + }, + { + "name": "Song Control" + }, + { + "name": "Speed" + }, + { + "name": "State" + }, + { + "name": "Tempo Control" + } + ] + }, + { + "class": "LoungeLizard", + "category": "instrument", + "displayName": "Electric", + "parameters": [ + { + "name": "Damp Amount" + }, + { + "name": "Damp Balance" + }, + { + "name": "Damp Tone" + }, + { + "name": "Detune" + }, + { + "name": "F Release" + }, + { + "name": "F Tine < Key" + }, + { + "name": "F Tine Color" + }, + { + "name": "F Tine Decay" + }, + { + "name": "F Tine Vol" + }, + { + "name": "F Tone Decay" + }, + { + "name": "F Tone Vol" + }, + { + "name": "KB Stretch" + }, + { + "name": "M Force" + }, + { + "name": "M Force < Key" + }, + { + "name": "M Force < Vel" + }, + { + "name": "M Stiff < Key" + }, + { + "name": "M Stiff < Vel" + }, + { + "name": "M Stiffness" + }, + { + "name": "Noise < Key" + }, + { + "name": "Noise Amount" + }, + { + "name": "Noise Decay" + }, + { + "name": "Noise Pitch" + }, + { + "name": "Note PB Range" + }, + { + "name": "P Amp < Key" + }, + { + "name": "P Amp In" + }, + { + "name": "P Amp Out" + }, + { + "name": "P Distance" + }, + { + "name": "P Symmetry" + }, + { + "name": "PB Range" + }, + { + "name": "Pickup Model" + }, + { + "name": "Semitone" + }, + { + "name": "Voices" + }, + { + "name": "Volume" + } + ] + }, + { + "class": "MidiArpeggiator", + "category": "midi_effect", + "displayName": "Arpeggiator", + "parameters": [ + { + "name": "Free Rate" + }, + { + "name": "Gate" + }, + { + "name": "Groove" + }, + { + "name": "Hold On" + }, + { + "name": "Offset" + }, + { + "name": "Repeats" + }, + { + "name": "Ret. Interval" + }, + { + "name": "Retrigger Mode" + }, + { + "name": "Style" + }, + { + "name": "Sync On" + }, + { + "name": "Synced Rate" + }, + { + "name": "Tranpose Key" + }, + { + "name": "Tranpose Mode" + }, + { + "name": "Transp. Dist." + }, + { + "name": "Transp. Steps" + }, + { + "name": "Vel. Retrigger" + }, + { + "name": "Velocity Decay" + }, + { + "name": "Velocity On" + }, + { + "name": "Velocity Target" + } + ] + }, + { + "class": "MidiCcControl", + "category": "midi_effect", + "displayName": "MIDI CC Control", + "parameters": [ + { + "name": "Button" + }, + { + "name": "Custom A" + }, + { + "name": "Custom B" + }, + { + "name": "Custom Button Target" + }, + { + "name": "Custom C" + }, + { + "name": "Custom D" + }, + { + "name": "Custom E" + }, + { + "name": "Custom F" + }, + { + "name": "Custom G" + }, + { + "name": "Custom H" + }, + { + "name": "Custom I" + }, + { + "name": "Custom J" + }, + { + "name": "Custom K" + }, + { + "name": "Custom L" + }, + { + "name": "Custom M" + }, + { + "name": "Custom Target 1" + }, + { + "name": "Custom Target 10" + }, + { + "name": "Custom Target 11" + }, + { + "name": "Custom Target 12" + }, + { + "name": "Custom Target 2" + }, + { + "name": "Custom Target 3" + }, + { + "name": "Custom Target 4" + }, + { + "name": "Custom Target 5" + }, + { + "name": "Custom Target 6" + }, + { + "name": "Custom Target 7" + }, + { + "name": "Custom Target 8" + }, + { + "name": "Custom Target 9" + }, + { + "name": "Mod Wheel" + }, + { + "name": "Pitch Bend" + }, + { + "name": "Pressure" + }, + { + "name": "Resend" + } + ] + }, + { + "class": "MidiChord", + "category": "midi_effect", + "displayName": "Chord", + "parameters": [ + { + "name": "Shift1" + }, + { + "name": "Shift2" + }, + { + "name": "Shift3" + }, + { + "name": "Shift4" + }, + { + "name": "Shift5" + }, + { + "name": "Shift6" + }, + { + "name": "Velocity1" + }, + { + "name": "Velocity2" + }, + { + "name": "Velocity3" + }, + { + "name": "Velocity4" + }, + { + "name": "Velocity5" + }, + { + "name": "Velocity6" + } + ] + }, + { + "class": "MidiEffectGroupDevice", + "category": "rack", + "displayName": "MIDI Effect Rack", + "parameters": [ + { + "name": "Macro 1" + }, + { + "name": "Macro 2" + }, + { + "name": "Macro 3" + }, + { + "name": "Macro 4" + }, + { + "name": "Macro 5" + }, + { + "name": "Macro 6" + }, + { + "name": "Macro 7" + }, + { + "name": "Macro 8" + } + ] + }, + { + "class": "MidiNoteLength", + "category": "midi_effect", + "displayName": "Note Length", + "parameters": [ + { + "name": "Decay Key Scale" + }, + { + "name": "Decay Time" + }, + { + "name": "Gate" + }, + { + "name": "Latch" + }, + { + "name": "Release Velocity" + }, + { + "name": "Sync On" + }, + { + "name": "Synced Length" + }, + { + "name": "Time Length" + }, + { + "name": "Trigger Source" + } + ] + }, + { + "class": "MidiPitcher", + "category": "midi_effect", + "displayName": "Pitch", + "parameters": [ + { + "name": "Lowest" + }, + { + "name": "Pitch" + }, + { + "name": "Range" + } + ] + }, + { + "class": "MidiRandom", + "category": "midi_effect", + "displayName": "Random", + "parameters": [ + { + "name": "Chance" + }, + { + "name": "Choices" + }, + { + "name": "Interval" + }, + { + "name": "Mode" + }, + { + "name": "Sign" + } + ] + }, + { + "class": "MidiScale", + "category": "midi_effect", + "displayName": "Scale", + "parameters": [ + { + "name": "Base" + }, + { + "name": "Fold" + }, + { + "name": "Lowest" + }, + { + "name": "Range" + }, + { + "name": "Transpose" + } + ] + }, + { + "class": "MidiVelocity", + "category": "midi_effect", + "displayName": "Velocity", + "parameters": [ + { + "name": "Compand" + }, + { + "name": "Drive" + }, + { + "name": "Lowest" + }, + { + "name": "Mode" + }, + { + "name": "Out Hi" + }, + { + "name": "Out Low" + }, + { + "name": "Random" + }, + { + "name": "Range" + } + ] + }, + { + "class": "MultiSampler", + "category": "instrument", + "displayName": "Sampler", + "parameters": [ + { + "name": "Ae A Slope" + }, + { + "name": "Ae Attack" + }, + { + "name": "Ae D Slope" + }, + { + "name": "Ae Decay" + }, + { + "name": "Ae End" + }, + { + "name": "Ae Init" + }, + { + "name": "Ae Loop" + }, + { + "name": "Ae Mode" + }, + { + "name": "Ae On" + }, + { + "name": "Ae Peak" + }, + { + "name": "Ae R Slope" + }, + { + "name": "Ae Release" + }, + { + "name": "Ae Retrig" + }, + { + "name": "Ae Sustain" + }, + { + "name": "Detune" + }, + { + "name": "F On" + }, + { + "name": "Fe < Env" + }, + { + "name": "Fe Attack" + }, + { + "name": "Fe Decay" + }, + { + "name": "Fe End" + }, + { + "name": "Fe Init" + }, + { + "name": "Fe Loop" + }, + { + "name": "Fe Mode" + }, + { + "name": "Fe On" + }, + { + "name": "Fe Peak" + }, + { + "name": "Fe R < Vel" + }, + { + "name": "Fe Release" + }, + { + "name": "Fe Retrig" + }, + { + "name": "Fe Sustain" + }, + { + "name": "Filt < Key" + }, + { + "name": "Filt < LFO" + }, + { + "name": "Filt < Vel" + }, + { + "name": "Filter Circuit - BP/NO/Morph" + }, + { + "name": "Filter Circuit - LP/HP" + }, + { + "name": "Filter Drive" + }, + { + "name": "Filter Freq" + }, + { + "name": "Filter Morph" + }, + { + "name": "Filter Res" + }, + { + "name": "Filter Res (Legacy)" + }, + { + "name": "Filter Slope" + }, + { + "name": "Filter Type" + }, + { + "name": "Filter Type (Legacy)" + }, + { + "name": "Glide Mode" + }, + { + "name": "Glide Time" + }, + { + "name": "Key Zone Shift" + }, + { + "name": "L 1 Attack" + }, + { + "name": "L 1 Offset" + }, + { + "name": "L 1 On" + }, + { + "name": "L 1 Rate" + }, + { + "name": "L 1 Retrig" + }, + { + "name": "L 1 Sync" + }, + { + "name": "L 1 Sync Rate" + }, + { + "name": "L 1 Wave" + }, + { + "name": "L 2 Attack" + }, + { + "name": "L 2 Offset" + }, + { + "name": "L 2 On" + }, + { + "name": "L 2 Phase" + }, + { + "name": "L 2 Rate" + }, + { + "name": "L 2 Retrig" + }, + { + "name": "L 2 Spin" + }, + { + "name": "L 2 St Mode" + }, + { + "name": "L 2 Sync" + }, + { + "name": "L 2 Sync Rate" + }, + { + "name": "L 2 Wave" + }, + { + "name": "L 3 Attack" + }, + { + "name": "L 3 Offset" + }, + { + "name": "L 3 On" + }, + { + "name": "L 3 Phase" + }, + { + "name": "L 3 Rate" + }, + { + "name": "L 3 Retrig" + }, + { + "name": "L 3 Spin" + }, + { + "name": "L 3 St Mode" + }, + { + "name": "L 3 Sync" + }, + { + "name": "L 3 Sync Rate" + }, + { + "name": "L 3 Wave" + }, + { + "name": "O Coarse" + }, + { + "name": "O Fine" + }, + { + "name": "O Fix Freq" + }, + { + "name": "O Fix Freq Mul" + }, + { + "name": "O Fix On" + }, + { + "name": "O Mode" + }, + { + "name": "O Type" + }, + { + "name": "O Volume" + }, + { + "name": "Oe Attack" + }, + { + "name": "Oe Decay" + }, + { + "name": "Oe End" + }, + { + "name": "Oe Init" + }, + { + "name": "Oe Loop" + }, + { + "name": "Oe Mode" + }, + { + "name": "Oe Peak" + }, + { + "name": "Oe Release" + }, + { + "name": "Oe Retrig" + }, + { + "name": "Oe Sustain" + }, + { + "name": "Osc On" + }, + { + "name": "Pan" + }, + { + "name": "Pan < LFO" + }, + { + "name": "Pan < Rnd" + }, + { + "name": "Pe < Env" + }, + { + "name": "Pe Attack" + }, + { + "name": "Pe Decay" + }, + { + "name": "Pe End" + }, + { + "name": "Pe Init" + }, + { + "name": "Pe Loop" + }, + { + "name": "Pe Mode" + }, + { + "name": "Pe On" + }, + { + "name": "Pe Peak" + }, + { + "name": "Pe R < Vel" + }, + { + "name": "Pe Release" + }, + { + "name": "Pe Retrig" + }, + { + "name": "Pe Sustain" + }, + { + "name": "Pitch < LFO" + }, + { + "name": "Shaper Amt" + }, + { + "name": "Shaper On" + }, + { + "name": "Shaper Type" + }, + { + "name": "Spread" + }, + { + "name": "Time" + }, + { + "name": "Time < Key" + }, + { + "name": "Transpose" + }, + { + "name": "Ve Attack" + }, + { + "name": "Ve Decay" + }, + { + "name": "Ve Init" + }, + { + "name": "Ve Loop" + }, + { + "name": "Ve Mode" + }, + { + "name": "Ve Peak" + }, + { + "name": "Ve R < Vel" + }, + { + "name": "Ve Release" + }, + { + "name": "Ve Retrig" + }, + { + "name": "Ve Sustain" + }, + { + "name": "Vol < LFO" + }, + { + "name": "Vol < Vel" + }, + { + "name": "Volume" + } + ] + }, + { + "class": "MultibandDynamics", + "category": "audio_effect", + "displayName": "Multiband Dynamics", + "parameters": [ + { + "name": "Above Ratio (High)" + }, + { + "name": "Above Ratio (Low)" + }, + { + "name": "Above Ratio (Mid)" + }, + { + "name": "Above Threshold (High)" + }, + { + "name": "Above Threshold (Low)" + }, + { + "name": "Above Threshold (Mid)" + }, + { + "name": "Amount" + }, + { + "name": "Attack Time (High)" + }, + { + "name": "Attack Time (Low)" + }, + { + "name": "Attack Time (Mid)" + }, + { + "name": "Band Activator (High)" + }, + { + "name": "Band Activator (Low)" + }, + { + "name": "Band Activator (Mid)" + }, + { + "name": "Below Ratio (High)" + }, + { + "name": "Below Ratio (Low)" + }, + { + "name": "Below Ratio (Mid)" + }, + { + "name": "Below Threshold (High)" + }, + { + "name": "Below Threshold (Low)" + }, + { + "name": "Below Threshold (Mid)" + }, + { + "name": "Input Gain (High)" + }, + { + "name": "Input Gain (Low)" + }, + { + "name": "Input Gain (Mid)" + }, + { + "name": "Low-Mid Crossover" + }, + { + "name": "Master Output" + }, + { + "name": "Mid-High Crossover" + }, + { + "name": "Output Gain (High)" + }, + { + "name": "Output Gain (Low)" + }, + { + "name": "Output Gain (Mid)" + }, + { + "name": "Peak/RMS Mode" + }, + { + "name": "Release Time (High)" + }, + { + "name": "Release Time (Low)" + }, + { + "name": "Release Time (Mid)" + }, + { + "name": "S/C Gain" + }, + { + "name": "S/C Mix" + }, + { + "name": "S/C On" + }, + { + "name": "Soft Knee On/Off" + }, + { + "name": "Time Scaling" + } + ] + }, + { + "class": "Operator", + "category": "instrument", + "displayName": "Operator", + "parameters": [ + { + "name": "A Coarse" + }, + { + "name": "A Fine" + }, + { + "name": "A Fix Freq" + }, + { + "name": "A Fix Freq Mul" + }, + { + "name": "A Fix On " + }, + { + "name": "A Freq] = IndexedDict(...)` subscript assignments. Per-device parameter sets are the union across all listed files." + }, + "license_note": { + "type": "string", + "description": "Attribution and licensing note for the upstream material. No upstream source is redistributed by ASA; only extracted device/parameter name metadata." + }, + "generated_at": { + "type": "string", + "format": "date-time", + "description": "UTC ISO-8601 timestamp when the catalogue was generated." + }, + "extraction_notes": { + "type": "string", + "description": "Notes about what the static extraction can and cannot recover (e.g. min/max/unit/default live on runtime Live.DeviceParameter objects and are not present in static source)." + }, + "devices": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/device" + } + } + }, + "$defs": { + "device": { + "type": "object", + "additionalProperties": false, + "required": ["class", "category", "displayName", "parameters"], + "properties": { + "class": { + "type": "string", + "minLength": 1, + "description": "Live's internal device class name as exposed by the Live Object Model (e.g. `Saturator`, `Eq8`, `GlueCompressor`)." + }, + "category": { + "type": "string", + "enum": ["instrument", "audio_effect", "midi_effect", "rack"], + "description": "Coarse device family. Hand-mapped per `class`; defaults to `audio_effect` when the class name does not indicate otherwise." + }, + "displayName": { + "type": "string", + "minLength": 1, + "description": "User-visible Live 12 device name (e.g. `EQ Eight` for class `Eq8`). Hand-mapped; defaults to `class` when the UI label matches the class name." + }, + "parameters": { + "type": "array", + "minItems": 0, + "items": { + "$ref": "#/$defs/parameter" + } + } + } + }, + "parameter": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Canonical Live parameter name as exposed by the Live Object Model. Catalogue uses these exact strings for `has_parameter` lookups." + }, + "type": { + "type": "string", + "enum": ["float", "int", "bool", "enum"], + "description": "Parameter datatype. Optional — not present in static source; reserved for future enrichment from runtime introspection." + }, + "min": { + "type": "number", + "description": "Lower numeric bound. Optional — not present in static source." + }, + "max": { + "type": "number", + "description": "Upper numeric bound. Optional — not present in static source." + }, + "unit": { + "type": "string", + "description": "Parameter unit (`dB`, `Hz`, `ms`, ...). Optional — not present in static source." + }, + "default": { + "description": "Default parameter value. Optional — not present in static source.", + "type": ["string", "number", "boolean", "null"] + }, + "values": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + }, + "description": "For `enum` parameters: the list of allowed string values. Optional — not present in static source." + } + } + } + } +} diff --git a/scripts/build_live12_catalogue.py b/scripts/build_live12_catalogue.py new file mode 100755 index 00000000..8a6e5f90 --- /dev/null +++ b/scripts/build_live12_catalogue.py @@ -0,0 +1,598 @@ +#!/usr/bin/env python3 +"""Generate the Live 12 addressable-target catalogue from an upstream clone. + +The upstream is the decompiled `gluon/AbletonLive12_MIDIRemoteScripts` repository, +which mirrors Ableton's MIDI Remote Scripts directory. The single source file +`Push2/custom_bank_definitions.py` contains a `BANK_DEFINITIONS` dict that maps +Live's internal device class names (e.g. `Saturator`, `Eq8`, `GlueCompressor`) to +banked parameter lists. + +We parse that file via the stdlib `ast` module — never executing it — and walk +the BANK_DEFINITIONS dict to collect, per device class, every parameter name +referenced by any bank. The output is written to `data/live12_catalogue.json` +and validated against `data/live12_catalogue.schema.json` semantics. + +Static source does NOT carry parameter ranges, types, units, or defaults: those +live on runtime `Live.DeviceParameter` objects. The schema reserves space for +them but the generator emits names only. + +Usage +----- + scripts/build_live12_catalogue.py \\ + --source /path/to/gluon/AbletonLive12_MIDIRemoteScripts \\ + --output data/live12_catalogue.json + +If `--source` is omitted, the script reads $SONIC_ANALYZER_LIVE12_SOURCE. If +neither is set, it errors out — the catalogue must be deterministic, with no +implicit clone-at-request-time behavior. +""" + +from __future__ import annotations + +import argparse +import ast +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable + + +SOURCE_FILES_REL: tuple[str, ...] = ( + "Push2/custom_bank_definitions.py", + "Move/custom_bank_definitions.py", +) +PRIMARY_SOURCE_FILE_REL = SOURCE_FILES_REL[0] +SOURCE_URL = "https://github.com/gluon/AbletonLive12_MIDIRemoteScripts" +LICENSE_NOTE = ( + "Upstream is a decompiled redistribution of Ableton's MIDI Remote Scripts. " + "ASA does not ship any upstream source; this catalogue records only the " + "device-class and parameter-name metadata extracted via static AST parsing, " + "used as a Phase 2 output-validation gate." +) +EXTRACTION_NOTES = ( + "Generated from the upstream Live 12 MIDI Remote Scripts via stdlib ast. " + "Push2/custom_bank_definitions.py contributes the BANK_DEFINITIONS dict " + "literal; Move/custom_bank_definitions.py contributes additional " + "CUSTOM_BANK_DEFINITIONS[] = IndexedDict(...) subscript assignments " + "(including Live 12-new devices like AutoShift). Per-class parameter sets " + "are the union across all source files. Parameter names are taken from " + "BANK_PARAMETERS_KEY and OPTIONS_KEY tuples and from use(...)/else_use(...)/" + "if_parameter(...) call arguments. type/min/max/unit/default are intentionally " + "omitted because they are not present in static source; they live on runtime " + "Live.DeviceParameter objects." +) +SCHEMA_VERSION = "1" + +PARAM_NAME_METHODS: frozenset[str] = frozenset({ + "use", + "else_use", + "if_parameter", + "and_parameter", + "or_parameter", +}) + + +# Hand-mapped display names: Live's internal class -> the label Ableton's UI shows. +# Any class not in this map gets `displayName == class`. Mapping is the single +# bridge between curated UI-name recommendations (what Gemini cites) and +# canonical-class catalogue keys (what the upstream source uses). +DISPLAY_NAMES: dict[str, str] = { + "UltraAnalog": "Analog", + "ChannelEq": "Channel EQ", + "Compressor2": "Compressor", + "Chorus2": "Chorus-Ensemble", + "Drift": "Drift", + "DrumBuss": "Drum Buss", + "DrumCell": "Drum Sampler", + "Echo": "Echo", + "Eq8": "EQ Eight", + "FilterEQ3": "EQ Three", + "Erosion": "Erosion", + "FilterDelay": "Filter Delay", + "FrequencyShifter": "Frequency Shifter", + "Gate": "Gate", + "GlueCompressor": "Glue Compressor", + "GrainDelay": "Grain Delay", + "Hybrid": "Hybrid Reverb", + "InstrumentImpulse": "Impulse", + "InstrumentMeld": "Meld", + "InstrumentVector": "Wavetable", + "Limiter": "Limiter", + "Looper": "Looper", + "LoungeLizard": "Electric", + "MidiArpeggiator": "Arpeggiator", + "MidiCcControl": "MIDI CC Control", + "MidiChord": "Chord", + "MidiNoteLength": "Note Length", + "MidiPitcher": "Pitch", + "MidiRandom": "Random", + "MidiScale": "Scale", + "MidiVelocity": "Velocity", + "MultiSampler": "Sampler", + "MultibandDynamics": "Multiband Dynamics", + "Operator": "Operator", + "OriginalSimpler": "Simpler", + "Overdrive": "Overdrive", + "Pedal": "Pedal", + "Phaser": "Phaser", + "PhaserNew": "Phaser-Flanger", + "Redux2": "Redux", + "Resonator": "Resonators", + "Reverb": "Reverb", + "Roar": "Roar", + "Saturator": "Saturator", + "Shifter": "Shifter", + "Spectral": "Spectral Resonator", + "StereoGain": "Utility", + "StringStudio": "Tension", + "Transmute": "Spectral Time", + "Tube": "Dynamic Tube", + "Vinyl": "Vinyl Distortion", + "Vocoder": "Vocoder", + "Cabinet": "Cabinet", + "Amp": "Amp", + "AutoFilter": "Auto Filter", + "AutoPan": "Auto Pan-Tremolo", + "BeatRepeat": "Beat Repeat", + "Chorus": "Chorus", + "Collision": "Collision", + "Corpus": "Corpus", + "Delay": "Delay", + "Flanger": "Flanger", + "Redux": "Redux Legacy", + "AudioEffectGroupDevice": "Audio Effect Rack", + "MidiEffectGroupDevice": "MIDI Effect Rack", + "InstrumentGroupDevice": "Instrument Rack", + "DrumGroupDevice": "Drum Rack", + "ProxyAudioEffectDevice": "Audio Effect (Proxy)", + # Move-only additions (Live 12 device classes that only appear in + # Move/custom_bank_definitions.py CUSTOM_BANK_DEFINITIONS subscripts). + "AutoShift": "Auto Shift", + "AutoFilter2": "Auto Filter (Move)", + "AutoPan2": "Auto Pan-Tremolo (Move)", + "Erosion2": "Erosion (Move)", +} + + +# Coarse category classifier. Falls back to "audio_effect" for any class not +# explicitly listed — that matches Live's grouping for most leftover entries +# (most of `BANK_DEFINITIONS` is audio effects). +CATEGORIES: dict[str, str] = { + # Instruments + "UltraAnalog": "instrument", + "Collision": "instrument", + "DrumCell": "instrument", + "Drift": "instrument", + "Hybrid": "audio_effect", # Hybrid Reverb is an audio effect, not an instrument + "InstrumentImpulse": "instrument", + "InstrumentMeld": "instrument", + "InstrumentVector": "instrument", + "LoungeLizard": "instrument", + "MultiSampler": "instrument", + "Operator": "instrument", + "OriginalSimpler": "instrument", + "StringStudio": "instrument", + # MIDI effects + "MidiArpeggiator": "midi_effect", + "MidiCcControl": "midi_effect", + "MidiChord": "midi_effect", + "MidiNoteLength": "midi_effect", + "MidiPitcher": "midi_effect", + "MidiRandom": "midi_effect", + "MidiScale": "midi_effect", + "MidiVelocity": "midi_effect", + # Racks + "AudioEffectGroupDevice": "rack", + "MidiEffectGroupDevice": "rack", + "InstrumentGroupDevice": "rack", + "DrumGroupDevice": "rack", +} + + +# Inside a BANK_PARAMETERS_KEY / OPTIONS_KEY tuple, these section/separator +# strings appear and are NOT parameter names. They are bank-section headings, +# blank-encoder placeholders, etc. +KNOWN_NON_PARAMETER_STRINGS: frozenset[str] = frozenset({ + "", +}) + + +def _resolve_source_root(args: argparse.Namespace) -> Path: + if args.source: + return Path(args.source).expanduser().resolve() + env_source = os.environ.get("SONIC_ANALYZER_LIVE12_SOURCE") + if env_source: + return Path(env_source).expanduser().resolve() + raise SystemExit( + "ERROR: pass --source or set " + "SONIC_ANALYZER_LIVE12_SOURCE. Catalogue generation is deterministic; " + "the upstream clone is never fetched at request time." + ) + + +def _git_head_sha(repo_root: Path) -> str: + try: + result = subprocess.run( + ["git", "-C", str(repo_root), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + except (FileNotFoundError, subprocess.CalledProcessError) as exc: + raise SystemExit( + f"ERROR: could not read upstream HEAD SHA at {repo_root}: {exc}" + ) from exc + sha = result.stdout.strip() + if not sha: + raise SystemExit(f"ERROR: upstream HEAD SHA at {repo_root} is empty.") + return sha + + +def _read_source(source_root: Path, source_rel: str, required: bool) -> tuple[str | None, Path]: + source_path = source_root / source_rel + if not source_path.is_file(): + if required: + raise SystemExit( + f"ERROR: expected upstream file {source_rel} not found at {source_path}." + ) + return None, source_path + return source_path.read_text(encoding="utf-8"), source_path + + +def _extract_param_names_from_call(call: ast.Call) -> list[str]: + """Walk a chained call like + `use("X").if_parameter("P").has_value("V").else_use("Y").with_name("Z")` + and collect parameter names from `use/else_use/if_parameter/and_parameter/ + or_parameter` arg-zero only. `has_value` and `with_name` carry display or + value strings, never parameter names, and are skipped. + """ + names: list[str] = [] + current: ast.AST = call + while isinstance(current, ast.Call): + method = None + receiver: ast.AST | None = None + if isinstance(current.func, ast.Attribute): + method = current.func.attr + receiver = current.func.value + elif isinstance(current.func, ast.Name): + method = current.func.id + + if method in PARAM_NAME_METHODS and current.args: + arg0 = current.args[0] + if isinstance(arg0, ast.Constant) and isinstance(arg0.value, str): + stripped = arg0.value.strip() + if stripped and stripped not in KNOWN_NON_PARAMETER_STRINGS: + names.append(arg0.value) + + if receiver is None or not isinstance(receiver, ast.Call): + break + current = receiver + return names + + +def _extract_param_names_from_elt(elt: ast.AST) -> list[str]: + """Element inside a parameter tuple. Either a string constant or a chained + `use(...)` expression.""" + if isinstance(elt, ast.Constant) and isinstance(elt.value, str): + stripped = elt.value.strip() + if stripped and stripped not in KNOWN_NON_PARAMETER_STRINGS: + return [elt.value] + return [] + if isinstance(elt, ast.Call): + return _extract_param_names_from_call(elt) + return [] + + +def _collect_param_names_from_value(value: ast.AST) -> list[str]: + """The value bound to BANK_PARAMETERS_KEY or OPTIONS_KEY. Usually a tuple + of items; occasionally a bare string constant (e.g. `'Resonator': '"Select"'` + in the upstream — a bare string serving as a one-element bank).""" + if isinstance(value, ast.Constant) and isinstance(value.value, str): + return _extract_param_names_from_elt(value) + if isinstance(value, ast.Tuple): + names: list[str] = [] + for elt in value.elts: + names.extend(_extract_param_names_from_elt(elt)) + return names + if isinstance(value, ast.Call): + return _extract_param_names_from_call(value) + return [] + + +def _is_parameter_key_node(node: ast.AST) -> bool: + """Match either the Name `BANK_PARAMETERS_KEY`/`OPTIONS_KEY` or the + equivalent string constants (in case the upstream ever inlines them).""" + if isinstance(node, ast.Name): + return node.id in ("BANK_PARAMETERS_KEY", "OPTIONS_KEY") + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value in ("BANK_PARAMETERS_KEY", "OPTIONS_KEY") + return False + + +def _extract_params_from_indexeddict_call(node: ast.Call) -> list[str]: + """Walk `IndexedDict(((bank_name, bank_dict), ...))`. Each bank_dict has + BANK_PARAMETERS_KEY (and sometimes OPTIONS_KEY) entries whose values + enumerate parameter names referenced by that bank.""" + if not (isinstance(node.func, ast.Name) and node.func.id == "IndexedDict"): + return [] + if not node.args or not isinstance(node.args[0], ast.Tuple): + return [] + names: list[str] = [] + for entry in node.args[0].elts: + if not isinstance(entry, ast.Tuple) or len(entry.elts) < 2: + continue + bank_body = entry.elts[1] + if not isinstance(bank_body, ast.Dict): + continue + for key, value in zip(bank_body.keys, bank_body.values): + if _is_parameter_key_node(key): + names.extend(_collect_param_names_from_value(value)) + return names + + +def _find_top_level_assign(tree: ast.Module, name: str) -> ast.AST | None: + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == name: + return node.value + return None + + +def _collect_subscript_assignments( + tree: ast.Module, target_name: str +) -> dict[str, ast.AST]: + """Collect `['ClassName'] = ` subscript assignments. + + Returns {device_class: value_node}. Used to harvest entries from + `Move/custom_bank_definitions.py` (`CUSTOM_BANK_DEFINITIONS['AutoShift'] = IndexedDict(...)`) + on top of the `BANK_DEFINITIONS` dict literal from `Push2/...`. + """ + result: dict[str, ast.AST] = {} + for node in tree.body: + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if not isinstance(target, ast.Subscript): + continue + if not (isinstance(target.value, ast.Name) and target.value.id == target_name): + continue + slice_node = target.slice + if isinstance(slice_node, ast.Constant) and isinstance(slice_node.value, str): + result[slice_node.value] = node.value + break + return result + + +def _collect_device_parameters_from_tree( + tree: ast.Module, + is_primary: bool, +) -> dict[str, list[str]]: + """Walk a parsed bank-definitions module and return {class: param names}. + + `is_primary=True` parses a Push2-style `BANK_DEFINITIONS = {...}` dict + literal. `is_primary=False` parses a Move-style sequence of + `CUSTOM_BANK_DEFINITIONS[''] = IndexedDict(...)` subscript + assignments. Both flavors share the inner `IndexedDict(...)` shape. + """ + rack_banks_value = _find_top_level_assign(tree, "RACK_BANKS") + rack_banks_params: list[str] = [] + if isinstance(rack_banks_value, ast.Call): + rack_banks_params = _extract_params_from_indexeddict_call(rack_banks_value) + + result: dict[str, list[str]] = {} + + if is_primary: + bank_definitions_value = _find_top_level_assign(tree, "BANK_DEFINITIONS") + if not isinstance(bank_definitions_value, ast.Dict): + raise SystemExit( + "ERROR: expected `BANK_DEFINITIONS = {...}` top-level dict assignment in primary upstream." + ) + for key, value in zip(bank_definitions_value.keys, bank_definitions_value.values): + if not (isinstance(key, ast.Constant) and isinstance(key.value, str)): + continue + device_class = key.value + if isinstance(value, ast.Name) and value.id == "RACK_BANKS": + result[device_class] = list(rack_banks_params) + continue + if isinstance(value, ast.Call): + result[device_class] = _extract_params_from_indexeddict_call(value) + continue + + subscripts = _collect_subscript_assignments(tree, "CUSTOM_BANK_DEFINITIONS") + for device_class, value in subscripts.items(): + if isinstance(value, ast.Name) and value.id == "RACK_BANKS": + result.setdefault(device_class, list(rack_banks_params)) + continue + if isinstance(value, ast.Call): + new_names = _extract_params_from_indexeddict_call(value) + if device_class in result: + result[device_class] = list(set(result[device_class]) | set(new_names)) + else: + result[device_class] = new_names + + return result + + +def _merge_device_parameters( + *sources: dict[str, list[str]], +) -> dict[str, list[str]]: + """Per-device union across multiple source files. Per-device parameter + lists are deduped and sorted at the end so the catalogue output is + canonical regardless of file order.""" + merged: dict[str, set[str]] = {} + for source in sources: + for device_class, names in source.items(): + merged.setdefault(device_class, set()).update(names) + return {device_class: sorted(names) for device_class, names in merged.items()} + + +def _collect_device_parameters(tree: ast.Module) -> dict[str, list[str]]: + """Back-compat wrapper for callers that pass a single primary tree (used by + the in-memory `build_catalogue_from_text` entry point).""" + return _merge_device_parameters( + _collect_device_parameters_from_tree(tree, is_primary=True) + ) + + +def _build_catalogue( + *, + devices: dict[str, list[str]], + source_commit: str, + generated_at: str, + source_files: tuple[str, ...] = (PRIMARY_SOURCE_FILE_REL,), +) -> dict: + catalogue_devices: list[dict] = [] + for device_class in sorted(devices): + param_names = devices[device_class] + display_name = DISPLAY_NAMES.get(device_class, device_class) + category = CATEGORIES.get(device_class, "audio_effect") + catalogue_devices.append( + { + "class": device_class, + "category": category, + "displayName": display_name, + "parameters": [ + {"name": name} for name in sorted(set(param_names)) + ], + } + ) + return { + "schema_version": SCHEMA_VERSION, + "source_commit": source_commit, + "source_url": SOURCE_URL, + "source_files": list(source_files), + "license_note": LICENSE_NOTE, + "generated_at": generated_at, + "extraction_notes": EXTRACTION_NOTES, + "devices": catalogue_devices, + } + + +def _utc_iso8601_now() -> str: + return datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def build_catalogue_from_source( + *, + source_root: Path, + source_commit: str | None = None, + generated_at: str | None = None, +) -> dict: + """Library entry point used by tests and the CLI. Reads the upstream + bank-definitions files and returns the catalogue dict. + + Walks every file in `SOURCE_FILES_REL` against `source_root`. The first + entry is the primary file (BANK_DEFINITIONS dict literal); subsequent + entries are optional supplementary files containing + `CUSTOM_BANK_DEFINITIONS[] = IndexedDict(...)` subscript + assignments. The catalogue is the union of all per-device parameter sets. + """ + per_file_devices: list[dict[str, list[str]]] = [] + found_files: list[str] = [] + for index, source_rel in enumerate(SOURCE_FILES_REL): + text, path = _read_source(source_root, source_rel, required=(index == 0)) + if text is None: + continue + tree = ast.parse(text, filename=source_rel) + per_file_devices.append( + _collect_device_parameters_from_tree(tree, is_primary=(index == 0)) + ) + found_files.append(source_rel) + devices = _merge_device_parameters(*per_file_devices) + if source_commit is None: + source_commit = _git_head_sha(source_root) + if generated_at is None: + generated_at = _utc_iso8601_now() + return _build_catalogue( + devices=devices, + source_commit=source_commit, + generated_at=generated_at, + source_files=tuple(found_files) or (PRIMARY_SOURCE_FILE_REL,), + ) + + +def build_catalogue_from_text( + *, + bank_definitions_text: str, + source_commit: str, + generated_at: str, +) -> dict: + """Generate a catalogue from in-memory source text (used by the generator + unit test against the vendored fixture).""" + tree = ast.parse(bank_definitions_text, filename=PRIMARY_SOURCE_FILE_REL) + devices = _collect_device_parameters(tree) + return _build_catalogue( + devices=devices, + source_commit=source_commit, + generated_at=generated_at, + ) + + +def _write_catalogue(catalogue: dict, output_path: Path) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + serialized = json.dumps(catalogue, indent=2, ensure_ascii=False) + "\n" + output_path.write_text(serialized, encoding="utf-8") + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--source", + default=None, + help=( + "Path to the local clone of gluon/AbletonLive12_MIDIRemoteScripts. " + "Falls back to $SONIC_ANALYZER_LIVE12_SOURCE." + ), + ) + parser.add_argument( + "--output", + default="data/live12_catalogue.json", + help=( + "Where to write the generated catalogue. Path is relative to the repo " + "root unless absolute. Default: data/live12_catalogue.json." + ), + ) + parser.add_argument( + "--source-commit", + default=None, + help=( + "Override the recorded source commit SHA. By default the script reads " + "git HEAD from --source." + ), + ) + parser.add_argument( + "--generated-at", + default=None, + help=( + "Override the recorded generation timestamp (ISO-8601 UTC). Use this " + "for deterministic regenerations during review." + ), + ) + args = parser.parse_args(list(argv) if argv is not None else None) + + source_root = _resolve_source_root(args) + catalogue = build_catalogue_from_source( + source_root=source_root, + source_commit=args.source_commit, + generated_at=args.generated_at, + ) + + repo_root = Path(__file__).resolve().parents[1] + output_path = Path(args.output) + if not output_path.is_absolute(): + output_path = repo_root / output_path + _write_catalogue(catalogue, output_path) + print( + f"Wrote {len(catalogue['devices'])} devices to {output_path} " + f"(source {catalogue['source_commit'][:12]})", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From dbd91ad2e694a4a1ff976e2e56673915edf0fd40 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Fri, 29 May 2026 12:37:16 +1200 Subject: [PATCH 5/6] fix(backend): pin missing symusic dependency after sample_synthesis migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit `029fb92e` ("refactor(backend): migrate sample_synthesis.write_midi to symusic") introduced `from symusic import Note, Score, Tempo, Track` at `apps/backend/sample_synthesis.py:31` but never added `symusic` to `apps/backend/requirements.txt`. After a fresh `./apps/backend/scripts/bootstrap.sh`, eight test modules failed to import — every cascade traced back to that single import: test_sample_synthesis (direct), test_sample_generation, test_sample_audio_content, test_server, test_server_samples (via server → server_samples → sample_generation → sample_synthesis), test_cleanup, test_phase1_evaluation_transcription, and test_polyphonic_evaluation. Adds `symusic==0.6.0` to requirements.txt — matches the existing `==` pin style, alphabetically between `submitit` and `sympy`, supports Python 3.11 (the backend's required interpreter), and resolves cleanly against the rest of the pin set. Also updates `_valid_phase2_result()` in `tests/test_server.py` to include `phase1Fields` on the three recommendation entries (abletonRecommendations[0], mixAndMasterChain[0], secretSauce.workflowSteps[0]). The fixture predates the catalogue gates' citation-existence requirement; with symusic now installed the previously- masked `test_run_interpretation_request_with_profile_config_surfaces_salvage_warnings_in_normal_diagnostics` runs and exposed the gap. Fixture-only update; no contract change. 948 / 948 backend unittests pass (1 skipped, unrelated). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/backend/requirements.txt | 1 + apps/backend/tests/test_server.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt index 35b298bf..632cc0ab 100644 --- a/apps/backend/requirements.txt +++ b/apps/backend/requirements.txt @@ -58,6 +58,7 @@ soundfile==0.13.1 soxr==1.0.0 starlette==0.52.1 submitit==1.5.4 +symusic==0.6.0 sympy==1.14.0 threadpoolctl==3.6.0 torch==2.10.0 diff --git a/apps/backend/tests/test_server.py b/apps/backend/tests/test_server.py index ce8ddb88..8fb4b1b4 100644 --- a/apps/backend/tests/test_server.py +++ b/apps/backend/tests/test_server.py @@ -144,6 +144,7 @@ def _valid_phase2_result() -> dict: "parameter": "Band 1 Frequency", "value": "-1.5 dB @ 35 Hz", "reason": "Tighten sub energy.", + "phase1Fields": ["spectralBalance.subBass"], } ], "secretSauce": { @@ -163,6 +164,7 @@ def _valid_phase2_result() -> dict: "value": "3 ms", "instruction": "Set up light bus glue before the build opens up.", "measurementJustification": "The measured crest profile supports a controlled transient shape.", + "phase1Fields": ["crestFactor"], } ], }, @@ -184,6 +186,7 @@ def _valid_phase2_result() -> dict: "value": "3 ms", "reason": "Keep transients intact.", "advancedTip": "Drive lightly.", + "phase1Fields": ["crestFactor"], } ], } From a2a7b9720bf688ff7c4fc3ff38a0b039c5ef2ccc Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Fri, 29 May 2026 13:04:25 +1200 Subject: [PATCH 6/6] fix(backend): downgrade Live 12 catalogue gate to warn-and-keep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review found the catalogue gate produced confidently-wrong output and silently dropped legitimate advice: 1. Fuzzy parameter resolution rewrote a producer's "Band 1 Frequency" to "1 Frequency B" on EQ Eight (wrong band AND wrong A/B curve) and shipped it with full authority via a PARAMETER_REWRITTEN event. 2. Unknown-device / unknown-parameter checks DROPPED recommendations the statically-extracted (and therefore incomplete) catalogue couldn't confirm — deleting valid advice like EQ Eight "Band 1 Gain". Both violate the product's measurement-cited specificity invariant, and a rewrite to the wrong control is worse than the gap it tried to close. Convert apply_live12_catalogue_gates to advisory warn-and-keep, matching the sibling _validate_phase2_citation_paths: - Never drops a recommendation; never rewrites a parameter. phase2_result is no longer mutated. - Emits RECOMMENDATION_UNVERIFIED warnings (reason discriminator: device_unknown / parameter_unknown / value_out_of_range / citation_missing) on the existing validationWarnings channel. - fuzzy_resolve is demoted to a warning-suppressor: a close-but-inexact parameter is kept silently (no wrong-band mutation); only a parameter with no close match warns. The fuzzy target is never trusted to rewrite. - The new code is intentionally NOT in the frontend SALVAGE_WARNING_CODES set — nothing is salvaged/dropped, so it renders as a plain advisory warning via the graceful fallback rather than tripping the "Gemini output was repaired" decision gate. Also wrap the gate call in server.py in its own try/except so a catalogue load/parse error degrades to a CATALOGUE_CHECK_UNAVAILABLE warning instead of converting an otherwise-successful Gemini interpretation into a 502. Add a regression test asserting EQ-band phrasings ("Band 1 Frequency", "Frequency 1", "Band 1 Gain", ...) are never rewritten against the real shipped catalogue, plus coverage that no record is ever dropped. Full backend suite: 951 pass, 1 skip. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/backend/phase2_catalogue_gates.py | 291 ++++++++---------- apps/backend/server.py | 47 ++- .../tests/test_phase2_validator_catalogue.py | 279 ++++++++++------- 3 files changed, 342 insertions(+), 275 deletions(-) diff --git a/apps/backend/phase2_catalogue_gates.py b/apps/backend/phase2_catalogue_gates.py index 1203b2fa..a2ccea2e 100644 --- a/apps/backend/phase2_catalogue_gates.py +++ b/apps/backend/phase2_catalogue_gates.py @@ -1,39 +1,52 @@ -"""Live 12 source-catalogue gates that drop or rewrite Phase 2 recommendations. +"""Live 12 source-catalogue checks that ANNOTATE Phase 2 recommendations. The Phase 2 Gemini handler emits recommendations as `{device, parameter, value, phase1Fields}` records inside `mixAndMasterChain`, `abletonRecommendations`, -and `secretSauce.workflowSteps`. The curated `prompts/live12_device_catalog.json` -check in `server_phase2._validate_phase2_catalog_entry` is WARNING-only -- it -flags recommendations whose device or parameter is not in the prompt-side -curated catalog but never drops them. - -This module adds a stricter gate backed by the source-extracted -`data/live12_catalogue.json` (generated by `scripts/build_live12_catalogue.py` -from the upstream MIDI Remote Scripts). The contract is: - - 1. Reject if `device` is not in the source catalogue. - 2. Reject if `parameter` is not on the device -- but first try - `Live12Catalogue.fuzzy_resolve`; if it resolves, REWRITE the record's - `parameter` and emit a structured `PARAMETER_REWRITTEN` event with - original + resolved + requestId so nothing is rewritten silently. - 3. Reject if numeric `value` falls outside the catalogue range. The gate is - intentionally inert when `ParamSpec.min/max` are absent because static - source extraction does not carry ranges; reserved for future enrichment - from runtime introspection. - 4. Reject if `phase1Fields` is missing or empty. Unresolved cited paths - (paths that don't resolve against the measurement payload) remain - WARNING-only via `_validate_phase2_citation_paths`; this gate addresses - the harder failure of citing nothing at all. - -Every rejection emits a structured `RECOMMENDATION_REJECTED` event carrying -`reason`, `device`, `parameter`, `path`, and `requestId`. The function returns -the event list; the caller stitches them into the existing -`validationWarnings` channel. The phase2 result is MUTATED in place to drop -rejected entries -- the goal's "never swallow silently" requirement is met by -the emitted events, not by surviving rejected output. - -Living separately from `server_phase2.py` keeps the gate code free of the -FastAPI / pydantic import chain so unit tests can exercise it in pure stdlib. +and `secretSauce.workflowSteps`. This module cross-checks each record against +the source-extracted `data/live12_catalogue.json` (generated by +`scripts/build_live12_catalogue.py` from the upstream MIDI Remote Scripts) and +emits advisory `validationWarnings` for anything it cannot confirm. + +WARN-AND-KEEP CONTRACT (deliberate — see PR history): + This module NEVER drops a recommendation and NEVER rewrites a parameter. It + only emits warning-shaped events; `phase2_result` is left untouched. + + Why warn instead of gate? Phase 2 is an *advisory* layer, the catalogue is + statically extracted and therefore incomplete, and Live's internal parameter + names ("1 Frequency A") diverge from a producer's natural phrasing ("Band 1 + Frequency"). Dropping or auto-rewriting on that basis produced confidently- + wrong output: a fuzzy rewrite of "Band 1 Frequency" landed on "1 Frequency B" + — the wrong band AND the wrong A/B curve — and shipped it to the user with + full authority. That is worse than the gap it tried to close. So every check + now KEEPS the recommendation and rides a WARNING on the existing + `validationWarnings` channel. This mirrors the sibling + `server_phase2._validate_phase2_citation_paths`, which is likewise + WARNING-only and never mutates the Phase 1-authoritative payload. + +The checks (each warn-and-keep): + + 1. `device_unknown` -- `device` is not in the source catalogue. + 2. `parameter_unknown` -- `parameter` is not on the device AND + `Live12Catalogue.fuzzy_resolve` finds NO close match. When fuzzy DOES find + a close match the parameter is almost certainly real but phrased + differently, so the record is kept SILENTLY (no warning). Fuzzy is used + here only to suppress false "unknown" warnings — NEVER to rewrite the + parameter, because the closest lexical match is frequently the wrong + instance on multi-band devices (the EQ-band failure above). + 3. `value_out_of_range` -- numeric `value` falls outside the catalogue range. + Inert today: static extraction carries no `min`/`max`, so `has_range()` is + always False in production; reserved for future runtime-introspection + enrichment. Exercised only by tests that inject a ranged catalogue. + 4. `citation_missing` -- `phase1Fields` is missing or empty. The chain-of- + custody invariant requires every recommendation to cite >=1 Phase 1 field. + +Every warning carries `code="RECOMMENDATION_UNVERIFIED"`, a `reason`, plus +`device`, `parameter`, `path`, and `requestId` so the operator-facing +diagnostic log keeps a complete trail. The function returns the event list; the +caller stitches it into the existing `validationWarnings` channel. + +Living separately from `server_phase2.py` keeps this free of the FastAPI / +pydantic import chain so unit tests can exercise it in pure stdlib. """ from __future__ import annotations @@ -46,10 +59,18 @@ from live12_catalogue import Live12Catalogue -_CATALOGUE_REJECT_REASON_DEVICE_UNKNOWN = "device_unknown" -_CATALOGUE_REJECT_REASON_PARAMETER_UNKNOWN = "parameter_unknown" -_CATALOGUE_REJECT_REASON_VALUE_OUT_OF_RANGE = "value_out_of_range" -_CATALOGUE_REJECT_REASON_CITATION_MISSING = "citation_missing" +_REASON_DEVICE_UNKNOWN = "device_unknown" +_REASON_PARAMETER_UNKNOWN = "parameter_unknown" +_REASON_VALUE_OUT_OF_RANGE = "value_out_of_range" +_REASON_CITATION_MISSING = "citation_missing" + +# Single warning code for every catalogue check. Nothing is rejected or +# rewritten, so the old `RECOMMENDATION_REJECTED` / `PARAMETER_REWRITTEN` codes +# are gone; consumers discriminate on the `reason` field. This code is +# intentionally NOT in the frontend `SALVAGE_WARNING_CODES` set — warn-and-keep +# means no recommendation was silently dropped or repaired, so it must not trip +# the "Gemini output was salvaged" decision gate. +_RECOMMENDATION_UNVERIFIED = "RECOMMENDATION_UNVERIFIED" _VALUE_NUMBER_RE = re.compile(r"-?\d+(?:\.\d+)?") @@ -73,10 +94,10 @@ def _coerce_value_to_number(raw: Any) -> float | None: """Pull a finite numeric value out of Gemini's `value` field. Gemini emits `value` as a free-form string ("4.5", "-12 dB", "10ms", - "1:4", "auto"). The range gate only fires when we can extract a single + "1:4", "auto"). The range check only fires when we can extract a single finite number; anything ambiguous (compound ratios, slashes, "auto", etc.) passes through without a range check rather than producing a false-positive - rejection. + warning. """ if isinstance(raw, bool): # bool is a subclass of int -- exclude. return None @@ -95,7 +116,7 @@ def _coerce_value_to_number(raw: Any) -> float | None: return None -def _build_recommendation_rejected_event( +def _build_unverified_event( *, path: str, reason: str, @@ -106,7 +127,7 @@ def _build_recommendation_rejected_event( value: Any = None, ) -> dict[str, Any]: event: dict[str, Any] = { - "code": "RECOMMENDATION_REJECTED", + "code": _RECOMMENDATION_UNVERIFIED, "path": path, "message": message, "reason": reason, @@ -119,30 +140,7 @@ def _build_recommendation_rejected_event( return event -def _build_parameter_rewritten_event( - *, - path: str, - device: str, - original_parameter: str, - resolved_parameter: str, - request_id: str, -) -> dict[str, Any]: - return { - "code": "PARAMETER_REWRITTEN", - "path": path, - "message": ( - f"Rewrote parameter {original_parameter!r} to " - f"{resolved_parameter!r} on device {device!r} via Live 12 catalogue " - "fuzzy resolution." - ), - "device": device, - "originalParameter": original_parameter, - "resolvedParameter": resolved_parameter, - "requestId": request_id, - } - - -def _catalogue_check_record( +def _inspect_record( *, catalogue: Live12Catalogue, record: dict[str, Any], @@ -150,88 +148,78 @@ def _catalogue_check_record( request_id: str, require_citation: bool, events: list[dict[str, Any]], -) -> bool: - """Run device/parameter/range/citation gates against one record. +) -> None: + """Run advisory device/parameter/range/citation checks against one record. - Returns True when the record is accepted (and may have been mutated in - place by a fuzzy rewrite), False when the record must be dropped. - Emitted events go onto `events`. + Appends WARNING events to `events`. NEVER mutates `record` and NEVER signals + a drop — the recommendation stays in the user-facing payload regardless of + what the checks find. """ device_raw = record.get("device") parameter_raw = record.get("parameter") device = device_raw.strip() if isinstance(device_raw, str) else "" parameter = parameter_raw.strip() if isinstance(parameter_raw, str) else "" - if not catalogue.has_device(device): + canonical_device = catalogue.canonical_device(device) + if canonical_device is None: events.append( - _build_recommendation_rejected_event( + _build_unverified_event( path=base_path, - reason=_CATALOGUE_REJECT_REASON_DEVICE_UNKNOWN, + reason=_REASON_DEVICE_UNKNOWN, message=( f"Device {device!r} is not in the Live 12 source catalogue. " - "Reject before surfacing the recommendation to the user." + "Kept as-is, but flagged as unverified." ), device=device, parameter=parameter, request_id=request_id, ) ) - return False - - canonical_device = catalogue.canonical_device(device) or device - effective_parameter = parameter - - if not catalogue.has_parameter(canonical_device, parameter): - resolution = catalogue.fuzzy_resolve(canonical_device, parameter) - if resolution is None: + elif not catalogue.has_parameter(canonical_device, parameter): + # Not an exact catalogue parameter. Use fuzzy resolution ONLY to decide + # whether to warn: a close match means the parameter is almost certainly + # real under a slightly different name (e.g. "Band 1 Frequency" vs + # "1 Frequency A"), so keep it silently. The fuzzy *target* is NOT + # trusted enough to rewrite — on multi-band devices the closest lexical + # match is routinely the wrong instance. + if catalogue.fuzzy_resolve(canonical_device, parameter) is None: events.append( - _build_recommendation_rejected_event( + _build_unverified_event( path=base_path, - reason=_CATALOGUE_REJECT_REASON_PARAMETER_UNKNOWN, + reason=_REASON_PARAMETER_UNKNOWN, message=( f"Parameter {parameter!r} is not on device " f"{canonical_device!r} in the Live 12 source catalogue, " - "and fuzzy resolution found no close match." + "and no close match was found. Kept as-is, but flagged " + "as unverified." ), device=canonical_device, parameter=parameter, request_id=request_id, ) ) - return False - resolved_device, resolved_parameter = resolution - if resolved_parameter != parameter: - events.append( - _build_parameter_rewritten_event( - path=f"{base_path}.parameter", - device=resolved_device, - original_parameter=parameter, - resolved_parameter=resolved_parameter, - request_id=request_id, - ) - ) - record["parameter"] = resolved_parameter - effective_parameter = resolved_parameter - - spec = catalogue.parameter_spec(canonical_device, effective_parameter) - if spec is not None and spec.has_range(): - numeric_value = _coerce_value_to_number(record.get("value")) - if numeric_value is not None and not spec.in_range(numeric_value): - events.append( - _build_recommendation_rejected_event( - path=base_path, - reason=_CATALOGUE_REJECT_REASON_VALUE_OUT_OF_RANGE, - message=( - f"Value {numeric_value} for {canonical_device!r}.{effective_parameter!r} " - f"is outside the catalogue range [{spec.min}, {spec.max}]." - ), - device=canonical_device, - parameter=effective_parameter, - request_id=request_id, - value=record.get("value"), + else: + # Exact parameter match — the only case where a range check is sound. + spec = catalogue.parameter_spec(canonical_device, parameter) + if spec is not None and spec.has_range(): + numeric_value = _coerce_value_to_number(record.get("value")) + if numeric_value is not None and not spec.in_range(numeric_value): + events.append( + _build_unverified_event( + path=base_path, + reason=_REASON_VALUE_OUT_OF_RANGE, + message=( + f"Value {numeric_value} for {canonical_device!r}." + f"{parameter!r} is outside the catalogue range " + f"[{spec.min}, {spec.max}]. Kept as-is, but flagged " + "as unverified." + ), + device=canonical_device, + parameter=parameter, + request_id=request_id, + value=record.get("value"), + ) ) - ) - return False if require_citation: cited = record.get("phase1Fields") @@ -240,25 +228,23 @@ def _catalogue_check_record( ) if not non_empty: events.append( - _build_recommendation_rejected_event( + _build_unverified_event( path=f"{base_path}.phase1Fields", - reason=_CATALOGUE_REJECT_REASON_CITATION_MISSING, + reason=_REASON_CITATION_MISSING, message=( "Recommendation has no Phase 1 citation. The chain of " "custody invariant requires every Gemini recommendation " - "to cite at least one Phase 1 measurement field." + "to cite at least one Phase 1 measurement field. Kept " + "as-is, but flagged as unverified." ), - device=canonical_device, - parameter=effective_parameter, + device=canonical_device or device, + parameter=parameter, request_id=request_id, ) ) - return False - - return True -def _apply_catalogue_gates_to_list( +def _apply_catalogue_checks_to_list( *, catalogue: Live12Catalogue, phase2_result: dict[str, Any], @@ -271,27 +257,22 @@ def _apply_catalogue_gates_to_list( items = phase2_result.get(list_key) if not isinstance(items, list) or not items: return - surviving: list[Any] = [] for index, item in enumerate(items): record = _as_record(item) if record is None: - # Pass through opaque entries -- shape salvage handled them already. - surviving.append(item) + # Opaque entries left untouched — shape salvage handled them already. continue - base_path = f"{base_path_prefix}[{index}]" - if _catalogue_check_record( + _inspect_record( catalogue=catalogue, record=record, - base_path=base_path, + base_path=f"{base_path_prefix}[{index}]", request_id=request_id, require_citation=require_citation, events=events, - ): - surviving.append(record) - phase2_result[list_key] = surviving + ) -def _apply_catalogue_gates_to_workflow_steps( +def _apply_catalogue_checks_to_workflow_steps( *, catalogue: Live12Catalogue, phase2_result: dict[str, Any], @@ -304,26 +285,20 @@ def _apply_catalogue_gates_to_workflow_steps( steps = secret_sauce.get("workflowSteps") if not isinstance(steps, list) or not steps: return - surviving: list[Any] = [] for index, item in enumerate(steps): record = _as_record(item) if record is None: - surviving.append(item) continue - base_path = f"secretSauce.workflowSteps[{index}]" # secretSauce workflow steps cite Phase 1 via `phase1Fields` too -- the - # schema requires the array, so the citation gate applies. - if _catalogue_check_record( + # schema requires the array, so the citation check applies. + _inspect_record( catalogue=catalogue, record=record, - base_path=base_path, + base_path=f"secretSauce.workflowSteps[{index}]", request_id=request_id, require_citation=True, events=events, - ): - surviving.append(record) - secret_sauce["workflowSteps"] = surviving - phase2_result["secretSauce"] = secret_sauce + ) def apply_live12_catalogue_gates( @@ -332,13 +307,15 @@ def apply_live12_catalogue_gates( request_id: str, catalogue: Live12Catalogue | None = None, ) -> list[dict[str, Any]]: - """Mutate `phase2_result` in place, dropping recommendations that fail the - Live 12 catalogue gates and rewriting parameters resolved via fuzzy match. + """Cross-check `phase2_result` against the Live 12 source catalogue and + return advisory warning events. - Returns a list of structured events (warning-shaped dicts) describing every - rejection and rewrite. The caller stitches them into the existing - `validationWarnings` channel. The mutation is intentional so the - user-facing payload contains only accepted recommendations. + WARN-AND-KEEP: this function does NOT mutate `phase2_result`, does NOT drop + recommendations, and does NOT rewrite parameters. It only inspects and + returns warning-shaped dicts describing devices/parameters/citations the + catalogue cannot confirm. The caller stitches the events into the existing + `validationWarnings` channel. See the module docstring for why gating was + deliberately downgraded to warning. The catalogue is loaded from `data/live12_catalogue.json` via `Live12Catalogue.load_default()` when not injected explicitly (tests pass a @@ -349,9 +326,7 @@ def apply_live12_catalogue_gates( if catalogue is None: catalogue = Live12Catalogue.load_default() events: list[dict[str, Any]] = [] - # mixAndMasterChain items have a `phase1Fields` array per the schema -- - # citation gate applies. Same for abletonRecommendations. - _apply_catalogue_gates_to_list( + _apply_catalogue_checks_to_list( catalogue=catalogue, phase2_result=phase2_result, list_key="mixAndMasterChain", @@ -360,7 +335,7 @@ def apply_live12_catalogue_gates( require_citation=True, events=events, ) - _apply_catalogue_gates_to_list( + _apply_catalogue_checks_to_list( catalogue=catalogue, phase2_result=phase2_result, list_key="abletonRecommendations", @@ -369,7 +344,7 @@ def apply_live12_catalogue_gates( require_citation=True, events=events, ) - _apply_catalogue_gates_to_workflow_steps( + _apply_catalogue_checks_to_workflow_steps( catalogue=catalogue, phase2_result=phase2_result, request_id=request_id, diff --git a/apps/backend/server.py b/apps/backend/server.py index d7cff8f8..07117138 100644 --- a/apps/backend/server.py +++ b/apps/backend/server.py @@ -1621,20 +1621,39 @@ def _generate_files_api() -> Any: if profile_id == "producer_summary" and interpretation_result is not None else [] ) - # Live 12 source-catalogue gates run last so they see (a) the salvaged - # / coerced shape, (b) the post-rename measurement field names that - # the citation walker uses. They MUTATE `interpretation_result` by - # dropping recommendations that fail device/parameter/range/citation - # checks; the events surface as warning-shaped validationWarnings so - # the operator-facing diagnostic log keeps a complete trail. - catalogue_gate_warnings = ( - apply_live12_catalogue_gates( - interpretation_result, - request_id=request_id, - ) - if profile_id == "producer_summary" and interpretation_result is not None - else [] - ) + # Live 12 source-catalogue checks run last so they see (a) the salvaged + # / coerced shape and (b) the post-rename measurement field names that + # the citation walker uses. They are ADVISORY (warn-and-keep): they + # never drop or rewrite a recommendation, only emit warning-shaped + # validationWarnings flagging devices/parameters/citations the source + # catalogue cannot confirm, so the operator-facing diagnostic log keeps + # a complete trail. Wrapped defensively: a catalogue load/parse error + # must NOT fail an otherwise-successful Gemini interpretation. + catalogue_gate_warnings: list[dict[str, Any]] = [] + if profile_id == "producer_summary" and interpretation_result is not None: + try: + catalogue_gate_warnings = apply_live12_catalogue_gates( + interpretation_result, + request_id=request_id, + ) + except Exception as exc: # degrade, never fail the request on a gate error + logger.warning( + "Live 12 catalogue checks skipped (request_id=%s): %s", + request_id, + exc, + ) + catalogue_gate_warnings = [ + { + "code": "CATALOGUE_CHECK_UNAVAILABLE", + "path": "interpretationResult", + "message": ( + "Live 12 catalogue checks were skipped due to an " + f"internal error: {exc}. The interpretation is " + "unaffected." + ), + "requestId": request_id, + } + ] validation_warnings = ( parse_validation_warnings + style_profile_warnings diff --git a/apps/backend/tests/test_phase2_validator_catalogue.py b/apps/backend/tests/test_phase2_validator_catalogue.py index 0344772f..e4c19672 100644 --- a/apps/backend/tests/test_phase2_validator_catalogue.py +++ b/apps/backend/tests/test_phase2_validator_catalogue.py @@ -1,20 +1,27 @@ -"""Integration tests for the Live 12 catalogue gates in -`server_phase2.apply_live12_catalogue_gates`. - -The synthetic Phase 2 result here intentionally bundles one recommendation in -each of the four categories the goal calls out: - - 1. Valid recommendation — accepted, no event. - 2. Hallucinated device — rejected, RECOMMENDATION_REJECTED with - reason=device_unknown. - 3. Fixable parameter typo — rewritten, PARAMETER_REWRITTEN with original + - resolved + requestId. - 4. Out-of-range value (on a parameter that DOES carry a spec.min/max in the - fixture) — rejected, RECOMMENDATION_REJECTED with - reason=value_out_of_range. - -A fifth case covers the citation gate (missing/empty phase1Fields → -RECOMMENDATION_REJECTED with reason=citation_missing). +"""Integration tests for the Live 12 catalogue checks in +`phase2_catalogue_gates.apply_live12_catalogue_gates`. + +WARN-AND-KEEP CONTRACT: the checks NEVER drop a recommendation and NEVER +rewrite a parameter. Each check emits an advisory `RECOMMENDATION_UNVERIFIED` +warning (discriminated by `reason`) and leaves the recommendation untouched in +the payload. These tests assert exactly that — every record survives, every +`parameter` is byte-for-byte what Gemini wrote, and the legacy +`RECOMMENDATION_REJECTED` / `PARAMETER_REWRITTEN` codes are never emitted. + +The synthetic Phase 2 result here bundles one recommendation in each of the +four check categories: + + 1. Valid recommendation — no event. + 2. Unknown device — RECOMMENDATION_UNVERIFIED, reason=device_unknown (kept). + 3a. Near-miss parameter that fuzzy-resolves — kept SILENTLY, no event (fuzzy + suppresses the warning but never rewrites the parameter). + 3b. Parameter with no close match — RECOMMENDATION_UNVERIFIED, + reason=parameter_unknown (kept). + 4. Out-of-range value (on a parameter that DOES carry spec.min/max in the + fixture) — RECOMMENDATION_UNVERIFIED, reason=value_out_of_range (kept). + +A fifth case covers the citation check (missing/empty phase1Fields → +RECOMMENDATION_UNVERIFIED, reason=citation_missing, kept). """ from __future__ import annotations @@ -34,6 +41,8 @@ / "three_device_catalogue.json" ) +_LEGACY_MUTATING_CODES = ("RECOMMENDATION_REJECTED", "PARAMETER_REWRITTEN") + def _make_recommendation( *, @@ -57,22 +66,25 @@ def _make_recommendation( } -class CatalogueGatesIntegrationTests(unittest.TestCase): +class CatalogueChecksIntegrationTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.catalogue = Live12Catalogue.from_path(_THREE_DEVICE_FIXTURE) def _apply(self, phase2_result: dict[str, Any], *, request_id: str = "req-test-42"): - events = apply_live12_catalogue_gates( + return apply_live12_catalogue_gates( phase2_result, request_id=request_id, catalogue=self.catalogue, ) - return events + + def _assert_no_legacy_codes(self, events: list[dict[str, Any]]) -> None: + for event in events: + self.assertNotIn(event["code"], _LEGACY_MUTATING_CODES) # ----- Valid recommendation ----- - def test_valid_recommendation_passes_unchanged(self): + def test_valid_recommendation_passes_with_no_event(self): phase2 = { "abletonRecommendations": [ _make_recommendation( @@ -88,7 +100,7 @@ def test_valid_recommendation_passes_unchanged(self): self.assertEqual(len(phase2["abletonRecommendations"]), 1) self.assertEqual(phase2["abletonRecommendations"][0]["parameter"], "Drive") - def test_display_name_device_passes_unchanged(self): + def test_display_name_device_passes_with_no_event(self): phase2 = { "abletonRecommendations": [ _make_recommendation( @@ -103,9 +115,9 @@ def test_display_name_device_passes_unchanged(self): self.assertEqual(events, []) self.assertEqual(len(phase2["abletonRecommendations"]), 1) - # ----- Hallucinated device ----- + # ----- Unknown device ----- - def test_hallucinated_device_is_rejected(self): + def test_unknown_device_is_flagged_and_kept(self): phase2 = { "abletonRecommendations": [ _make_recommendation( @@ -116,19 +128,22 @@ def test_hallucinated_device_is_rejected(self): ], } events = self._apply(phase2) - self.assertEqual(phase2["abletonRecommendations"], []) + # Kept — warn-and-keep never drops. + self.assertEqual(len(phase2["abletonRecommendations"]), 1) self.assertEqual(len(events), 1) event = events[0] - self.assertEqual(event["code"], "RECOMMENDATION_REJECTED") + self.assertEqual(event["code"], "RECOMMENDATION_UNVERIFIED") self.assertEqual(event["reason"], "device_unknown") self.assertEqual(event["device"], "Saturation Color") self.assertEqual(event["requestId"], "req-test-42") self.assertEqual(event["path"], "abletonRecommendations[0]") - # ----- Fixable parameter typo ----- + # ----- Near-miss parameter (fuzzy-resolvable) ----- - def test_fixable_parameter_typo_is_rewritten_with_event(self): - # Saturator catalogue has "Drive" (close to "Drives" typo). + def test_fuzzy_resolvable_parameter_is_kept_silently_and_not_rewritten(self): + # Saturator catalogue has "Drive"; "Drives" fuzzy-resolves to it. + # Under warn-and-keep that suppresses the warning but MUST NOT rewrite + # the parameter — the record keeps "Drives" exactly as written. phase2 = { "abletonRecommendations": [ _make_recommendation( @@ -139,21 +154,12 @@ def test_fixable_parameter_typo_is_rewritten_with_event(self): ], } events = self._apply(phase2) - # Recommendation survives — fuzzy resolution accepted. + self.assertEqual(events, []) self.assertEqual(len(phase2["abletonRecommendations"]), 1) - self.assertEqual(phase2["abletonRecommendations"][0]["parameter"], "Drive") - # A single PARAMETER_REWRITTEN event was emitted, original + resolved - # + requestId all present. - self.assertEqual(len(events), 1) - event = events[0] - self.assertEqual(event["code"], "PARAMETER_REWRITTEN") - self.assertEqual(event["device"], "Saturator") - self.assertEqual(event["originalParameter"], "Drives") - self.assertEqual(event["resolvedParameter"], "Drive") - self.assertEqual(event["requestId"], "req-test-42") - self.assertEqual(event["path"], "abletonRecommendations[0].parameter") + # Critical: parameter is NOT rewritten. + self.assertEqual(phase2["abletonRecommendations"][0]["parameter"], "Drives") - def test_parameter_unresolvable_typo_is_rejected(self): + def test_parameter_with_no_close_match_is_flagged_and_kept(self): phase2 = { "abletonRecommendations": [ _make_recommendation( @@ -164,16 +170,19 @@ def test_parameter_unresolvable_typo_is_rejected(self): ], } events = self._apply(phase2) - self.assertEqual(phase2["abletonRecommendations"], []) + self.assertEqual(len(phase2["abletonRecommendations"]), 1) + self.assertEqual( + phase2["abletonRecommendations"][0]["parameter"], "TotalGarbage" + ) self.assertEqual(len(events), 1) - self.assertEqual(events[0]["code"], "RECOMMENDATION_REJECTED") + self.assertEqual(events[0]["code"], "RECOMMENDATION_UNVERIFIED") self.assertEqual(events[0]["reason"], "parameter_unknown") self.assertEqual(events[0]["device"], "Saturator") self.assertEqual(events[0]["parameter"], "TotalGarbage") # ----- Out-of-range value ----- - def test_out_of_range_value_is_rejected(self): + def test_out_of_range_value_is_flagged_and_kept(self): # Operator/Volume in the fixture: type=float, min=-36.0, max=6.0. phase2 = { "abletonRecommendations": [ @@ -185,16 +194,16 @@ def test_out_of_range_value_is_rejected(self): ], } events = self._apply(phase2) - self.assertEqual(phase2["abletonRecommendations"], []) + self.assertEqual(len(phase2["abletonRecommendations"]), 1) self.assertEqual(len(events), 1) event = events[0] - self.assertEqual(event["code"], "RECOMMENDATION_REJECTED") + self.assertEqual(event["code"], "RECOMMENDATION_UNVERIFIED") self.assertEqual(event["reason"], "value_out_of_range") self.assertEqual(event["device"], "Operator") self.assertEqual(event["parameter"], "Volume") self.assertEqual(event["value"], "42 dB") - def test_in_range_value_passes(self): + def test_in_range_value_produces_no_event(self): phase2 = { "abletonRecommendations": [ _make_recommendation( @@ -208,8 +217,8 @@ def test_in_range_value_passes(self): self.assertEqual(events, []) self.assertEqual(len(phase2["abletonRecommendations"]), 1) - def test_range_gate_is_inert_when_spec_lacks_bounds(self): - # Saturator/Drive in the fixture: name only, no min/max -- range gate + def test_range_check_is_inert_when_spec_lacks_bounds(self): + # Saturator/Drive in the fixture: name only, no min/max -- range check # must NOT fire, even on absurd values. phase2 = { "abletonRecommendations": [ @@ -224,9 +233,9 @@ def test_range_gate_is_inert_when_spec_lacks_bounds(self): self.assertEqual(events, []) self.assertEqual(len(phase2["abletonRecommendations"]), 1) - def test_unparseable_value_passes_range_gate(self): - # "auto" cannot be coerced to a number — the gate stays silent rather - # than producing a false-positive rejection. + def test_unparseable_value_produces_no_range_event(self): + # "auto" cannot be coerced to a number — the check stays silent rather + # than producing a false-positive warning. phase2 = { "abletonRecommendations": [ _make_recommendation( @@ -240,9 +249,9 @@ def test_unparseable_value_passes_range_gate(self): self.assertEqual(events, []) self.assertEqual(len(phase2["abletonRecommendations"]), 1) - # ----- Citation gate ----- + # ----- Citation check ----- - def test_missing_phase1_fields_is_rejected(self): + def test_missing_phase1_fields_is_flagged_and_kept(self): phase2 = { "abletonRecommendations": [ _make_recommendation( @@ -254,13 +263,13 @@ def test_missing_phase1_fields_is_rejected(self): ], } events = self._apply(phase2) - self.assertEqual(phase2["abletonRecommendations"], []) + self.assertEqual(len(phase2["abletonRecommendations"]), 1) self.assertEqual(len(events), 1) - self.assertEqual(events[0]["code"], "RECOMMENDATION_REJECTED") + self.assertEqual(events[0]["code"], "RECOMMENDATION_UNVERIFIED") self.assertEqual(events[0]["reason"], "citation_missing") self.assertEqual(events[0]["path"], "abletonRecommendations[0].phase1Fields") - def test_whitespace_only_phase1_fields_is_rejected(self): + def test_whitespace_only_phase1_fields_is_flagged_and_kept(self): phase2 = { "abletonRecommendations": [ _make_recommendation( @@ -272,13 +281,13 @@ def test_whitespace_only_phase1_fields_is_rejected(self): ], } events = self._apply(phase2) - self.assertEqual(phase2["abletonRecommendations"], []) + self.assertEqual(len(phase2["abletonRecommendations"]), 1) self.assertEqual(len(events), 1) self.assertEqual(events[0]["reason"], "citation_missing") - # ----- Mixed: all four cases at once ----- + # ----- Mixed: all cases at once, everything kept ----- - def test_mixed_recommendations_drop_only_failures(self): + def test_mixed_recommendations_are_all_kept_with_warnings(self): phase2 = { "abletonRecommendations": [ _make_recommendation( @@ -296,33 +305,29 @@ def test_mixed_recommendations_drop_only_failures(self): ], } events = self._apply(phase2) - # Survivors: index 0 (valid) and index 2 (rewritten "Drives" -> "Drive"). - self.assertEqual(len(phase2["abletonRecommendations"]), 2) - self.assertEqual(phase2["abletonRecommendations"][0]["parameter"], "Drive") - self.assertEqual(phase2["abletonRecommendations"][1]["parameter"], "Drive") - - codes = sorted(e["code"] for e in events) - reasons = sorted( - e.get("reason", "") for e in events if e["code"] == "RECOMMENDATION_REJECTED" - ) + # Nothing is dropped — all four survive in their original order. + self.assertEqual(len(phase2["abletonRecommendations"]), 4) + # Parameters are never rewritten — index 2 keeps "Drives". self.assertEqual( - codes, - ["PARAMETER_REWRITTEN", "RECOMMENDATION_REJECTED", "RECOMMENDATION_REJECTED"], + [r["parameter"] for r in phase2["abletonRecommendations"]], + ["Drive", "Drive", "Drives", "Volume"], ) + self._assert_no_legacy_codes(events) + # Only the unknown-device (idx 1) and out-of-range (idx 3) warn; the + # fuzzy-resolvable "Drives" (idx 2) is kept silently. + self.assertTrue(all(e["code"] == "RECOMMENDATION_UNVERIFIED" for e in events)) + reasons = sorted(e["reason"] for e in events) self.assertEqual(reasons, ["device_unknown", "value_out_of_range"]) - # Base paths still reference the ORIGINAL indices in Gemini's response - # so the operator log can correlate which slot was dropped. - rejection_paths = sorted( - e["path"] for e in events if e["code"] == "RECOMMENDATION_REJECTED" - ) + # Paths reference the ORIGINAL indices so the operator log can correlate. + paths = sorted(e["path"] for e in events) self.assertEqual( - rejection_paths, + paths, ["abletonRecommendations[1]", "abletonRecommendations[3]"], ) - # ----- mixAndMasterChain receives the same gates ----- + # ----- mixAndMasterChain receives the same checks ----- - def test_mix_and_master_chain_is_gated(self): + def test_mix_and_master_chain_is_checked(self): phase2 = { "mixAndMasterChain": [ { @@ -331,14 +336,14 @@ def test_mix_and_master_chain_is_gated(self): "deviceFamily": "NATIVE", "trackContext": "Master", "workflowStage": "MASTER", - "parameter": "Drives", # fuzzy rewrite to "Drive" + "parameter": "Drives", # fuzzy-resolvable -> kept silently "value": "3.0", "reason": "test", "phase1Fields": ["bpm"], }, { "order": 2, - "device": "Saturation Color", # hallucination + "device": "Saturation Color", # unknown device "deviceFamily": "NATIVE", "trackContext": "Master", "workflowStage": "MASTER", @@ -350,20 +355,18 @@ def test_mix_and_master_chain_is_gated(self): ], } events = self._apply(phase2) - self.assertEqual(len(phase2["mixAndMasterChain"]), 1) - self.assertEqual(phase2["mixAndMasterChain"][0]["parameter"], "Drive") - rewrite_paths = sorted( - e["path"] for e in events if e["code"] == "PARAMETER_REWRITTEN" - ) - self.assertEqual(rewrite_paths, ["mixAndMasterChain[0].parameter"]) - rejection_paths = sorted( - e["path"] for e in events if e["code"] == "RECOMMENDATION_REJECTED" - ) - self.assertEqual(rejection_paths, ["mixAndMasterChain[1]"]) + # Both kept; "Drives" not rewritten. + self.assertEqual(len(phase2["mixAndMasterChain"]), 2) + self.assertEqual(phase2["mixAndMasterChain"][0]["parameter"], "Drives") + self._assert_no_legacy_codes(events) + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["code"], "RECOMMENDATION_UNVERIFIED") + self.assertEqual(events[0]["reason"], "device_unknown") + self.assertEqual(events[0]["path"], "mixAndMasterChain[1]") # ----- secretSauce.workflowSteps ----- - def test_workflow_steps_are_gated(self): + def test_workflow_steps_are_checked(self): phase2 = { "secretSauce": { "title": "Test", @@ -374,7 +377,7 @@ def test_workflow_steps_are_gated(self): "step": 1, "trackContext": "Drums", "device": "Saturator", - "parameter": "Drives", # fuzzy rewrite + "parameter": "Drives", # fuzzy-resolvable -> kept silently "value": "3.0", "instruction": "Test", "measurementJustification": "Test", @@ -395,15 +398,14 @@ def test_workflow_steps_are_gated(self): } events = self._apply(phase2) steps = phase2["secretSauce"]["workflowSteps"] - self.assertEqual(len(steps), 1) - self.assertEqual(steps[0]["parameter"], "Drive") - codes = sorted(e["code"] for e in events) - self.assertEqual(codes, ["PARAMETER_REWRITTEN", "RECOMMENDATION_REJECTED"]) - rewrite = next(e for e in events if e["code"] == "PARAMETER_REWRITTEN") - rejection = next(e for e in events if e["code"] == "RECOMMENDATION_REJECTED") - self.assertEqual(rewrite["path"], "secretSauce.workflowSteps[0].parameter") - self.assertEqual(rejection["path"], "secretSauce.workflowSteps[1].phase1Fields") - self.assertEqual(rejection["reason"], "citation_missing") + # Both kept; "Drives" not rewritten. + self.assertEqual(len(steps), 2) + self.assertEqual(steps[0]["parameter"], "Drives") + self._assert_no_legacy_codes(events) + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["code"], "RECOMMENDATION_UNVERIFIED") + self.assertEqual(events[0]["reason"], "citation_missing") + self.assertEqual(events[0]["path"], "secretSauce.workflowSteps[1].phase1Fields") # ----- request_id is always present on events ----- @@ -419,6 +421,64 @@ def test_every_event_carries_request_id(self): for event in events: self.assertEqual(event["requestId"], "custom-req-id-001") + # ----- Regression: EQ-band parameters must never be rewritten ----- + + def test_eq_band_parameter_is_never_rewritten_against_real_catalogue(self): + """Regression for the wrong-band rewrite. Against the real shipped + catalogue, EQ Eight stores band params as '1 Frequency A' ... + '8 Frequency B'. A producer's natural phrasing ('Band 1 Frequency') + lexically fuzzy-matches the WRONG instance ('1 Frequency B' / even + '8 Frequency B'). Warn-and-keep must leave the parameter byte-for-byte + as written and never drop the recommendation.""" + catalogue = Live12Catalogue.load_default() + for phrasing in ( + "Band 1 Frequency", + "Frequency 1", + "Frequency", + "Band 1 Gain", + "Resonance", + ): + with self.subTest(phrasing=phrasing): + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="EQ Eight", + parameter=phrasing, + value="-1.5 dB @ 35 Hz", + phase1_fields=["spectralBalance.subBass"], + ), + ], + } + events = apply_live12_catalogue_gates( + phase2, request_id="req-eq", catalogue=catalogue + ) + rec = phase2["abletonRecommendations"][0] + # Never dropped. + self.assertEqual(len(phase2["abletonRecommendations"]), 1) + # Never rewritten — exactly what Gemini wrote. + self.assertEqual(rec["parameter"], phrasing) + # Never via a legacy mutating code. + self._assert_no_legacy_codes(events) + + def test_record_failing_every_check_is_still_kept(self): + # Unknown device AND missing citation — the worst case. Still kept, + # with both warnings surfaced. + phase2 = { + "abletonRecommendations": [ + _make_recommendation( + device="Totally Fake Device", + parameter="Whatever", + value="1", + phase1_fields=[], + ), + ], + } + events = self._apply(phase2) + self.assertEqual(len(phase2["abletonRecommendations"]), 1) + self._assert_no_legacy_codes(events) + reasons = sorted(e["reason"] for e in events) + self.assertEqual(reasons, ["citation_missing", "device_unknown"]) + # ----- Idempotency / empty / opaque input ----- def test_empty_phase2_result_produces_no_events(self): @@ -434,6 +494,19 @@ def test_non_dict_input_is_safe(self): ) self.assertEqual(events, []) + def test_opaque_list_entries_are_left_untouched(self): + phase2 = { + "abletonRecommendations": [ + "not a record", + _make_recommendation(device="Saturator", parameter="Drive", value="1"), + ], + } + events = self._apply(phase2) + # Opaque entry survives untouched; the real record is checked (and OK). + self.assertEqual(len(phase2["abletonRecommendations"]), 2) + self.assertEqual(phase2["abletonRecommendations"][0], "not a record") + self.assertEqual(events, []) + def test_loads_default_catalogue_when_not_injected(self): """Smoke test: production code path loads `data/live12_catalogue.json` via `Live12Catalogue.load_default()` when no catalogue is injected.