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.
Files changed (46) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +108 -10
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +39 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/atlas/cross_pack_chain.py +658 -0
  7. package/mcp_server/atlas/demo_story.py +700 -0
  8. package/mcp_server/atlas/extract_chain.py +786 -0
  9. package/mcp_server/atlas/macro_fingerprint.py +554 -0
  10. package/mcp_server/atlas/overlays.py +95 -3
  11. package/mcp_server/atlas/pack_aware_compose.py +1255 -0
  12. package/mcp_server/atlas/preset_resolver.py +238 -0
  13. package/mcp_server/atlas/tools.py +1001 -31
  14. package/mcp_server/atlas/transplant.py +1177 -0
  15. package/mcp_server/mix_engine/state_builder.py +44 -1
  16. package/mcp_server/runtime/capability_state.py +34 -3
  17. package/mcp_server/runtime/remote_commands.py +10 -0
  18. package/mcp_server/server.py +45 -24
  19. package/mcp_server/tools/agent_os.py +33 -9
  20. package/mcp_server/tools/analyzer.py +84 -23
  21. package/mcp_server/tools/browser.py +20 -1
  22. package/mcp_server/tools/devices.py +78 -11
  23. package/mcp_server/tools/perception.py +5 -1
  24. package/mcp_server/tools/tracks.py +39 -2
  25. package/mcp_server/user_corpus/__init__.py +48 -0
  26. package/mcp_server/user_corpus/manifest.py +142 -0
  27. package/mcp_server/user_corpus/plugin_engine/__init__.py +39 -0
  28. package/mcp_server/user_corpus/plugin_engine/detector.py +579 -0
  29. package/mcp_server/user_corpus/plugin_engine/manual.py +347 -0
  30. package/mcp_server/user_corpus/plugin_engine/research.py +247 -0
  31. package/mcp_server/user_corpus/runner.py +261 -0
  32. package/mcp_server/user_corpus/scanner.py +115 -0
  33. package/mcp_server/user_corpus/scanners/__init__.py +18 -0
  34. package/mcp_server/user_corpus/scanners/adg.py +79 -0
  35. package/mcp_server/user_corpus/scanners/als.py +144 -0
  36. package/mcp_server/user_corpus/scanners/amxd.py +374 -0
  37. package/mcp_server/user_corpus/scanners/plugin_preset.py +202 -0
  38. package/mcp_server/user_corpus/tools.py +904 -0
  39. package/mcp_server/user_corpus/wizard.py +224 -0
  40. package/package.json +2 -2
  41. package/remote_script/LivePilot/__init__.py +1 -1
  42. package/remote_script/LivePilot/browser.py +7 -2
  43. package/remote_script/LivePilot/devices.py +9 -0
  44. package/remote_script/LivePilot/simpler_sample.py +98 -0
  45. package/requirements.txt +3 -3
  46. 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("-")