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.
- logicxkit/__init__.py +18 -0
- logicxkit/au/__init__.py +16 -0
- logicxkit/au/_views.py +74 -0
- logicxkit/au/cli.py +94 -0
- logicxkit/au/services/__init__.py +0 -0
- logicxkit/au/services/aupreset.py +54 -0
- logicxkit/au/services/embed.py +37 -0
- logicxkit/au/services/ffp.py +41 -0
- logicxkit/au/services/host.py +108 -0
- logicxkit/au/services/juce.py +164 -0
- logicxkit/au/services/report.py +132 -0
- logicxkit/au/services/sonible.py +96 -0
- logicxkit/au/services/tables.py +54 -0
- logicxkit/au/services/tr5.py +41 -0
- logicxkit/au/services/waves.py +45 -0
- logicxkit/cli.py +62 -0
- logicxkit/logic/__init__.py +88 -0
- logicxkit/logic/_apply.py +188 -0
- logicxkit/logic/_apply_template.py +200 -0
- logicxkit/logic/_apply_tracks.py +158 -0
- logicxkit/logic/_binary.py +94 -0
- logicxkit/logic/_capabilities.py +215 -0
- logicxkit/logic/_chains_cmd.py +176 -0
- logicxkit/logic/_controlbar.py +77 -0
- logicxkit/logic/_diagnose.py +87 -0
- logicxkit/logic/_edit.py +111 -0
- logicxkit/logic/_groups.py +78 -0
- logicxkit/logic/_header.py +70 -0
- logicxkit/logic/_inspect.py +222 -0
- logicxkit/logic/_metronome.py +89 -0
- logicxkit/logic/_modes.py +81 -0
- logicxkit/logic/_prefs.py +96 -0
- logicxkit/logic/_song.py +213 -0
- logicxkit/logic/_toolbar.py +67 -0
- logicxkit/logic/_width.py +71 -0
- logicxkit/logic/cli.py +457 -0
- logicxkit/logic/orchestrators/__init__.py +0 -0
- logicxkit/logic/orchestrators/apply_ops.py +104 -0
- logicxkit/logic/orchestrators/apply_template.py +403 -0
- logicxkit/logic/orchestrators/ops.py +33 -0
- logicxkit/logic/services/__init__.py +1 -0
- logicxkit/logic/services/addtrack.py +310 -0
- logicxkit/logic/services/arrangement.py +84 -0
- logicxkit/logic/services/arrangement_write.py +195 -0
- logicxkit/logic/services/binding.py +116 -0
- logicxkit/logic/services/chain_report.py +83 -0
- logicxkit/logic/services/chains.py +432 -0
- logicxkit/logic/services/channel_alloc.py +238 -0
- logicxkit/logic/services/comp.py +54 -0
- logicxkit/logic/services/controlbar.py +244 -0
- logicxkit/logic/services/donors.py +145 -0
- logicxkit/logic/services/environment.py +294 -0
- logicxkit/logic/services/eq.py +61 -0
- logicxkit/logic/services/events.py +75 -0
- logicxkit/logic/services/graft.py +115 -0
- logicxkit/logic/services/groups.py +394 -0
- logicxkit/logic/services/header.py +149 -0
- logicxkit/logic/services/inputs_create.py +69 -0
- logicxkit/logic/services/insert.py +434 -0
- logicxkit/logic/services/instout.py +180 -0
- logicxkit/logic/services/integrity.py +117 -0
- logicxkit/logic/services/keyflags.py +107 -0
- logicxkit/logic/services/levels.py +160 -0
- logicxkit/logic/services/library.py +63 -0
- logicxkit/logic/services/limiter.py +54 -0
- logicxkit/logic/services/manifest.py +77 -0
- logicxkit/logic/services/metronome.py +126 -0
- logicxkit/logic/services/modes.py +96 -0
- logicxkit/logic/services/neural.py +36 -0
- logicxkit/logic/services/ocr.py +89 -0
- logicxkit/logic/services/pairing.py +281 -0
- logicxkit/logic/services/prefs.py +204 -0
- logicxkit/logic/services/prefs_table.py +268 -0
- logicxkit/logic/services/projdiff.py +78 -0
- logicxkit/logic/services/project.py +193 -0
- logicxkit/logic/services/pst.py +83 -0
- logicxkit/logic/services/recbuild.py +66 -0
- logicxkit/logic/services/recdiff.py +125 -0
- logicxkit/logic/services/records.py +107 -0
- logicxkit/logic/services/regions.py +117 -0
- logicxkit/logic/services/registry.py +205 -0
- logicxkit/logic/services/reorder.py +66 -0
- logicxkit/logic/services/retrack.py +272 -0
- logicxkit/logic/services/routing.py +59 -0
- logicxkit/logic/services/selection.py +38 -0
- logicxkit/logic/services/sends.py +104 -0
- logicxkit/logic/services/sends_write.py +148 -0
- logicxkit/logic/services/sequence.py +248 -0
- logicxkit/logic/services/settings.py +86 -0
- logicxkit/logic/services/signature.py +137 -0
- logicxkit/logic/services/signature_write.py +161 -0
- logicxkit/logic/services/slotkeys.py +74 -0
- logicxkit/logic/services/spec.py +137 -0
- logicxkit/logic/services/stack_create.py +182 -0
- logicxkit/logic/services/stacks.py +288 -0
- logicxkit/logic/services/stripsave.py +56 -0
- logicxkit/logic/services/tempo.py +69 -0
- logicxkit/logic/services/tempo_write.py +153 -0
- logicxkit/logic/services/toolbar.py +133 -0
- logicxkit/logic/services/tracklist.py +181 -0
- logicxkit/logic/services/transplant.py +175 -0
- logicxkit/logic/services/validate.py +123 -0
- logicxkit/logicx/__init__.py +15 -0
- logicxkit/logicx/container.py +70 -0
- logicxkit/native/aulatency.swift +61 -0
- logicxkit/native/auprobe.swift +142 -0
- logicxkit/native/vision_ocr.swift +37 -0
- logicxkit/utils/__init__.py +6 -0
- logicxkit/utils/data.py +49 -0
- logicxkit/utils/env.py +20 -0
- logicxkit/utils/swiftrun.py +37 -0
- logicxkit-0.1.0.dist-info/METADATA +259 -0
- logicxkit-0.1.0.dist-info/RECORD +118 -0
- logicxkit-0.1.0.dist-info/WHEEL +5 -0
- logicxkit-0.1.0.dist-info/entry_points.txt +2 -0
- logicxkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- logicxkit-0.1.0.dist-info/licenses/NOTICE +16 -0
- 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"
|
logicxkit/au/__init__.py
ADDED
|
@@ -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}
|