logicxkit 0.1.0__py3-none-any.whl

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 (118) hide show
  1. logicxkit/__init__.py +18 -0
  2. logicxkit/au/__init__.py +16 -0
  3. logicxkit/au/_views.py +74 -0
  4. logicxkit/au/cli.py +94 -0
  5. logicxkit/au/services/__init__.py +0 -0
  6. logicxkit/au/services/aupreset.py +54 -0
  7. logicxkit/au/services/embed.py +37 -0
  8. logicxkit/au/services/ffp.py +41 -0
  9. logicxkit/au/services/host.py +108 -0
  10. logicxkit/au/services/juce.py +164 -0
  11. logicxkit/au/services/report.py +132 -0
  12. logicxkit/au/services/sonible.py +96 -0
  13. logicxkit/au/services/tables.py +54 -0
  14. logicxkit/au/services/tr5.py +41 -0
  15. logicxkit/au/services/waves.py +45 -0
  16. logicxkit/cli.py +62 -0
  17. logicxkit/logic/__init__.py +88 -0
  18. logicxkit/logic/_apply.py +188 -0
  19. logicxkit/logic/_apply_template.py +200 -0
  20. logicxkit/logic/_apply_tracks.py +158 -0
  21. logicxkit/logic/_binary.py +94 -0
  22. logicxkit/logic/_capabilities.py +215 -0
  23. logicxkit/logic/_chains_cmd.py +176 -0
  24. logicxkit/logic/_controlbar.py +77 -0
  25. logicxkit/logic/_diagnose.py +87 -0
  26. logicxkit/logic/_edit.py +111 -0
  27. logicxkit/logic/_groups.py +78 -0
  28. logicxkit/logic/_header.py +70 -0
  29. logicxkit/logic/_inspect.py +222 -0
  30. logicxkit/logic/_metronome.py +89 -0
  31. logicxkit/logic/_modes.py +81 -0
  32. logicxkit/logic/_prefs.py +96 -0
  33. logicxkit/logic/_song.py +213 -0
  34. logicxkit/logic/_toolbar.py +67 -0
  35. logicxkit/logic/_width.py +71 -0
  36. logicxkit/logic/cli.py +457 -0
  37. logicxkit/logic/orchestrators/__init__.py +0 -0
  38. logicxkit/logic/orchestrators/apply_ops.py +104 -0
  39. logicxkit/logic/orchestrators/apply_template.py +403 -0
  40. logicxkit/logic/orchestrators/ops.py +33 -0
  41. logicxkit/logic/services/__init__.py +1 -0
  42. logicxkit/logic/services/addtrack.py +310 -0
  43. logicxkit/logic/services/arrangement.py +84 -0
  44. logicxkit/logic/services/arrangement_write.py +195 -0
  45. logicxkit/logic/services/binding.py +116 -0
  46. logicxkit/logic/services/chain_report.py +83 -0
  47. logicxkit/logic/services/chains.py +432 -0
  48. logicxkit/logic/services/channel_alloc.py +238 -0
  49. logicxkit/logic/services/comp.py +54 -0
  50. logicxkit/logic/services/controlbar.py +244 -0
  51. logicxkit/logic/services/donors.py +145 -0
  52. logicxkit/logic/services/environment.py +294 -0
  53. logicxkit/logic/services/eq.py +61 -0
  54. logicxkit/logic/services/events.py +75 -0
  55. logicxkit/logic/services/graft.py +115 -0
  56. logicxkit/logic/services/groups.py +394 -0
  57. logicxkit/logic/services/header.py +149 -0
  58. logicxkit/logic/services/inputs_create.py +69 -0
  59. logicxkit/logic/services/insert.py +434 -0
  60. logicxkit/logic/services/instout.py +180 -0
  61. logicxkit/logic/services/integrity.py +117 -0
  62. logicxkit/logic/services/keyflags.py +107 -0
  63. logicxkit/logic/services/levels.py +160 -0
  64. logicxkit/logic/services/library.py +63 -0
  65. logicxkit/logic/services/limiter.py +54 -0
  66. logicxkit/logic/services/manifest.py +77 -0
  67. logicxkit/logic/services/metronome.py +126 -0
  68. logicxkit/logic/services/modes.py +96 -0
  69. logicxkit/logic/services/neural.py +36 -0
  70. logicxkit/logic/services/ocr.py +89 -0
  71. logicxkit/logic/services/pairing.py +281 -0
  72. logicxkit/logic/services/prefs.py +204 -0
  73. logicxkit/logic/services/prefs_table.py +268 -0
  74. logicxkit/logic/services/projdiff.py +78 -0
  75. logicxkit/logic/services/project.py +193 -0
  76. logicxkit/logic/services/pst.py +83 -0
  77. logicxkit/logic/services/recbuild.py +66 -0
  78. logicxkit/logic/services/recdiff.py +125 -0
  79. logicxkit/logic/services/records.py +107 -0
  80. logicxkit/logic/services/regions.py +117 -0
  81. logicxkit/logic/services/registry.py +205 -0
  82. logicxkit/logic/services/reorder.py +66 -0
  83. logicxkit/logic/services/retrack.py +272 -0
  84. logicxkit/logic/services/routing.py +59 -0
  85. logicxkit/logic/services/selection.py +38 -0
  86. logicxkit/logic/services/sends.py +104 -0
  87. logicxkit/logic/services/sends_write.py +148 -0
  88. logicxkit/logic/services/sequence.py +248 -0
  89. logicxkit/logic/services/settings.py +86 -0
  90. logicxkit/logic/services/signature.py +137 -0
  91. logicxkit/logic/services/signature_write.py +161 -0
  92. logicxkit/logic/services/slotkeys.py +74 -0
  93. logicxkit/logic/services/spec.py +137 -0
  94. logicxkit/logic/services/stack_create.py +182 -0
  95. logicxkit/logic/services/stacks.py +288 -0
  96. logicxkit/logic/services/stripsave.py +56 -0
  97. logicxkit/logic/services/tempo.py +69 -0
  98. logicxkit/logic/services/tempo_write.py +153 -0
  99. logicxkit/logic/services/toolbar.py +133 -0
  100. logicxkit/logic/services/tracklist.py +181 -0
  101. logicxkit/logic/services/transplant.py +175 -0
  102. logicxkit/logic/services/validate.py +123 -0
  103. logicxkit/logicx/__init__.py +15 -0
  104. logicxkit/logicx/container.py +70 -0
  105. logicxkit/native/aulatency.swift +61 -0
  106. logicxkit/native/auprobe.swift +142 -0
  107. logicxkit/native/vision_ocr.swift +37 -0
  108. logicxkit/utils/__init__.py +6 -0
  109. logicxkit/utils/data.py +49 -0
  110. logicxkit/utils/env.py +20 -0
  111. logicxkit/utils/swiftrun.py +37 -0
  112. logicxkit-0.1.0.dist-info/METADATA +259 -0
  113. logicxkit-0.1.0.dist-info/RECORD +118 -0
  114. logicxkit-0.1.0.dist-info/WHEEL +5 -0
  115. logicxkit-0.1.0.dist-info/entry_points.txt +2 -0
  116. logicxkit-0.1.0.dist-info/licenses/LICENSE +202 -0
  117. logicxkit-0.1.0.dist-info/licenses/NOTICE +16 -0
  118. logicxkit-0.1.0.dist-info/top_level.txt +1 -0
