livepilot 1.23.2 → 1.23.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +124 -0
- package/README.md +108 -10
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +39 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/cross_pack_chain.py +658 -0
- package/mcp_server/atlas/demo_story.py +700 -0
- package/mcp_server/atlas/extract_chain.py +786 -0
- package/mcp_server/atlas/macro_fingerprint.py +554 -0
- package/mcp_server/atlas/overlays.py +95 -3
- package/mcp_server/atlas/pack_aware_compose.py +1255 -0
- package/mcp_server/atlas/preset_resolver.py +238 -0
- package/mcp_server/atlas/tools.py +1001 -31
- package/mcp_server/atlas/transplant.py +1177 -0
- package/mcp_server/mix_engine/state_builder.py +44 -1
- package/mcp_server/runtime/capability_state.py +34 -3
- package/mcp_server/runtime/remote_commands.py +10 -0
- package/mcp_server/server.py +45 -24
- package/mcp_server/tools/agent_os.py +33 -9
- package/mcp_server/tools/analyzer.py +84 -23
- package/mcp_server/tools/browser.py +20 -1
- package/mcp_server/tools/devices.py +78 -11
- package/mcp_server/tools/perception.py +5 -1
- package/mcp_server/tools/tracks.py +39 -2
- package/mcp_server/user_corpus/__init__.py +48 -0
- package/mcp_server/user_corpus/manifest.py +142 -0
- package/mcp_server/user_corpus/plugin_engine/__init__.py +39 -0
- package/mcp_server/user_corpus/plugin_engine/detector.py +579 -0
- package/mcp_server/user_corpus/plugin_engine/manual.py +347 -0
- package/mcp_server/user_corpus/plugin_engine/research.py +247 -0
- package/mcp_server/user_corpus/runner.py +261 -0
- package/mcp_server/user_corpus/scanner.py +115 -0
- package/mcp_server/user_corpus/scanners/__init__.py +18 -0
- package/mcp_server/user_corpus/scanners/adg.py +79 -0
- package/mcp_server/user_corpus/scanners/als.py +144 -0
- package/mcp_server/user_corpus/scanners/amxd.py +374 -0
- package/mcp_server/user_corpus/scanners/plugin_preset.py +202 -0
- package/mcp_server/user_corpus/tools.py +904 -0
- package/mcp_server/user_corpus/wizard.py +224 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/browser.py +7 -2
- package/remote_script/LivePilot/devices.py +9 -0
- package/remote_script/LivePilot/simpler_sample.py +98 -0
- package/requirements.txt +3 -3
- package/server.json +2 -2
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""AMXD scanner — extracts metadata from Max for Live device files.
|
|
2
|
+
|
|
3
|
+
.amxd structure (binary):
|
|
4
|
+
24-byte 'ampf' header + ptch chunk + mx@c chunk + JSON patcher + frozen deps
|
|
5
|
+
|
|
6
|
+
We do a best-effort parse:
|
|
7
|
+
- Read the 24-byte ampf header → device type byte (audio/instrument/midi)
|
|
8
|
+
- Locate the JSON patcher block by scanning for "{ \"patcher\""
|
|
9
|
+
- Parse the JSON, extract:
|
|
10
|
+
- patcher.appversion (Max major.minor)
|
|
11
|
+
- patcher.boxes[*].text (script names, used to infer "what's inside")
|
|
12
|
+
- patcher.parameters (Live-exposed parameter names where stored as plain JSON)
|
|
13
|
+
- Look for a `var VERSION = "..."` string in any embedded js (LivePilot ping pattern)
|
|
14
|
+
|
|
15
|
+
Param VALUES inside the frozen JS deps are NOT extracted (similar to PluginDevice
|
|
16
|
+
binary state — opaque without a Max runtime). Identity metadata is what we capture.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from ..scanner import Scanner, register_scanner
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ─── Scanner ────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Producer-vocabulary keywords that survive a substring match against a
|
|
33
|
+
# .amxd device's filename. Hits become "purpose:<keyword>" tags so the
|
|
34
|
+
# overlay index can answer "what's an arpeggiator in my user library".
|
|
35
|
+
# Order matters slightly — short keywords go last so longer matches win.
|
|
36
|
+
_PURPOSE_KEYWORDS: tuple[str, ...] = (
|
|
37
|
+
# Sequencing / rhythm
|
|
38
|
+
"arpeggiator", "arp", "sequencer", "euclidean", "polyrhythm", "polymeter",
|
|
39
|
+
"groove", "humanize", "swing", "step",
|
|
40
|
+
# Drum / percussion
|
|
41
|
+
"drum", "kick", "snare", "hihat", "hat", "ride", "clap", "perc", "rim",
|
|
42
|
+
"808", "cymbal",
|
|
43
|
+
# Synthesis
|
|
44
|
+
"synth", "instrument", "wavetable", "additive", "subtractive", "fm",
|
|
45
|
+
"physical", "granular", "sample", "sampler", "looper", "operator", "drift",
|
|
46
|
+
"wavefolder", "fold", "noise", "oscillator",
|
|
47
|
+
# Filtering / EQ
|
|
48
|
+
"filter", "lowpass", "highpass", "bandpass", "comb", "formant",
|
|
49
|
+
"eq", "parametric", "shelving",
|
|
50
|
+
# Time-based effects
|
|
51
|
+
"delay", "echo", "reverb", "chorus", "phaser", "flanger", "tremolo",
|
|
52
|
+
"vibrato", "shimmer", "convolution", "spectral",
|
|
53
|
+
# Distortion / saturation
|
|
54
|
+
"saturator", "saturation", "distort", "distortion", "bitcrush", "redux",
|
|
55
|
+
"shaper", "warmth", "drive", "tube", "tape", "vinyl",
|
|
56
|
+
# Modulation
|
|
57
|
+
"lfo", "envelope", "modulator", "modulation", "morph", "macro",
|
|
58
|
+
# Dynamics / mix
|
|
59
|
+
"sidechain", "compressor", "compression", "gate", "limiter", "expander",
|
|
60
|
+
"transient", "ducker", "pumper", "pump",
|
|
61
|
+
# Pitch / tuning / harmony
|
|
62
|
+
"vocoder", "harmonizer", "harmony", "pitch", "tuner", "tune",
|
|
63
|
+
"chord", "scale", "transpose", "transposer", "key", "interval",
|
|
64
|
+
# Glitch / experimental
|
|
65
|
+
"stutter", "glitch", "freeze", "stretch", "skip",
|
|
66
|
+
"feedback", "resonator", "ringmod",
|
|
67
|
+
# Utility / routing
|
|
68
|
+
"midi", "cc", "rack", "router", "splitter", "mixer", "send", "return",
|
|
69
|
+
"matrix", "patch", "bus",
|
|
70
|
+
# Common third-party device prefixes (J74, ML, mt., MT, K-Devices, fors)
|
|
71
|
+
"ableton", "live", "j74", "mt.", "k-devices", "fors", "iftah", "monolake",
|
|
72
|
+
# Visual / analyzer
|
|
73
|
+
"analyzer", "scope", "spectrum", "meter", "tuner",
|
|
74
|
+
# Sonic descriptors (where filename hints at character)
|
|
75
|
+
"warm", "bright", "dark", "crisp", "lofi", "vintage", "modern",
|
|
76
|
+
"ambient", "drone", "pad", "lead", "bass",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@register_scanner
|
|
81
|
+
class AmxdScanner(Scanner):
|
|
82
|
+
type_id = "amxd"
|
|
83
|
+
file_extensions = [".amxd"]
|
|
84
|
+
output_subdir = "max_devices"
|
|
85
|
+
schema_version = 1
|
|
86
|
+
|
|
87
|
+
def scan_one(self, path: Path) -> dict:
|
|
88
|
+
raw = path.read_bytes()
|
|
89
|
+
return _parse_amxd(raw, path.name)
|
|
90
|
+
|
|
91
|
+
def derive_tags(self, sidecar: dict) -> list[str]:
|
|
92
|
+
tags = ["max-device"]
|
|
93
|
+
dev_type = sidecar.get("device_type")
|
|
94
|
+
if dev_type:
|
|
95
|
+
tags.append(f"max-{dev_type}")
|
|
96
|
+
max_version = sidecar.get("max_version")
|
|
97
|
+
if max_version:
|
|
98
|
+
tags.append(f"max-v{max_version}")
|
|
99
|
+
if sidecar.get("livepilot_ping_version"):
|
|
100
|
+
tags.append("livepilot-ping")
|
|
101
|
+
for p in (sidecar.get("exposed_parameters") or [])[:5]:
|
|
102
|
+
tags.append(f"param:{_slug(p)}")
|
|
103
|
+
|
|
104
|
+
# Producer-vocabulary tagging — scans against:
|
|
105
|
+
# 1. The filename (cheap, often diagnostic)
|
|
106
|
+
# 2. The patcher keyword_corpus (annotations + parameter names + varnames +
|
|
107
|
+
# subpatcher names) — closes the gap for devices whose name doesn't
|
|
108
|
+
# contain producer-vocab words (e.g. Sie-Q has "EQ" in box annotations
|
|
109
|
+
# but not its filename).
|
|
110
|
+
name_lower = (sidecar.get("name") or "").lower()
|
|
111
|
+
keyword_corpus = (sidecar.get("keyword_corpus") or "").lower()
|
|
112
|
+
seen_purposes: set[str] = set()
|
|
113
|
+
for kw in _PURPOSE_KEYWORDS:
|
|
114
|
+
if kw in seen_purposes:
|
|
115
|
+
continue
|
|
116
|
+
if kw in name_lower or kw in keyword_corpus:
|
|
117
|
+
tags.append(f"purpose:{kw}")
|
|
118
|
+
seen_purposes.add(kw)
|
|
119
|
+
|
|
120
|
+
# Object-class signature — what kind of Max device this is by structure.
|
|
121
|
+
# E.g., a device with many `live.dial` boxes is parameter-heavy; one
|
|
122
|
+
# with `js` boxes is script-driven; one with `gen~` is DSP-graph.
|
|
123
|
+
obj_classes = sidecar.get("object_classes") or {}
|
|
124
|
+
for cls, n in obj_classes.items():
|
|
125
|
+
cls_lower = cls.lower()
|
|
126
|
+
if cls_lower.startswith("live.") and n >= 3:
|
|
127
|
+
tags.append("rich-ui")
|
|
128
|
+
break
|
|
129
|
+
if "gen~" in obj_classes or "rnbo~" in obj_classes:
|
|
130
|
+
tags.append("dsp-graph")
|
|
131
|
+
if any(c.startswith("js") or c.startswith("v8") for c in obj_classes):
|
|
132
|
+
tags.append("script-driven")
|
|
133
|
+
|
|
134
|
+
return tags
|
|
135
|
+
|
|
136
|
+
def derive_description(self, sidecar: dict) -> str:
|
|
137
|
+
n_params = len(sidecar.get("exposed_parameters") or [])
|
|
138
|
+
dev_type = sidecar.get("device_type") or "unknown"
|
|
139
|
+
max_version = sidecar.get("max_version") or "?"
|
|
140
|
+
return f"Max {dev_type} device, Max v{max_version}, {n_params} exposed params"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ─── Parsing ─────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# The ampf header carries device type as an ASCII letter at offset 8:
|
|
147
|
+
# 'a' (97) = audio effect
|
|
148
|
+
# 'i' (105) = instrument
|
|
149
|
+
# 'm' (109) = MIDI effect
|
|
150
|
+
# Earlier guess of 0/1/2 was wrong — verified against the user's 393-file
|
|
151
|
+
# real-world .amxd corpus where 388/393 devices reported as unknown-{97,109,105}.
|
|
152
|
+
_AMPF_DEVICE_TYPE_BYTE = 8
|
|
153
|
+
_DEVICE_TYPE_MAP = {
|
|
154
|
+
ord("a"): "audio",
|
|
155
|
+
ord("i"): "instrument",
|
|
156
|
+
ord("m"): "midi",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_amxd(raw: bytes, filename: str) -> dict:
|
|
161
|
+
"""Best-effort .amxd metadata extractor.
|
|
162
|
+
|
|
163
|
+
Never raises on malformed files — returns whatever we can recover with
|
|
164
|
+
nulls for missing fields.
|
|
165
|
+
"""
|
|
166
|
+
out: dict[str, Any] = {
|
|
167
|
+
"name": filename.removesuffix(".amxd"),
|
|
168
|
+
"device_type": None,
|
|
169
|
+
"max_version": None,
|
|
170
|
+
"exposed_parameters": [],
|
|
171
|
+
"embedded_scripts": [],
|
|
172
|
+
"livepilot_ping_version": None,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# 1. Device type from the ampf header
|
|
176
|
+
if len(raw) > 24 and raw[:4] == b"ampf":
|
|
177
|
+
try:
|
|
178
|
+
tb = raw[_AMPF_DEVICE_TYPE_BYTE]
|
|
179
|
+
out["device_type"] = _DEVICE_TYPE_MAP.get(tb, f"unknown-{tb}")
|
|
180
|
+
except IndexError:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
# 2. Locate + parse the JSON patcher block
|
|
184
|
+
json_blob = _extract_patcher_json(raw)
|
|
185
|
+
if json_blob:
|
|
186
|
+
try:
|
|
187
|
+
patcher = json.loads(json_blob)
|
|
188
|
+
if isinstance(patcher, dict):
|
|
189
|
+
out.update(_extract_from_patcher(patcher))
|
|
190
|
+
except json.JSONDecodeError:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
# 3. LivePilot-style ping version
|
|
194
|
+
m = re.search(rb'var\s+VERSION\s*=\s*"([0-9]+\.[0-9]+\.[0-9]+)"', raw)
|
|
195
|
+
if m:
|
|
196
|
+
out["livepilot_ping_version"] = m.group(1).decode("ascii", errors="ignore")
|
|
197
|
+
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _extract_patcher_json(raw: bytes) -> bytes | None:
|
|
202
|
+
"""Find the embedded patcher JSON in a .amxd binary.
|
|
203
|
+
|
|
204
|
+
Strategy: find the first occurrence of `{"patcher"` (whitespace-tolerant
|
|
205
|
+
via a small loop), then walk braces with depth-counting until we hit
|
|
206
|
+
matching close. Conservative — handles strings + escapes.
|
|
207
|
+
"""
|
|
208
|
+
needle = re.search(rb'\{[\s\r\n]*"patcher"', raw)
|
|
209
|
+
if not needle:
|
|
210
|
+
return None
|
|
211
|
+
start = needle.start()
|
|
212
|
+
depth = 0
|
|
213
|
+
in_str = False
|
|
214
|
+
esc = False
|
|
215
|
+
i = start
|
|
216
|
+
while i < len(raw):
|
|
217
|
+
ch = raw[i]
|
|
218
|
+
if esc:
|
|
219
|
+
esc = False
|
|
220
|
+
elif ch == 0x5C and in_str: # backslash
|
|
221
|
+
esc = True
|
|
222
|
+
elif ch == 0x22: # double-quote
|
|
223
|
+
in_str = not in_str
|
|
224
|
+
elif not in_str:
|
|
225
|
+
if ch == 0x7B: # {
|
|
226
|
+
depth += 1
|
|
227
|
+
elif ch == 0x7D: # }
|
|
228
|
+
depth -= 1
|
|
229
|
+
if depth == 0:
|
|
230
|
+
return raw[start:i + 1]
|
|
231
|
+
i += 1
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _extract_from_patcher(patcher: dict) -> dict:
|
|
236
|
+
"""Pull useful fields out of the patcher JSON.
|
|
237
|
+
|
|
238
|
+
Walks the patcher's box graph (recursively into subpatchers) and harvests:
|
|
239
|
+
- max_version (from appversion)
|
|
240
|
+
- exposed_parameters (live.dial / live.numbox / live.tab / etc.)
|
|
241
|
+
- embedded_scripts (js / jsui filenames)
|
|
242
|
+
- parameter_longnames (full Live-exposed parameter labels)
|
|
243
|
+
- parameter_shortnames (short labels, often producer-meaningful)
|
|
244
|
+
- varnames (author-assigned variable names)
|
|
245
|
+
- annotations (human-written box descriptions)
|
|
246
|
+
- subpatcher_names (`p <name>` boxes — common organizational hint)
|
|
247
|
+
- object_classes (count of each maxclass — device-shape signal)
|
|
248
|
+
|
|
249
|
+
The keyword_corpus field at the bottom is the concatenation of all
|
|
250
|
+
human-readable text we found (longnames + shortnames + varnames +
|
|
251
|
+
annotations + subpatcher names) lower-cased — derive_tags scans that
|
|
252
|
+
against the producer vocabulary.
|
|
253
|
+
"""
|
|
254
|
+
info = patcher.get("patcher") if "patcher" in patcher else patcher
|
|
255
|
+
if not isinstance(info, dict):
|
|
256
|
+
return {}
|
|
257
|
+
out: dict[str, Any] = {}
|
|
258
|
+
|
|
259
|
+
appversion = info.get("appversion")
|
|
260
|
+
if isinstance(appversion, dict):
|
|
261
|
+
major = appversion.get("major")
|
|
262
|
+
minor = appversion.get("minor")
|
|
263
|
+
if major is not None and minor is not None:
|
|
264
|
+
out["max_version"] = f"{major}.{minor}"
|
|
265
|
+
|
|
266
|
+
state = {
|
|
267
|
+
"scripts": [],
|
|
268
|
+
"params": [], # parameter_longnames (Live-exposed)
|
|
269
|
+
"shortnames": [],
|
|
270
|
+
"varnames": [],
|
|
271
|
+
"annotations": [],
|
|
272
|
+
"subpatcher_names": [],
|
|
273
|
+
"maxclasses": {}, # class → count
|
|
274
|
+
}
|
|
275
|
+
_walk_patcher_boxes(info, state, depth=0)
|
|
276
|
+
|
|
277
|
+
if state["params"]:
|
|
278
|
+
out["exposed_parameters"] = state["params"][:64]
|
|
279
|
+
if state["shortnames"]:
|
|
280
|
+
out["parameter_shortnames"] = state["shortnames"][:64]
|
|
281
|
+
if state["varnames"]:
|
|
282
|
+
out["varnames"] = state["varnames"][:64]
|
|
283
|
+
if state["annotations"]:
|
|
284
|
+
out["annotations"] = state["annotations"][:32]
|
|
285
|
+
if state["subpatcher_names"]:
|
|
286
|
+
out["subpatcher_names"] = state["subpatcher_names"][:32]
|
|
287
|
+
if state["scripts"]:
|
|
288
|
+
out["embedded_scripts"] = state["scripts"][:32]
|
|
289
|
+
if state["maxclasses"]:
|
|
290
|
+
out["object_classes"] = dict(sorted(
|
|
291
|
+
state["maxclasses"].items(), key=lambda kv: -kv[1])[:16])
|
|
292
|
+
|
|
293
|
+
# The corpus of human-readable strings derive_tags() scans against
|
|
294
|
+
# producer-vocabulary keywords. Lower-cased + space-joined for cheap
|
|
295
|
+
# substring scanning.
|
|
296
|
+
keyword_blob_parts: list[str] = []
|
|
297
|
+
keyword_blob_parts.extend(state["params"])
|
|
298
|
+
keyword_blob_parts.extend(state["shortnames"])
|
|
299
|
+
keyword_blob_parts.extend(state["varnames"])
|
|
300
|
+
keyword_blob_parts.extend(state["annotations"])
|
|
301
|
+
keyword_blob_parts.extend(state["subpatcher_names"])
|
|
302
|
+
if keyword_blob_parts:
|
|
303
|
+
# 8KB cap — devices with massive parameter lists won't blow up the sidecar
|
|
304
|
+
out["keyword_corpus"] = " ".join(keyword_blob_parts).lower()[:8192]
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _walk_patcher_boxes(info: dict, state: dict, depth: int) -> None:
|
|
309
|
+
"""Recurse through patcher.boxes — including subpatcher nodes — to collect signals.
|
|
310
|
+
|
|
311
|
+
Cap depth at 4 to avoid pathological deeply-nested device graphs.
|
|
312
|
+
"""
|
|
313
|
+
if depth > 4 or not isinstance(info, dict):
|
|
314
|
+
return
|
|
315
|
+
for box in (info.get("boxes") or []):
|
|
316
|
+
if not isinstance(box, dict):
|
|
317
|
+
continue
|
|
318
|
+
b = box.get("box")
|
|
319
|
+
if not isinstance(b, dict):
|
|
320
|
+
continue
|
|
321
|
+
maxclass = (b.get("maxclass") or "").strip()
|
|
322
|
+
if maxclass:
|
|
323
|
+
state["maxclasses"][maxclass] = state["maxclasses"].get(maxclass, 0) + 1
|
|
324
|
+
text = b.get("text", "") or ""
|
|
325
|
+
|
|
326
|
+
# Live-exposed UI elements — these are the dials/buttons producers see
|
|
327
|
+
if isinstance(text, str) and ("live." in text or maxclass.startswith("live.")):
|
|
328
|
+
longname = b.get("parameter_longname") or b.get("varname")
|
|
329
|
+
if longname and longname not in state["params"]:
|
|
330
|
+
state["params"].append(longname)
|
|
331
|
+
shortname = b.get("parameter_shortname")
|
|
332
|
+
if shortname and shortname not in state["shortnames"]:
|
|
333
|
+
state["shortnames"].append(shortname)
|
|
334
|
+
|
|
335
|
+
# Author varnames (whether or not the box is live.*)
|
|
336
|
+
varname = b.get("varname")
|
|
337
|
+
if varname and varname not in state["varnames"]:
|
|
338
|
+
state["varnames"].append(varname)
|
|
339
|
+
|
|
340
|
+
# Human-readable annotations (box.annotation or box.hint)
|
|
341
|
+
for field in ("annotation", "hint", "comment"):
|
|
342
|
+
anno = b.get(field)
|
|
343
|
+
if isinstance(anno, str) and len(anno) >= 4:
|
|
344
|
+
if anno not in state["annotations"]:
|
|
345
|
+
state["annotations"].append(anno)
|
|
346
|
+
|
|
347
|
+
# Comment boxes — pure prose descriptions ("Section: Filters")
|
|
348
|
+
if maxclass == "comment" and isinstance(text, str) and len(text) >= 4:
|
|
349
|
+
if text not in state["annotations"]:
|
|
350
|
+
state["annotations"].append(text)
|
|
351
|
+
|
|
352
|
+
# Subpatcher boxes — text starts with "p <name>"
|
|
353
|
+
if isinstance(text, str) and text.startswith("p "):
|
|
354
|
+
sub_name = text[2:].strip()
|
|
355
|
+
if sub_name and sub_name not in state["subpatcher_names"]:
|
|
356
|
+
state["subpatcher_names"].append(sub_name)
|
|
357
|
+
|
|
358
|
+
# Embedded JS / JSUI scripts
|
|
359
|
+
if isinstance(text, str):
|
|
360
|
+
for prefix in ("js ", "jsui ", "v8 ", "v8ui "):
|
|
361
|
+
if text.startswith(prefix):
|
|
362
|
+
parts = text.split()
|
|
363
|
+
if len(parts) > 1:
|
|
364
|
+
state["scripts"].append(parts[1])
|
|
365
|
+
break
|
|
366
|
+
|
|
367
|
+
# Recurse into the subpatcher's own boxes
|
|
368
|
+
sub = b.get("patcher")
|
|
369
|
+
if isinstance(sub, dict):
|
|
370
|
+
_walk_patcher_boxes(sub, state, depth + 1)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _slug(s: str) -> str:
|
|
374
|
+
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Plugin preset scanner — captures identity metadata from VST/VST3/AU/NKS preset files.
|
|
2
|
+
|
|
3
|
+
Param VALUES are plugin-specific binary blobs and stay opaque (same constraint
|
|
4
|
+
as PluginDevice in .als). What we extract:
|
|
5
|
+
- .aupreset: plist key/value (manufacturer, name, type)
|
|
6
|
+
- .vstpreset: VST3 preset header (class id, plugin name)
|
|
7
|
+
- .fxp/.fxb: VST2 chunk headers (manufacturer + plugin code)
|
|
8
|
+
- .nksf: Native Instruments preset (NI metadata via JSON sidecar block)
|
|
9
|
+
|
|
10
|
+
For unknown formats we still produce a usable wrapper from the file path
|
|
11
|
+
(plugin name often = parent folder, manufacturer = grandparent folder).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import plistlib
|
|
17
|
+
import re
|
|
18
|
+
import struct
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from ..scanner import Scanner, register_scanner
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@register_scanner
|
|
26
|
+
class PluginPresetScanner(Scanner):
|
|
27
|
+
type_id = "plugin-preset"
|
|
28
|
+
file_extensions = [".aupreset", ".vstpreset", ".fxp", ".fxb", ".nksf"]
|
|
29
|
+
output_subdir = "plugin_presets"
|
|
30
|
+
schema_version = 1
|
|
31
|
+
|
|
32
|
+
def scan_one(self, path: Path) -> dict:
|
|
33
|
+
ext = path.suffix.lower()
|
|
34
|
+
if ext == ".aupreset":
|
|
35
|
+
return _parse_aupreset(path)
|
|
36
|
+
if ext == ".vstpreset":
|
|
37
|
+
return _parse_vstpreset(path)
|
|
38
|
+
if ext in (".fxp", ".fxb"):
|
|
39
|
+
return _parse_vst2_chunk(path)
|
|
40
|
+
if ext == ".nksf":
|
|
41
|
+
return _parse_nksf(path)
|
|
42
|
+
return _fallback_metadata(path)
|
|
43
|
+
|
|
44
|
+
def derive_tags(self, sidecar: dict) -> list[str]:
|
|
45
|
+
tags = ["plugin-preset"]
|
|
46
|
+
fmt = sidecar.get("format")
|
|
47
|
+
if fmt:
|
|
48
|
+
tags.append(fmt.lower())
|
|
49
|
+
man = sidecar.get("manufacturer")
|
|
50
|
+
if man:
|
|
51
|
+
tags.append(f"vendor:{_slug(man)}")
|
|
52
|
+
plug = sidecar.get("plugin_name")
|
|
53
|
+
if plug:
|
|
54
|
+
tags.append(f"plugin:{_slug(plug)}")
|
|
55
|
+
return tags
|
|
56
|
+
|
|
57
|
+
def derive_description(self, sidecar: dict) -> str:
|
|
58
|
+
fmt = sidecar.get("format") or "preset"
|
|
59
|
+
plug = sidecar.get("plugin_name") or "unknown plugin"
|
|
60
|
+
man = sidecar.get("manufacturer") or "unknown vendor"
|
|
61
|
+
name = sidecar.get("preset_name") or sidecar.get("name") or ""
|
|
62
|
+
suffix = f" — {name}" if name else ""
|
|
63
|
+
return f"{fmt} preset for {plug} ({man}){suffix}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ─── Format-specific parsers ─────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_aupreset(path: Path) -> dict:
|
|
70
|
+
"""AU preset = binary plist with metadata + opaque ParameterData blob."""
|
|
71
|
+
out: dict[str, Any] = {
|
|
72
|
+
"format": "AU",
|
|
73
|
+
"name": path.stem,
|
|
74
|
+
"preset_name": path.stem,
|
|
75
|
+
"plugin_name": None,
|
|
76
|
+
"manufacturer": None,
|
|
77
|
+
}
|
|
78
|
+
try:
|
|
79
|
+
with path.open("rb") as fh:
|
|
80
|
+
plist = plistlib.load(fh)
|
|
81
|
+
if isinstance(plist, dict):
|
|
82
|
+
out["plugin_name"] = plist.get("name") or plist.get("manufacturer-name")
|
|
83
|
+
out["manufacturer"] = _decode_au_id(plist.get("manufacturer"))
|
|
84
|
+
out["subtype"] = _decode_au_id(plist.get("subtype"))
|
|
85
|
+
out["type_code"] = _decode_au_id(plist.get("type"))
|
|
86
|
+
if plist.get("name"):
|
|
87
|
+
out["preset_name"] = str(plist["name"])
|
|
88
|
+
except Exception: # noqa: BLE001
|
|
89
|
+
pass
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_vstpreset(path: Path) -> dict:
|
|
94
|
+
"""VST3 .vstpreset has a binary header containing the plugin's class UID."""
|
|
95
|
+
out: dict[str, Any] = {
|
|
96
|
+
"format": "VST3",
|
|
97
|
+
"name": path.stem,
|
|
98
|
+
"preset_name": path.stem,
|
|
99
|
+
"plugin_name": None,
|
|
100
|
+
"manufacturer": None,
|
|
101
|
+
}
|
|
102
|
+
try:
|
|
103
|
+
with path.open("rb") as fh:
|
|
104
|
+
# VST3 preset starts with "VST3" magic + version + class id
|
|
105
|
+
magic = fh.read(4)
|
|
106
|
+
if magic == b"VST3":
|
|
107
|
+
fh.read(4) # version
|
|
108
|
+
class_id = fh.read(32)
|
|
109
|
+
out["class_uid"] = class_id.hex() if class_id else None
|
|
110
|
+
# Plugin name often inferable from path: .../VST3 Presets/<vendor>/<plugin>/<preset>.vstpreset
|
|
111
|
+
parts = list(path.parts)
|
|
112
|
+
if len(parts) >= 3:
|
|
113
|
+
out["plugin_name"] = parts[-2]
|
|
114
|
+
out["manufacturer"] = parts[-3]
|
|
115
|
+
except Exception: # noqa: BLE001
|
|
116
|
+
pass
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parse_vst2_chunk(path: Path) -> dict:
|
|
121
|
+
"""VST2 .fxp/.fxb has a 'CcnK' chunk header with plugin code + name."""
|
|
122
|
+
out: dict[str, Any] = {
|
|
123
|
+
"format": "VST2",
|
|
124
|
+
"name": path.stem,
|
|
125
|
+
"preset_name": path.stem,
|
|
126
|
+
"plugin_name": None,
|
|
127
|
+
"manufacturer": None,
|
|
128
|
+
}
|
|
129
|
+
try:
|
|
130
|
+
with path.open("rb") as fh:
|
|
131
|
+
head = fh.read(60)
|
|
132
|
+
if head[:4] == b"CcnK":
|
|
133
|
+
# offset 16 (4 bytes) = plugin's unique fourCC
|
|
134
|
+
if len(head) >= 20:
|
|
135
|
+
out["plugin_code"] = head[16:20].decode("ascii", errors="ignore").strip()
|
|
136
|
+
# offset 28 (28-byte name) = preset/program name
|
|
137
|
+
if len(head) >= 60:
|
|
138
|
+
name_bytes = head[28:60].split(b"\x00", 1)[0]
|
|
139
|
+
out["preset_name"] = name_bytes.decode("latin-1", errors="ignore").strip() or path.stem
|
|
140
|
+
except Exception: # noqa: BLE001
|
|
141
|
+
pass
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_nksf(path: Path) -> dict:
|
|
146
|
+
"""NKS preset is a riff-like container; try to find metadata JSON block."""
|
|
147
|
+
out: dict[str, Any] = {
|
|
148
|
+
"format": "NKS",
|
|
149
|
+
"name": path.stem,
|
|
150
|
+
"preset_name": path.stem,
|
|
151
|
+
"plugin_name": None,
|
|
152
|
+
"manufacturer": None,
|
|
153
|
+
}
|
|
154
|
+
try:
|
|
155
|
+
raw = path.read_bytes()
|
|
156
|
+
m = re.search(rb'\{[^}]*"vendor"[^}]*\}', raw)
|
|
157
|
+
if m:
|
|
158
|
+
import json
|
|
159
|
+
try:
|
|
160
|
+
meta = json.loads(m.group(0))
|
|
161
|
+
if isinstance(meta, dict):
|
|
162
|
+
out["manufacturer"] = meta.get("vendor")
|
|
163
|
+
out["plugin_name"] = meta.get("product")
|
|
164
|
+
out["preset_name"] = meta.get("name") or path.stem
|
|
165
|
+
except json.JSONDecodeError:
|
|
166
|
+
pass
|
|
167
|
+
except Exception: # noqa: BLE001
|
|
168
|
+
pass
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _fallback_metadata(path: Path) -> dict:
|
|
173
|
+
"""When format-specific parsing fails, infer from filesystem layout."""
|
|
174
|
+
parts = list(path.parts)
|
|
175
|
+
return {
|
|
176
|
+
"format": "unknown",
|
|
177
|
+
"name": path.stem,
|
|
178
|
+
"preset_name": path.stem,
|
|
179
|
+
"plugin_name": parts[-2] if len(parts) >= 2 else None,
|
|
180
|
+
"manufacturer": parts[-3] if len(parts) >= 3 else None,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _decode_au_id(value: Any) -> str | None:
|
|
188
|
+
"""AU 4-char codes are stored as 32-bit big-endian ints. Decode → 'TDM!' etc."""
|
|
189
|
+
if value is None:
|
|
190
|
+
return None
|
|
191
|
+
if isinstance(value, str):
|
|
192
|
+
return value
|
|
193
|
+
if isinstance(value, int):
|
|
194
|
+
try:
|
|
195
|
+
return struct.pack(">I", value).decode("ascii", errors="ignore").strip()
|
|
196
|
+
except (ValueError, struct.error):
|
|
197
|
+
return str(value)
|
|
198
|
+
return str(value)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _slug(s: str) -> str:
|
|
202
|
+
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
|