livepilot 1.23.3 → 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 +93 -0
- package/README.md +106 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -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/server.py +45 -24
- package/mcp_server/tools/agent_os.py +33 -9
- package/mcp_server/tools/analyzer.py +38 -7
- 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/requirements.txt +3 -3
- package/server.json +2 -2
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"""Phase 2.1 + 2.2 — Detect installed plugins on disk + extract identity.
|
|
2
|
+
|
|
3
|
+
Walks the OS-specific plugin folders and produces an inventory entry per
|
|
4
|
+
plugin. Identity extraction:
|
|
5
|
+
- VST3: parse Contents/Resources/moduleinfo.json (mandatory per VST3 SDK 3.7+)
|
|
6
|
+
- AU v2: parse .component bundle's Contents/Info.plist
|
|
7
|
+
- AU v3: enumerate via `auval -a` (catches iOS-ported Mac Catalyst apps
|
|
8
|
+
whose .appex extensions live INSIDE app bundles at non-standard
|
|
9
|
+
paths like /Applications/IOS audio/<App>.app/...). The auval-based
|
|
10
|
+
scanner replaces the path-walker for AU on macOS — captures both
|
|
11
|
+
v2 and v3 in one pass with manufacturer + name resolved.
|
|
12
|
+
- VST2: read CcnK chunk header from the binary
|
|
13
|
+
|
|
14
|
+
Never raises on a malformed bundle — logs + skips. On macOS, plugin "files"
|
|
15
|
+
are actually directory bundles (`.vst3`, `.component`, `.vst`, `.aaxplugin`).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import platform
|
|
23
|
+
import plistlib
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import struct
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
from dataclasses import dataclass, asdict
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ─── Default search paths per OS ────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _macos_plugin_dirs() -> list[tuple[Path, str]]:
|
|
40
|
+
home = Path.home()
|
|
41
|
+
return [
|
|
42
|
+
(Path("/Library/Audio/Plug-Ins/VST3"), "VST3"),
|
|
43
|
+
(home / "Library/Audio/Plug-Ins/VST3", "VST3"),
|
|
44
|
+
(Path("/Library/Audio/Plug-Ins/Components"), "AU"),
|
|
45
|
+
(home / "Library/Audio/Plug-Ins/Components", "AU"),
|
|
46
|
+
(Path("/Library/Audio/Plug-Ins/VST"), "VST2"),
|
|
47
|
+
(home / "Library/Audio/Plug-Ins/VST", "VST2"),
|
|
48
|
+
(Path("/Library/Application Support/Avid/Audio/Plug-Ins"), "AAX"),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _windows_plugin_dirs() -> list[tuple[Path, str]]:
|
|
53
|
+
program_files = Path("C:/Program Files")
|
|
54
|
+
return [
|
|
55
|
+
(program_files / "Common Files/VST3", "VST3"),
|
|
56
|
+
(program_files / "VstPlugins", "VST2"),
|
|
57
|
+
(program_files / "Common Files/Avid/Audio/Plug-Ins", "AAX"),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _linux_plugin_dirs() -> list[tuple[Path, str]]:
|
|
62
|
+
home = Path.home()
|
|
63
|
+
return [
|
|
64
|
+
(Path("/usr/lib/vst3"), "VST3"),
|
|
65
|
+
(Path("/usr/local/lib/vst3"), "VST3"),
|
|
66
|
+
(home / ".vst3", "VST3"),
|
|
67
|
+
(Path("/usr/lib/lv2"), "LV2"),
|
|
68
|
+
(Path("/usr/local/lib/lv2"), "LV2"),
|
|
69
|
+
(home / ".lv2", "LV2"),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def default_plugin_dir() -> list[tuple[Path, str]]:
|
|
74
|
+
"""Return the default OS-appropriate plugin search paths.
|
|
75
|
+
|
|
76
|
+
Each entry is (Path, format_label). Caller can extend or override.
|
|
77
|
+
"""
|
|
78
|
+
sysname = platform.system()
|
|
79
|
+
if sysname == "Darwin":
|
|
80
|
+
return _macos_plugin_dirs()
|
|
81
|
+
if sysname == "Windows":
|
|
82
|
+
return _windows_plugin_dirs()
|
|
83
|
+
return _linux_plugin_dirs()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ─── Detected-plugin record ─────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class DetectedPlugin:
|
|
91
|
+
plugin_id: str # stable slug: "<vendor-slug>-<plugin-slug>"
|
|
92
|
+
name: str
|
|
93
|
+
vendor: str | None
|
|
94
|
+
format: str # VST3 / AU / VST2 / AAX / LV2
|
|
95
|
+
version: str | None
|
|
96
|
+
bundle_path: str
|
|
97
|
+
unique_id: str | None # CID / AU subtype / VST plugin code
|
|
98
|
+
file_size_kb: int | None = None
|
|
99
|
+
sdk_metadata: dict[str, Any] | None = None # raw moduleinfo / Info.plist
|
|
100
|
+
|
|
101
|
+
def to_dict(self) -> dict:
|
|
102
|
+
return asdict(self)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ─── Top-level entry ────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def detect_installed_plugins(
|
|
109
|
+
paths: list[tuple[Path, str]] | None = None,
|
|
110
|
+
formats: list[str] | None = None,
|
|
111
|
+
use_auval: bool = True,
|
|
112
|
+
) -> list[DetectedPlugin]:
|
|
113
|
+
"""Walk plugin folders + enumerate AUs via auval. Return all detected plugins.
|
|
114
|
+
|
|
115
|
+
On macOS, this combines two strategies:
|
|
116
|
+
- Path-based scanning of /Library/Audio/Plug-Ins/{VST3,VST,Components}
|
|
117
|
+
and the AAX folder — catches v2 AUs and all VST/AAX bundles.
|
|
118
|
+
- `auval -a` enumeration — catches AUv3 plugins whose .appex extensions
|
|
119
|
+
live inside arbitrary .app bundles (iOS-ported Mac Catalyst apps,
|
|
120
|
+
custom subdirectories like /Applications/IOS audio/, etc.).
|
|
121
|
+
|
|
122
|
+
Both passes are deduplicated by (vendor, name, format).
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
paths : explicit (Path, format) pairs to scan. Defaults to OS-appropriate.
|
|
127
|
+
formats : restrict to these formats (e.g. ["VST3", "AU"]). Default: all.
|
|
128
|
+
use_auval : when True (default) and on macOS, run `auval -a` as the AU
|
|
129
|
+
source of truth — much more reliable than path-walking. Set to
|
|
130
|
+
False to fall back to path-only scanning (mostly for tests
|
|
131
|
+
that don't want subprocess calls).
|
|
132
|
+
"""
|
|
133
|
+
if paths is None:
|
|
134
|
+
paths = default_plugin_dir()
|
|
135
|
+
if formats:
|
|
136
|
+
paths = [(p, f) for p, f in paths if f in formats]
|
|
137
|
+
|
|
138
|
+
results: list[DetectedPlugin] = []
|
|
139
|
+
seen_keys: set[tuple[str, str, str]] = set() # (vendor_lc, name_lc, format)
|
|
140
|
+
|
|
141
|
+
# 1. Path-based scan — covers VST3 / VST2 / AAX / LV2 + co-located AU v2
|
|
142
|
+
for root, fmt in paths:
|
|
143
|
+
if not root.exists():
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
for entry in sorted(root.iterdir()):
|
|
147
|
+
plugin = _identify_plugin(entry, fmt)
|
|
148
|
+
if plugin:
|
|
149
|
+
key = ((plugin.vendor or "").lower(), plugin.name.lower(), plugin.format)
|
|
150
|
+
if key not in seen_keys:
|
|
151
|
+
seen_keys.add(key)
|
|
152
|
+
results.append(plugin)
|
|
153
|
+
except (PermissionError, OSError) as e:
|
|
154
|
+
logger.warning("Cannot read %s: %s", root, e)
|
|
155
|
+
|
|
156
|
+
# 2. auval-based AU enumeration — captures AUv3 + arbitrary-location AUs
|
|
157
|
+
want_au = formats is None or "AU" in (formats or [])
|
|
158
|
+
if use_auval and want_au and platform.system() == "Darwin":
|
|
159
|
+
for plugin in _detect_via_auval():
|
|
160
|
+
key = ((plugin.vendor or "").lower(), plugin.name.lower(), plugin.format)
|
|
161
|
+
if key not in seen_keys:
|
|
162
|
+
seen_keys.add(key)
|
|
163
|
+
results.append(plugin)
|
|
164
|
+
|
|
165
|
+
return results
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _detect_via_auval() -> list[DetectedPlugin]:
|
|
169
|
+
"""Run macOS's `auval -a` and parse its output into DetectedPlugin records.
|
|
170
|
+
|
|
171
|
+
auval is the system-shipped Audio Unit validator. `-a` enumerates every
|
|
172
|
+
AU registered with the system (both v2 and v3, including those packaged
|
|
173
|
+
as app extensions inside arbitrary .app bundles). This is the only
|
|
174
|
+
reliable way to find iOS-ported Mac Catalyst plugins that don't live
|
|
175
|
+
in /Library/Audio/Plug-Ins/Components/.
|
|
176
|
+
|
|
177
|
+
Output line format:
|
|
178
|
+
<typecode> <subtype> <manufacturer> - <vendor>: <plugin name>
|
|
179
|
+
|
|
180
|
+
Where:
|
|
181
|
+
typecode 'aufx'/'aumu'/'aumi' (effect/instrument/midi)
|
|
182
|
+
subtype 4-char AU subtype code
|
|
183
|
+
manufacturer 4-char manufacturer code (e.g. "Moog", "Chow")
|
|
184
|
+
vendor human-readable vendor string
|
|
185
|
+
plugin name human-readable plugin name
|
|
186
|
+
|
|
187
|
+
Returns empty list on any failure (auval missing, subprocess error,
|
|
188
|
+
parse miss). Never raises.
|
|
189
|
+
"""
|
|
190
|
+
auval_bin = shutil.which("auval")
|
|
191
|
+
if not auval_bin:
|
|
192
|
+
return []
|
|
193
|
+
try:
|
|
194
|
+
proc = subprocess.run(
|
|
195
|
+
[auval_bin, "-a"],
|
|
196
|
+
capture_output=True, text=True, timeout=120,
|
|
197
|
+
check=False,
|
|
198
|
+
)
|
|
199
|
+
except (subprocess.SubprocessError, OSError) as e:
|
|
200
|
+
logger.warning("auval -a failed: %s", e)
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
plugins: list[DetectedPlugin] = []
|
|
204
|
+
line_re = re.compile(
|
|
205
|
+
r"^(?P<typecode>aufx|aumu|aumi)\s+"
|
|
206
|
+
r"(?P<subtype>\S{4})\s+"
|
|
207
|
+
r"(?P<manuf>\S{4})\s*-\s*"
|
|
208
|
+
r"(?P<vendor>[^:]+):\s*"
|
|
209
|
+
r"(?P<name>.+?)$",
|
|
210
|
+
)
|
|
211
|
+
for raw_line in proc.stdout.splitlines():
|
|
212
|
+
line = raw_line.strip()
|
|
213
|
+
if not line:
|
|
214
|
+
continue
|
|
215
|
+
m = line_re.match(line)
|
|
216
|
+
if not m:
|
|
217
|
+
continue
|
|
218
|
+
typecode = m.group("typecode")
|
|
219
|
+
subtype = m.group("subtype")
|
|
220
|
+
manuf_code = m.group("manuf")
|
|
221
|
+
vendor = m.group("vendor").strip()
|
|
222
|
+
name = m.group("name").strip()
|
|
223
|
+
|
|
224
|
+
# Skip "*deprecated" markers — Apple keeps legacy version registered
|
|
225
|
+
# alongside the current one. The current entry comes through too.
|
|
226
|
+
if "*deprecated" in name.lower():
|
|
227
|
+
continue
|
|
228
|
+
# Skip Apple's system AUs — they're system utility (AUDelay, AUFilter,
|
|
229
|
+
# AUDistortion, etc.) that the user never thinks of as third-party
|
|
230
|
+
# plugins. Add them back if you want comprehensive system-AU coverage.
|
|
231
|
+
if vendor.lower() == "apple" and manuf_code == "appl":
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
# Determine format via category heuristic. Most non-Apple AUs registered
|
|
235
|
+
# on modern macOS are AUv3 (the only path forward since 10.13). True v2
|
|
236
|
+
# plugins ALSO show up in this list because v2 is a registered AU type.
|
|
237
|
+
# We can't distinguish v2 from v3 without inspecting the plugin's
|
|
238
|
+
# executable architecture — so we tag all as "AU" and let downstream
|
|
239
|
+
# consumers infer specifics from bundle_path when available.
|
|
240
|
+
au_kind = {
|
|
241
|
+
"aufx": "audio_effect",
|
|
242
|
+
"aumu": "instrument",
|
|
243
|
+
"aumi": "midi_effect",
|
|
244
|
+
}.get(typecode, "unknown")
|
|
245
|
+
|
|
246
|
+
plugin_id = _slug(f"{vendor}-{name}")
|
|
247
|
+
unique_id = f"{typecode}.{subtype}.{manuf_code}"
|
|
248
|
+
plugins.append(DetectedPlugin(
|
|
249
|
+
plugin_id=plugin_id,
|
|
250
|
+
name=name,
|
|
251
|
+
vendor=vendor,
|
|
252
|
+
format="AU",
|
|
253
|
+
version=None, # auval doesn't surface version
|
|
254
|
+
bundle_path="", # not available from auval; resolve later if needed
|
|
255
|
+
unique_id=unique_id,
|
|
256
|
+
file_size_kb=None,
|
|
257
|
+
sdk_metadata={
|
|
258
|
+
"auval_typecode": typecode,
|
|
259
|
+
"auval_subtype": subtype,
|
|
260
|
+
"auval_manufacturer_code": manuf_code,
|
|
261
|
+
"au_role": au_kind, # audio_effect / instrument / midi_effect
|
|
262
|
+
},
|
|
263
|
+
))
|
|
264
|
+
return plugins
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _identify_plugin(path: Path, fmt: str) -> DetectedPlugin | None:
|
|
268
|
+
"""Dispatch to the format-specific identity extractor."""
|
|
269
|
+
if not path.exists():
|
|
270
|
+
return None
|
|
271
|
+
try:
|
|
272
|
+
if fmt == "VST3" and path.suffix == ".vst3":
|
|
273
|
+
return _identify_vst3(path)
|
|
274
|
+
if fmt == "AU" and path.suffix == ".component":
|
|
275
|
+
return _identify_au(path)
|
|
276
|
+
if fmt == "VST2" and (path.suffix == ".vst" or path.suffix == ".dylib"):
|
|
277
|
+
return _identify_vst2(path)
|
|
278
|
+
if fmt == "AAX" and path.suffix == ".aaxplugin":
|
|
279
|
+
return _identify_aax(path)
|
|
280
|
+
if fmt == "LV2" and path.is_dir():
|
|
281
|
+
return _identify_lv2(path)
|
|
282
|
+
except Exception as e: # noqa: BLE001 — never abort over one bad bundle
|
|
283
|
+
logger.warning("Identity extraction failed for %s: %s", path, e)
|
|
284
|
+
# If a format-specific parse failed, still emit a fallback record so the
|
|
285
|
+
# plugin shows up in the inventory with whatever we can derive from the path
|
|
286
|
+
return _fallback_identity(path, fmt)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ─── VST3 ───────────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _identify_vst3(bundle: Path) -> DetectedPlugin | None:
|
|
293
|
+
info_path = bundle / "Contents" / "Resources" / "moduleinfo.json"
|
|
294
|
+
plist_path = bundle / "Contents" / "Info.plist"
|
|
295
|
+
name = bundle.stem
|
|
296
|
+
vendor: str | None = None
|
|
297
|
+
version: str | None = None
|
|
298
|
+
unique_id: str | None = None
|
|
299
|
+
sdk: dict[str, Any] | None = None
|
|
300
|
+
if info_path.exists():
|
|
301
|
+
try:
|
|
302
|
+
sdk = json.loads(info_path.read_text(encoding="utf-8"))
|
|
303
|
+
if isinstance(sdk, dict):
|
|
304
|
+
name = sdk.get("Name") or name
|
|
305
|
+
vendor = sdk.get("Vendor")
|
|
306
|
+
version = sdk.get("Version")
|
|
307
|
+
classes = sdk.get("Classes") or []
|
|
308
|
+
if classes and isinstance(classes[0], dict):
|
|
309
|
+
unique_id = classes[0].get("CID")
|
|
310
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
311
|
+
logger.debug("VST3 moduleinfo.json unreadable for %s: %s", bundle, e)
|
|
312
|
+
# Pre-3.7 VST3 bundles don't have moduleinfo.json; fall back to Info.plist
|
|
313
|
+
if not vendor and plist_path.exists():
|
|
314
|
+
try:
|
|
315
|
+
with plist_path.open("rb") as fh:
|
|
316
|
+
plist = plistlib.load(fh)
|
|
317
|
+
bundle_id = plist.get("CFBundleIdentifier") or ""
|
|
318
|
+
copyright_text = plist.get("NSHumanReadableCopyright") or ""
|
|
319
|
+
if not version:
|
|
320
|
+
version = plist.get("CFBundleShortVersionString")
|
|
321
|
+
if bundle_id:
|
|
322
|
+
parts = bundle_id.split(".")
|
|
323
|
+
if len(parts) >= 2 and parts[0].lower() in ("com", "net", "org", "io", "co"):
|
|
324
|
+
vendor = parts[1].title()
|
|
325
|
+
if not vendor and copyright_text:
|
|
326
|
+
m = re.search(r"\b(?:19|20)\d{2}\s+(?:by\s+)?([A-Za-z][\w&.\- ]{1,40})", copyright_text)
|
|
327
|
+
if m:
|
|
328
|
+
vendor = m.group(1).strip().rstrip(".,;:")
|
|
329
|
+
if sdk is None:
|
|
330
|
+
sdk = {}
|
|
331
|
+
sdk.setdefault("bundle_identifier", bundle_id)
|
|
332
|
+
except (plistlib.InvalidFileException, OSError) as e:
|
|
333
|
+
logger.debug("VST3 Info.plist unreadable for %s: %s", bundle, e)
|
|
334
|
+
plugin_id = _slug(f"{vendor or 'unknown'}-{name}")
|
|
335
|
+
return DetectedPlugin(
|
|
336
|
+
plugin_id=plugin_id, name=name, vendor=vendor, format="VST3",
|
|
337
|
+
version=version, bundle_path=str(bundle), unique_id=unique_id,
|
|
338
|
+
file_size_kb=_bundle_size_kb(bundle),
|
|
339
|
+
sdk_metadata=sdk,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ─── AU ─────────────────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _identify_au(bundle: Path) -> DetectedPlugin | None:
|
|
347
|
+
info_path = bundle / "Contents" / "Info.plist"
|
|
348
|
+
bundle_name = bundle.stem
|
|
349
|
+
name = bundle_name
|
|
350
|
+
vendor: str | None = None
|
|
351
|
+
version: str | None = None
|
|
352
|
+
unique_id: str | None = None
|
|
353
|
+
sdk: dict[str, Any] | None = None
|
|
354
|
+
manufacturer_code: str | None = None
|
|
355
|
+
if info_path.exists():
|
|
356
|
+
try:
|
|
357
|
+
with info_path.open("rb") as fh:
|
|
358
|
+
plist = plistlib.load(fh)
|
|
359
|
+
if isinstance(plist, dict):
|
|
360
|
+
sdk = _strip_unjsonable(plist)
|
|
361
|
+
version = plist.get("CFBundleShortVersionString")
|
|
362
|
+
bundle_id = plist.get("CFBundleIdentifier") or ""
|
|
363
|
+
copyright_text = plist.get("NSHumanReadableCopyright") or ""
|
|
364
|
+
|
|
365
|
+
comps = plist.get("AudioComponents") or []
|
|
366
|
+
comp_name_raw: str = ""
|
|
367
|
+
if comps and isinstance(comps[0], dict):
|
|
368
|
+
c0 = comps[0]
|
|
369
|
+
comp_name_raw = (c0.get("name") or "")
|
|
370
|
+
manufacturer_code = _decode_au_id(c0.get("manufacturer"))
|
|
371
|
+
unique_id = _decode_au_id(c0.get("subtype"))
|
|
372
|
+
|
|
373
|
+
# Vendor + plugin name resolution — priority order:
|
|
374
|
+
# 1. AudioComponents.name "Vendor: Plugin" → split
|
|
375
|
+
# 2. Reverse-DNS from CFBundleIdentifier (com.<vendor>.<plugin>)
|
|
376
|
+
# 3. Plain prose copyright string
|
|
377
|
+
# 4. Fall back to 4-char manufacturer code as last resort
|
|
378
|
+
if comp_name_raw and ":" in comp_name_raw:
|
|
379
|
+
v, n = (s.strip() for s in comp_name_raw.split(":", 1))
|
|
380
|
+
if v:
|
|
381
|
+
vendor = v
|
|
382
|
+
if n:
|
|
383
|
+
name = n
|
|
384
|
+
if not name and comp_name_raw:
|
|
385
|
+
name = comp_name_raw
|
|
386
|
+
if not name:
|
|
387
|
+
name = plist.get("CFBundleName") or bundle_name
|
|
388
|
+
|
|
389
|
+
if not vendor and bundle_id:
|
|
390
|
+
parts = bundle_id.split(".")
|
|
391
|
+
if len(parts) >= 2 and parts[0].lower() in ("com", "net", "org", "io", "co"):
|
|
392
|
+
vendor = parts[1].title() # com.spectrasonics.X → "Spectrasonics"
|
|
393
|
+
if not vendor and copyright_text:
|
|
394
|
+
# "Copyright © 2024 u-he" → "u-he"; best-effort word grab after the year
|
|
395
|
+
m = re.search(r"\b(?:19|20)\d{2}\s+(?:by\s+)?([A-Za-z][\w&.\- ]{1,40})", copyright_text)
|
|
396
|
+
if m:
|
|
397
|
+
vendor = m.group(1).strip().rstrip(".,;:")
|
|
398
|
+
if not vendor:
|
|
399
|
+
vendor = manufacturer_code # last resort, the 4-char code
|
|
400
|
+
|
|
401
|
+
# Stash both readable + code in sdk metadata for downstream debugging
|
|
402
|
+
if sdk is None:
|
|
403
|
+
sdk = {}
|
|
404
|
+
sdk.setdefault("manufacturer_code", manufacturer_code)
|
|
405
|
+
sdk.setdefault("bundle_identifier", bundle_id)
|
|
406
|
+
except (plistlib.InvalidFileException, OSError) as e:
|
|
407
|
+
logger.debug("AU Info.plist unreadable for %s: %s", bundle, e)
|
|
408
|
+
plugin_id = _slug(f"{vendor or 'unknown'}-{name}")
|
|
409
|
+
return DetectedPlugin(
|
|
410
|
+
plugin_id=plugin_id, name=name, vendor=vendor, format="AU",
|
|
411
|
+
version=version, bundle_path=str(bundle), unique_id=unique_id,
|
|
412
|
+
file_size_kb=_bundle_size_kb(bundle),
|
|
413
|
+
sdk_metadata=sdk,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# ─── VST2 ───────────────────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _identify_vst2(path: Path) -> DetectedPlugin | None:
|
|
421
|
+
# On macOS .vst is a bundle; on Windows/Linux it can be a single .dll/.so/.dylib
|
|
422
|
+
binary_path = path
|
|
423
|
+
if path.is_dir():
|
|
424
|
+
# macOS bundle — look inside Contents/MacOS/
|
|
425
|
+
macos_dir = path / "Contents" / "MacOS"
|
|
426
|
+
if macos_dir.exists():
|
|
427
|
+
execs = list(macos_dir.iterdir())
|
|
428
|
+
if execs:
|
|
429
|
+
binary_path = execs[0]
|
|
430
|
+
name = path.stem
|
|
431
|
+
plugin_code: str | None = None
|
|
432
|
+
program_name: str | None = None
|
|
433
|
+
try:
|
|
434
|
+
with binary_path.open("rb") as fh:
|
|
435
|
+
head = fh.read(60)
|
|
436
|
+
if head[:4] == b"CcnK" and len(head) >= 60:
|
|
437
|
+
plugin_code = head[16:20].decode("ascii", errors="ignore").strip()
|
|
438
|
+
name_bytes = head[28:60].split(b"\x00", 1)[0]
|
|
439
|
+
program_name = name_bytes.decode("latin-1", errors="ignore").strip()
|
|
440
|
+
except OSError:
|
|
441
|
+
pass
|
|
442
|
+
# VST2 doesn't store vendor in the binary — infer from path
|
|
443
|
+
vendor = path.parent.name if path.parent.name not in ("VST", "VstPlugins") else None
|
|
444
|
+
plugin_id = _slug(f"{vendor or 'unknown'}-{name}")
|
|
445
|
+
return DetectedPlugin(
|
|
446
|
+
plugin_id=plugin_id, name=name, vendor=vendor, format="VST2",
|
|
447
|
+
version=None, bundle_path=str(path), unique_id=plugin_code,
|
|
448
|
+
file_size_kb=_bundle_size_kb(path),
|
|
449
|
+
sdk_metadata={"program_name": program_name} if program_name else None,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ─── AAX ────────────────────────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _identify_aax(bundle: Path) -> DetectedPlugin | None:
|
|
457
|
+
manifest = bundle / "Contents" / "Resources" / "PluginManifest.plist"
|
|
458
|
+
name = bundle.stem
|
|
459
|
+
vendor: str | None = None
|
|
460
|
+
version: str | None = None
|
|
461
|
+
unique_id: str | None = None
|
|
462
|
+
sdk: dict[str, Any] | None = None
|
|
463
|
+
if manifest.exists():
|
|
464
|
+
try:
|
|
465
|
+
with manifest.open("rb") as fh:
|
|
466
|
+
plist = plistlib.load(fh)
|
|
467
|
+
if isinstance(plist, dict):
|
|
468
|
+
sdk = _strip_unjsonable(plist)
|
|
469
|
+
name = plist.get("PluginName") or plist.get("CFBundleName") or name
|
|
470
|
+
vendor = plist.get("ManufacturerName") or plist.get("Manufacturer")
|
|
471
|
+
version = plist.get("PluginVersion") or plist.get("CFBundleShortVersionString")
|
|
472
|
+
unique_id = plist.get("PluginID")
|
|
473
|
+
except (plistlib.InvalidFileException, OSError) as e:
|
|
474
|
+
logger.debug("AAX manifest unreadable for %s: %s", bundle, e)
|
|
475
|
+
plugin_id = _slug(f"{vendor or 'unknown'}-{name}")
|
|
476
|
+
return DetectedPlugin(
|
|
477
|
+
plugin_id=plugin_id, name=name, vendor=vendor, format="AAX",
|
|
478
|
+
version=version, bundle_path=str(bundle), unique_id=unique_id,
|
|
479
|
+
file_size_kb=_bundle_size_kb(bundle),
|
|
480
|
+
sdk_metadata=sdk,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# ─── LV2 (Linux) ────────────────────────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _identify_lv2(plugin_dir: Path) -> DetectedPlugin | None:
|
|
488
|
+
"""LV2 plugins are RDF-described in .ttl files. Best-effort name + uri."""
|
|
489
|
+
name = plugin_dir.name
|
|
490
|
+
uri: str | None = None
|
|
491
|
+
vendor: str | None = None
|
|
492
|
+
for ttl in plugin_dir.glob("*.ttl"):
|
|
493
|
+
try:
|
|
494
|
+
text = ttl.read_text(encoding="utf-8", errors="ignore")
|
|
495
|
+
except OSError:
|
|
496
|
+
continue
|
|
497
|
+
m = re.search(r'<([^>]+)>\s+a\s+lv2:Plugin', text)
|
|
498
|
+
if m:
|
|
499
|
+
uri = m.group(1)
|
|
500
|
+
m = re.search(r'doap:name\s+"([^"]+)"', text)
|
|
501
|
+
if m:
|
|
502
|
+
name = m.group(1)
|
|
503
|
+
m = re.search(r'doap:maintainer[^"]*"([^"]+)"', text)
|
|
504
|
+
if m:
|
|
505
|
+
vendor = m.group(1)
|
|
506
|
+
if uri:
|
|
507
|
+
break
|
|
508
|
+
plugin_id = _slug(f"{vendor or 'unknown'}-{name}")
|
|
509
|
+
return DetectedPlugin(
|
|
510
|
+
plugin_id=plugin_id, name=name, vendor=vendor, format="LV2",
|
|
511
|
+
version=None, bundle_path=str(plugin_dir), unique_id=uri,
|
|
512
|
+
file_size_kb=_bundle_size_kb(plugin_dir),
|
|
513
|
+
sdk_metadata=None,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# ─── Fallback ───────────────────────────────────────────────────────────────
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _fallback_identity(path: Path, fmt: str) -> DetectedPlugin:
|
|
521
|
+
name = path.stem
|
|
522
|
+
return DetectedPlugin(
|
|
523
|
+
plugin_id=_slug(f"unknown-{name}"),
|
|
524
|
+
name=name, vendor=None, format=fmt,
|
|
525
|
+
version=None, bundle_path=str(path), unique_id=None,
|
|
526
|
+
file_size_kb=_bundle_size_kb(path),
|
|
527
|
+
sdk_metadata=None,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# ─── Helpers ────────────────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _slug(s: str) -> str:
|
|
535
|
+
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") or "unknown"
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _decode_au_id(value: Any) -> str | None:
|
|
539
|
+
"""AU 4-char codes are 32-bit big-endian ints. Decode to ASCII."""
|
|
540
|
+
if value is None:
|
|
541
|
+
return None
|
|
542
|
+
if isinstance(value, str):
|
|
543
|
+
return value
|
|
544
|
+
if isinstance(value, int):
|
|
545
|
+
try:
|
|
546
|
+
return struct.pack(">I", value).decode("ascii", errors="ignore").strip()
|
|
547
|
+
except (ValueError, struct.error):
|
|
548
|
+
return str(value)
|
|
549
|
+
return str(value)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _strip_unjsonable(d: Any) -> Any:
|
|
553
|
+
"""Recursively strip non-JSON-serializable values (bytes → hex, datetime → str)."""
|
|
554
|
+
if isinstance(d, dict):
|
|
555
|
+
return {k: _strip_unjsonable(v) for k, v in d.items()}
|
|
556
|
+
if isinstance(d, list):
|
|
557
|
+
return [_strip_unjsonable(v) for v in d]
|
|
558
|
+
if isinstance(d, bytes):
|
|
559
|
+
return d.hex()
|
|
560
|
+
if isinstance(d, (str, int, float, bool)) or d is None:
|
|
561
|
+
return d
|
|
562
|
+
return str(d)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _bundle_size_kb(path: Path) -> int | None:
|
|
566
|
+
"""Total size of a plugin bundle / file in KB. None if unreadable."""
|
|
567
|
+
try:
|
|
568
|
+
if path.is_file():
|
|
569
|
+
return int(path.stat().st_size / 1024)
|
|
570
|
+
total = 0
|
|
571
|
+
for p in path.rglob("*"):
|
|
572
|
+
try:
|
|
573
|
+
if p.is_file():
|
|
574
|
+
total += p.stat().st_size
|
|
575
|
+
except OSError:
|
|
576
|
+
pass
|
|
577
|
+
return int(total / 1024)
|
|
578
|
+
except OSError:
|
|
579
|
+
return None
|