logicxkit/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """logicxkit — tools for Logic Pro projects, channel strips and Audio Unit state.
2
+
3
+ Subpackages:
4
+ - ``logicxkit.logic`` — Logic Pro channel-strip (.cst) build/decode, .logicx analysis.
5
+ - ``logicxkit.au`` — 3rd-party plugin presets/states via a headless AU host.
6
+ - ``logicxkit.logicx`` — the .logicx container format; a leaf both logic and au read through.
7
+
8
+ A pf-core consumer (foundation tier). Each gear domain owns a subpackage; ``logicxkit.utils``
9
+ and ``logicxkit.logicx`` are support leaves (only what ≥2 domains actually call). The
10
+ subpackage import graph is a DAG — enforced by tests/test_package_layering.py.
11
+ """
12
+
13
+ from importlib.metadata import PackageNotFoundError, version as _version
14
+
15
+ try:
16
+ __version__ = _version("logicxkit")
17
+ except PackageNotFoundError: # a source tree that was never installed
18
+ __version__ = "0+unknown"
@@ -0,0 +1,16 @@
1
+ """logicxkit.au — Audio Unit preset/state intelligence (read-only).
2
+
3
+ Decodes plugin settings wherever Logic or the plugin stores them: standalone
4
+ .aupreset / .ffp preset files, and AU ClassInfo plists embedded in .cst strips
5
+ and .logicx ProjectData. Static parsers cover FabFilter/classic-AU parameter
6
+ tables and Waves XPst; the AU host (vendored Swift probe) instantiates the real
7
+ plugin headless for named, UI-formatted values of everything else.
8
+ """
9
+
10
+ from logicxkit.au.services.aupreset import AuState, parse_au_state # noqa: F401
11
+ from logicxkit.au.services.embed import find_au_plists, fourcc # noqa: F401
12
+ from logicxkit.au.services.ffp import FfpError, FfpPreset, parse_ffp # noqa: F401
13
+ from logicxkit.au.services.host import ( # noqa: F401
14
+ AuDump, AuHost, AuHostError, AuParam, is_headless_safe,
15
+ )
16
+ from logicxkit.au.services.waves import extract_xpst # noqa: F401
logicxkit/au/_views.py ADDED
@@ -0,0 +1,74 @@
1
+ """Terminal rendering for au decode results (data assembly lives in services)."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def _fmt_value(row: dict) -> str:
7
+ if row.get("display"):
8
+ return row["display"]
9
+ unit = f" {row['unit']}" if row.get("unit") and row["unit"] != "generic" else ""
10
+ return f"{row['value']:.4g}{unit}"
11
+
12
+
13
+ def _fmt_rows(params: list[dict], all_rows: bool) -> list[str]:
14
+ rows = params if all_rows else [r for r in params if r.get("changed")]
15
+ lines = []
16
+ for r in rows:
17
+ dflt = "" if r.get("default") is None else f" (default {r['default']:.4g})"
18
+ lines.append(f" [{r['id']:>5}] {r['name']:<44} = {_fmt_value(r)}{dflt}")
19
+ if not all_rows and len(rows) < len(params):
20
+ lines.append(f" … {len(params) - len(rows)} more at default (--all to show)")
21
+ return lines
22
+
23
+
24
+ def _header(out: dict) -> str:
25
+ plug = out["plugin"]
26
+ comp = plug.get("component") or f"{plug['manufacturer']}/{plug['subtype']}"
27
+ name = f" preset {out['preset_name']!r}" if out.get("preset_name") else ""
28
+ return f"{comp} ({plug['manufacturer']}/{plug['subtype']}){name} [{out['decode_path']}]"
29
+
30
+
31
+ def format_preset(out: dict, all_rows: bool) -> str:
32
+ lines = [_header(out)]
33
+ if out.get("host_error"):
34
+ lines.append(f" ! AU host failed, static fallback: {out['host_error']}")
35
+ if out.get("neural"):
36
+ n = out["neural"]
37
+ secs = ", ".join(f"{k}({len(v)})" for k, v in n.get("sections", {}).items())
38
+ lines.append(f" Neural DSP state [{n['format']}]: {secs}")
39
+ lines.append(" (full knob detail: logicxkit logic neural)")
40
+ if out.get("sonible"):
41
+ s = out["sonible"]
42
+ lines.append(f" sonible protobuf fields (unnamed; plugin {s.get('plugin')!r}):")
43
+ for k, v in s["fields"].items():
44
+ if isinstance(v, dict):
45
+ inner = " ".join(f"{ik}={iv}" for ik, iv in v.items())
46
+ lines.append(f" msg {k}: {inner}")
47
+ else:
48
+ lines.append(f" {k} = {v}")
49
+ if out.get("tr5"):
50
+ t = out["tr5"]
51
+ lines.append(f" TR5 chain (current preset: {t.get('current_preset')})")
52
+ for snap in t["snapshots"]:
53
+ active = [s for s in snap["slots"] if s.get("bypass") != "1"]
54
+ lines.append(f" snapshot {snap['snapshot']}: {len(active)} active module(s)")
55
+ for s in active:
56
+ params = " ".join(f"{k}={v}" for k, v in list(s["params"].items())[:8])
57
+ lines.append(f" {s['slot']} [{(s.get('guid') or '?')[:8]}] {params}")
58
+ lines.extend(_fmt_rows(out.get("params", []), all_rows))
59
+ if not out.get("params") and out.get("blobs"):
60
+ blobs = ", ".join(f"{k} ({v}B)" for k, v in out["blobs"].items())
61
+ lines.append(f" opaque state blobs: {blobs}")
62
+ return "\n".join(lines)
63
+
64
+
65
+ def format_strip(states: list[dict], all_rows: bool) -> str:
66
+ if not states:
67
+ return "no embedded 3rd-party AU states found"
68
+ chunks = []
69
+ for st in states:
70
+ where = f"@{st.get('offset', '?')}"
71
+ if st.get("channel"):
72
+ where += f" {st['channel']}"
73
+ chunks.append(f"— {where}\n" + format_preset(st, all_rows))
74
+ return "\n\n".join(chunks)
logicxkit/au/cli.py ADDED
@@ -0,0 +1,94 @@
1
+ """au CLI — decode plugin presets and embedded strip states.
2
+
3
+ logicxkit au preset <file> .ffp / .aupreset / .pst / extracted .plist
4
+ logicxkit au strip <file.cst|Song.logicx> every embedded 3rd-party state
5
+ logicxkit au params <type> <subtype> <manu> live AU parameter table (needs swift)
6
+ logicxkit au tables list the AU parameter tables in the data root
7
+
8
+ Decode ladder per state: NDSP -> JUCE decoders, Waves -> static XPst, else the
9
+ headless AU host (--no-host to force the static table join).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ from pathlib import Path
17
+
18
+ from logicxkit.au._views import format_preset, format_strip
19
+ from logicxkit.au.services.host import AuHost
20
+ from logicxkit.au.services.report import decode_preset_bytes, decode_strip_path
21
+ from logicxkit.au.services.tables import available_tables
22
+
23
+
24
+ def _host_or_none(args) -> AuHost | None:
25
+ if args.no_host or not AuHost.available():
26
+ return None
27
+ return AuHost()
28
+
29
+
30
+ def _emit(payload, text: str, as_json: bool) -> int:
31
+ print(json.dumps(payload, indent=2) if as_json else text)
32
+ return 0
33
+
34
+
35
+ def cmd_preset(args) -> int:
36
+ p = Path(args.file).expanduser() # every real preset lives under ~/Library or ~/Music
37
+ out = decode_preset_bytes(p.read_bytes(), suffix=p.suffix.lower(),
38
+ host=_host_or_none(args), name_hint=p.stem)
39
+ return _emit(out, format_preset(out, args.all), args.json)
40
+
41
+
42
+ def cmd_strip(args) -> int:
43
+ states = decode_strip_path(args.file, host=_host_or_none(args))
44
+ return _emit(states, format_strip(states, args.all), args.json)
45
+
46
+
47
+ def cmd_params(args) -> int:
48
+ dump = AuHost().list_params(args.type, args.subtype, args.manufacturer)
49
+ payload = {"component": dump.component, "params": [vars(p) for p in dump.params]}
50
+ text = "\n".join(f"[{p.id:>5}] {p.name:<44} {p.unit:<8} "
51
+ f"{p.min:.4g}..{p.max:.4g} (default {p.default:.4g})"
52
+ for p in dump.params)
53
+ return _emit(payload, f"{dump.component} — {len(dump.params)} params\n{text}", args.json)
54
+
55
+
56
+ def cmd_tables(args) -> int:
57
+ names = available_tables()
58
+ print("\n".join(names) if names else "no tables checked in")
59
+ return 0
60
+
61
+
62
+ def main(argv: list[str] | None = None) -> int:
63
+ ap = argparse.ArgumentParser(prog="logicxkit au", description=__doc__,
64
+ formatter_class=argparse.RawDescriptionHelpFormatter)
65
+ sub = ap.add_subparsers(dest="cmd", required=True)
66
+
67
+ def common(p):
68
+ p.add_argument("--all", action="store_true", help="show unchanged params too")
69
+ p.add_argument("--json", action="store_true")
70
+ p.add_argument("--no-host", action="store_true",
71
+ help="skip the AU host; static decode only")
72
+
73
+ p = sub.add_parser("preset", help="decode a preset file")
74
+ p.add_argument("file")
75
+ common(p)
76
+ p.set_defaults(fn=cmd_preset)
77
+
78
+ p = sub.add_parser("strip", help="decode embedded states in a .cst/.pst/.logicx")
79
+ p.add_argument("file")
80
+ common(p)
81
+ p.set_defaults(fn=cmd_strip)
82
+
83
+ p = sub.add_parser("params", help="live AU parameter table")
84
+ p.add_argument("type")
85
+ p.add_argument("subtype")
86
+ p.add_argument("manufacturer")
87
+ p.add_argument("--json", action="store_true")
88
+ p.set_defaults(fn=cmd_params)
89
+
90
+ p = sub.add_parser("tables", help="list the AU parameter tables in the data root")
91
+ p.set_defaults(fn=cmd_tables)
92
+
93
+ args = ap.parse_args(argv)
94
+ return args.fn(args)
File without changes
@@ -0,0 +1,54 @@
1
+ """AU ClassInfo dicts (.aupreset files and .cst/.logicx-embedded plists) -> typed state.
2
+
3
+ Two state shapes appear in the wild:
4
+
5
+ - the AU-standard ``data`` key — 12B header (8 reserved bytes + u32 BE pair
6
+ count) + (u32 BE param id, f32 BE value) pairs. Classic FabFilter plugins
7
+ (Pro-C 2, Pro-MB) store their whole state here; param ids match .ffp
8
+ positions.
9
+ - vendor blob keys (``FabFilterPluginState``, ``jucePluginState``,
10
+ ``Waves_XPst``, ``Line6PresetData``, …) — kept raw for the specialist
11
+ decoders or the AU host.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import struct
17
+ from dataclasses import dataclass
18
+
19
+ from logicxkit.au.services.embed import fourcc
20
+
21
+ _IDENTITY_KEYS = ("type", "subtype", "manufacturer", "version", "name", "data")
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class AuState:
26
+ type: str
27
+ subtype: str
28
+ manufacturer: str
29
+ name: str | None
30
+ param_pairs: list[tuple[int, float]] | None
31
+ blobs: dict[str, bytes]
32
+
33
+
34
+ def _parse_pairs(blob: bytes) -> list[tuple[int, float]] | None:
35
+ if len(blob) < 12:
36
+ return None
37
+ count = struct.unpack_from(">I", blob, 8)[0]
38
+ if len(blob) != 12 + 8 * count:
39
+ return None
40
+ return [struct.unpack_from(">If", blob, 12 + 8 * i) for i in range(count)]
41
+
42
+
43
+ def parse_au_state(plist: dict) -> AuState:
44
+ data = plist.get("data")
45
+ name = plist.get("name")
46
+ return AuState(
47
+ type=fourcc(plist.get("type", 0)),
48
+ subtype=fourcc(plist.get("subtype", 0)),
49
+ manufacturer=fourcc(plist.get("manufacturer", 0)),
50
+ name=name if isinstance(name, str) else None,
51
+ param_pairs=_parse_pairs(data) if isinstance(data, bytes) else None,
52
+ blobs={k: v for k, v in plist.items()
53
+ if isinstance(v, bytes) and k not in _IDENTITY_KEYS},
54
+ )
@@ -0,0 +1,37 @@
1
+ """Embedded AU state scanning — Logic stores 3rd-party plugin state as raw XML
2
+ plists inside its binaries (identically in ``.cst`` files and ``.logicx``
3
+ ``ProjectData``). Each plist is a full AU ClassInfo dict: identity fourccs
4
+ (``type``/``subtype``/``manufacturer``), preset ``name``, and the state itself
5
+ (``data`` pairs and/or vendor blob keys)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import plistlib
10
+ import struct
11
+
12
+
13
+ def find_au_plists(data: bytes) -> list[tuple[int, dict]]:
14
+ """Every embedded XML plist that parses to a dict, as ``(offset, plist)``."""
15
+ out: list[tuple[int, dict]] = []
16
+ pos = 0
17
+ while True:
18
+ i = data.find(b"<?xml", pos)
19
+ if i < 0:
20
+ break
21
+ end = data.find(b"</plist>", i)
22
+ if end < 0:
23
+ break
24
+ try:
25
+ pl = plistlib.loads(data[i : end + len(b"</plist>")])
26
+ except Exception:
27
+ pos = i + len(b"<?xml")
28
+ continue
29
+ if isinstance(pl, dict):
30
+ out.append((i, pl))
31
+ pos = end + len(b"</plist>")
32
+ return out
33
+
34
+
35
+ def fourcc(n: int) -> str:
36
+ s = struct.pack(">I", n & 0xFFFFFFFF).decode("latin-1")
37
+ return s if s.isascii() and s.isprintable() else f"0x{n:08X}"
@@ -0,0 +1,41 @@
1
+ """FabFilter ``.ffp`` preset files.
2
+
3
+ Layout (verified against Pro-C 2 / Pro-L / Pro-MB / Pro-Q 2 factory presets):
4
+ 4CC magic (``FC2p``, ``FPLr``, ``FPMb``, …) + u32 LE version + u32 LE param
5
+ count + count × float32 LE. Param position i corresponds to AU parameter id i
6
+ of the same plugin, so the AU parameter table names every value.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import struct
12
+ from dataclasses import dataclass
13
+
14
+ _MAX_PARAMS = 8192 # sanity bound: real plugins top out in the hundreds
15
+
16
+
17
+ class FfpError(ValueError):
18
+ """Not a parseable .ffp file."""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class FfpPreset:
23
+ magic: str
24
+ version: int
25
+ values: tuple[float, ...]
26
+
27
+
28
+ def parse_ffp(data: bytes) -> FfpPreset:
29
+ if len(data) < 12:
30
+ raise FfpError("too short for an .ffp header")
31
+ magic = data[:4]
32
+ if not all(32 <= b < 127 for b in magic):
33
+ raise FfpError(f"implausible magic {magic!r}")
34
+ version, count = struct.unpack_from("<II", data, 4)
35
+ if count > _MAX_PARAMS:
36
+ raise FfpError(f"implausible param count {count}")
37
+ need = 12 + 4 * count
38
+ if len(data) < need:
39
+ raise FfpError(f"truncated value table: need {need}B, have {len(data)}B")
40
+ values = struct.unpack_from(f"<{count}f", data, 12)
41
+ return FfpPreset(magic.decode("ascii"), version, values)
@@ -0,0 +1,108 @@
1
+ """Headless AU introspection via the vendored Swift probe (``logicxkit/native/auprobe.swift``).
2
+
3
+ The probe instantiates an installed Audio Unit, optionally restores a preset /
4
+ ClassInfo plist (kAudioUnitProperty_ClassInfo), and prints one JSON document:
5
+ component identity plus every parameter with name, unit, min/max/default,
6
+ current value, and the UI-formatted display string
7
+ (kAudioUnitProperty_ParameterStringFromValue). This wrapper shells out and
8
+ parses; everything degrades to the static decoders when Swift or the AU is
9
+ absent.
10
+
11
+ Some AUs crash when instantiated without a UI host — denylisted from batch
12
+ decodes via :func:`is_headless_safe` (Waves state decodes statically in
13
+ ``waves.py`` instead; sonible state is a protobuf ``jucePluginState``).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from logicxkit.utils.swiftrun import SwiftRunError, run_swift, swift_available
23
+
24
+ # Observed crashes: sonible (dyld abort), Waves (WaveShell objc class collision).
25
+ _HEADLESS_UNSAFE = {"Soni", "ksWV"}
26
+ _TIMEOUT_S = 120
27
+
28
+
29
+ class AuHostError(RuntimeError):
30
+ """The probe failed: missing toolchain/component, crash, or bad output."""
31
+
32
+
33
+ def is_headless_safe(manufacturer_cc: str) -> bool:
34
+ return manufacturer_cc not in _HEADLESS_UNSAFE
35
+
36
+
37
+ def auprobe_path() -> Path:
38
+ return Path(__file__).resolve().parents[4] / "native" / "auprobe.swift"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class AuParam:
43
+ id: int
44
+ name: str
45
+ unit: str
46
+ min: float
47
+ max: float
48
+ default: float
49
+ value: float | None = None
50
+ display: str | None = None
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class AuDump:
55
+ component: str
56
+ type: str
57
+ subtype: str
58
+ manufacturer: str
59
+ params: tuple[AuParam, ...]
60
+
61
+ def changed_params(self, eps: float = 1e-6) -> list[AuParam]:
62
+ return [p for p in self.params
63
+ if p.value is not None and abs(p.value - p.default) > eps]
64
+
65
+
66
+ def _default_runner(args: list[str], timeout: float) -> tuple[int, str, str]:
67
+ try:
68
+ return run_swift(Path(args[0]), list(args[1:]), timeout)
69
+ except SwiftRunError as e:
70
+ raise AuHostError(str(e)) from e
71
+
72
+
73
+ class AuHost:
74
+ def __init__(self, runner=None, timeout: float = _TIMEOUT_S):
75
+ self._runner = runner or _default_runner
76
+ self._timeout = timeout
77
+
78
+ @staticmethod
79
+ def available() -> bool:
80
+ return swift_available() and auprobe_path().exists()
81
+
82
+ def dump_preset(self, plist_path: str | Path) -> AuDump:
83
+ """Instantiate the AU named inside the plist, restore it, dump values."""
84
+ return self._invoke(["preset", str(plist_path)])
85
+
86
+ def list_params(self, type_cc: str, subtype_cc: str, manu_cc: str) -> AuDump:
87
+ """Parameter table (names/units/ranges/defaults) without preset state."""
88
+ return self._invoke(["list", type_cc, subtype_cc, manu_cc])
89
+
90
+ def _invoke(self, args: list[str]) -> AuDump:
91
+ rc, out, err = self._runner([str(auprobe_path()), *args], self._timeout)
92
+ if rc != 0:
93
+ tail = (err or out).strip().splitlines()
94
+ raise AuHostError(tail[-1] if tail else f"probe exited {rc}")
95
+ start = out.find("{")
96
+ if start < 0:
97
+ raise AuHostError(f"unparseable probe output: {out[:120]!r}")
98
+ try:
99
+ d = json.loads(out[start:])
100
+ except json.JSONDecodeError as e:
101
+ raise AuHostError(f"bad probe JSON: {e}") from e
102
+ params = tuple(
103
+ AuParam(id=p["id"], name=p["name"], unit=p["unit"], min=p["min"],
104
+ max=p["max"], default=p["default"],
105
+ value=p.get("value"), display=p.get("display"))
106
+ for p in d["params"])
107
+ return AuDump(component=d["component"], type=d["type"], subtype=d["subtype"],
108
+ manufacturer=d["manufacturer"], params=params)
@@ -0,0 +1,164 @@
1
+ """JUCE plugin-state decode — what AU plugins put in ``jucePluginState``.
2
+
3
+ Two container shapes, vendor-independent: an XML document wrapped by ``copyXmlToBinary``,
4
+ or a binary ValueTree. Extraction-only; writing state back is out of scope.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import struct
10
+ import xml.etree.ElementTree as ET
11
+
12
+ from logicxkit.au.services.embed import fourcc
13
+
14
+ _MAX_NODES = 4096 # sanity bound so garbage never parses as a huge ValueTree
15
+
16
+
17
+ def decode_juce_xml(state: bytes) -> str | None:
18
+ """Unwrap a JUCE ``copyXmlToBinary`` container; None if it isn't one."""
19
+ if len(state) < 8 or not state.startswith(b"VC2!"):
20
+ return None
21
+ n = struct.unpack_from("<I", state, 4)[0]
22
+ return state[8 : 8 + n].rstrip(b"\x00").decode("utf-8", "replace")
23
+
24
+
25
+ # ---- JUCE binary ValueTree ------------------------------------------------------------
26
+
27
+ def _read_string(data: bytes, pos: int) -> tuple[str, int]:
28
+ end = data.index(b"\x00", pos)
29
+ return data[pos:end].decode("utf-8"), end + 1
30
+
31
+
32
+ def _read_cint(data: bytes, pos: int) -> tuple[int, int]:
33
+ n = data[pos]
34
+ pos += 1
35
+ if n == 0:
36
+ return 0, pos
37
+ if n > 8 or pos + n > len(data):
38
+ raise ValueError("not a JUCE compressed int")
39
+ return int.from_bytes(data[pos : pos + n], "little"), pos + n
40
+
41
+
42
+ def _read_var(data: bytes, pos: int):
43
+ size, pos = _read_cint(data, pos)
44
+ if size == 0:
45
+ return None, pos
46
+ kind, payload = data[pos], data[pos + 1 : pos + size]
47
+ pos += size
48
+ if kind == 1:
49
+ return struct.unpack("<i", payload)[0], pos
50
+ if kind == 2:
51
+ return True, pos
52
+ if kind == 3:
53
+ return False, pos
54
+ if kind == 4:
55
+ return struct.unpack("<d", payload)[0], pos
56
+ if kind == 5:
57
+ return payload.rstrip(b"\x00").decode("utf-8", "replace"), pos
58
+ if kind == 6:
59
+ return struct.unpack("<q", payload)[0], pos
60
+ if kind == 8:
61
+ return payload, pos # binary blob (e.g. TR5's Chain XML) — caller decodes
62
+ return None, pos # unknown var kind: skip payload, keep parsing
63
+
64
+
65
+ def _read_tree(data: bytes, pos: int) -> tuple[dict, int]:
66
+ type_, pos = _read_string(data, pos)
67
+ if not type_ or not type_[0].isascii() or not type_.isprintable():
68
+ raise ValueError(f"implausible ValueTree type {type_!r}")
69
+ nprops, pos = _read_cint(data, pos)
70
+ if nprops > _MAX_NODES:
71
+ raise ValueError("implausible prop count")
72
+ props = {}
73
+ for _ in range(nprops):
74
+ name, pos = _read_string(data, pos)
75
+ props[name], pos = _read_var(data, pos)
76
+ nkids, pos = _read_cint(data, pos)
77
+ if nkids > _MAX_NODES:
78
+ raise ValueError("implausible child count")
79
+ children = []
80
+ for _ in range(nkids):
81
+ child, pos = _read_tree(data, pos)
82
+ children.append(child)
83
+ return {"type": type_, "props": props, "children": children}, pos
84
+
85
+
86
+ def parse_value_tree(state: bytes) -> dict | None:
87
+ """Parse a JUCE binary ValueTree to ``{type, props, children}``; None on garbage."""
88
+ try:
89
+ tree, _ = _read_tree(state, 0)
90
+ return tree
91
+ except (ValueError, IndexError, struct.error, UnicodeDecodeError):
92
+ return None
93
+
94
+
95
+ # ---- state -> meta/sections -----------------------------------------------------------
96
+
97
+ def _convert(v: str):
98
+ if v == "true":
99
+ return True
100
+ if v == "false":
101
+ return False
102
+ try:
103
+ return int(v)
104
+ except ValueError:
105
+ try:
106
+ return float(v)
107
+ except ValueError:
108
+ return v
109
+
110
+
111
+ def _xml_sections(xml_text: str) -> tuple[dict, dict]:
112
+ """Root attrs -> meta; every attributed descendant element -> a section by tag."""
113
+ root = ET.fromstring(xml_text)
114
+ meta = {k: _convert(v) for k, v in root.attrib.items()}
115
+ sections: dict[str, dict] = {}
116
+ for el in root.iter():
117
+ if el is root or not el.attrib:
118
+ continue
119
+ sections.setdefault(el.tag, {}).update(
120
+ {k: _convert(v) for k, v in el.attrib.items()})
121
+ return meta, sections
122
+
123
+
124
+ def _tree_sections(root: dict) -> tuple[dict, dict]:
125
+ """Root + descendant metadata props -> meta; PARAM id/value children -> params."""
126
+ meta = dict(root["props"])
127
+ params: dict = {}
128
+
129
+ def walk(node: dict) -> None:
130
+ if node["type"] == "PARAM":
131
+ pid = node["props"].get("id")
132
+ if pid is not None:
133
+ params[pid] = node["props"].get("value")
134
+ return
135
+ for k, v in node["props"].items():
136
+ meta.setdefault(k, v)
137
+ for child in node["children"]:
138
+ walk(child)
139
+
140
+ for child in root["children"]:
141
+ walk(child)
142
+ return meta, {"params": params}
143
+
144
+
145
+ def state_from_plist(pl: dict) -> dict | None:
146
+ """Decode one AU ClassInfo plist's ``jucePluginState``; None if undecodable."""
147
+ state = pl.get("jucePluginState")
148
+ if not isinstance(state, bytes):
149
+ return None
150
+ xml_text = decode_juce_xml(state)
151
+ if xml_text is not None:
152
+ try:
153
+ meta, sections = _xml_sections(xml_text)
154
+ except ET.ParseError:
155
+ return None
156
+ fmt = "xml"
157
+ else:
158
+ tree = parse_value_tree(state)
159
+ if tree is None:
160
+ return None
161
+ meta, sections = _tree_sections(tree)
162
+ fmt = "tree"
163
+ return {"subtype": fourcc(pl.get("subtype", 0)),
164
+ "format": fmt, "meta": meta, "sections": sections}