livepilot 1.10.9 → 1.12.2

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 (35) hide show
  1. package/CHANGELOG.md +245 -0
  2. package/README.md +7 -7
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/m4l_bridge.py +488 -13
  7. package/mcp_server/runtime/execution_router.py +7 -0
  8. package/mcp_server/runtime/mcp_dispatch.py +32 -0
  9. package/mcp_server/runtime/remote_commands.py +54 -0
  10. package/mcp_server/sample_engine/slice_classifier.py +169 -0
  11. package/mcp_server/server.py +11 -3
  12. package/mcp_server/tools/analyzer.py +187 -7
  13. package/mcp_server/tools/clips.py +65 -0
  14. package/mcp_server/tools/devices.py +517 -5
  15. package/mcp_server/tools/diagnostics.py +42 -0
  16. package/mcp_server/tools/follow_actions.py +202 -0
  17. package/mcp_server/tools/grooves.py +142 -0
  18. package/mcp_server/tools/miditool.py +280 -0
  19. package/mcp_server/tools/scales.py +126 -0
  20. package/mcp_server/tools/take_lanes.py +135 -0
  21. package/mcp_server/tools/tracks.py +46 -3
  22. package/mcp_server/tools/transport.py +62 -1
  23. package/package.json +2 -2
  24. package/remote_script/LivePilot/__init__.py +8 -4
  25. package/remote_script/LivePilot/clips.py +62 -0
  26. package/remote_script/LivePilot/devices.py +444 -0
  27. package/remote_script/LivePilot/diagnostics.py +52 -1
  28. package/remote_script/LivePilot/follow_actions.py +235 -0
  29. package/remote_script/LivePilot/grooves.py +185 -0
  30. package/remote_script/LivePilot/scales.py +138 -0
  31. package/remote_script/LivePilot/take_lanes.py +175 -0
  32. package/remote_script/LivePilot/tracks.py +59 -1
  33. package/remote_script/LivePilot/transport.py +90 -1
  34. package/remote_script/LivePilot/version_detect.py +9 -0
  35. package/server.json +3 -3
@@ -882,3 +882,447 @@ def set_chain_volume(song, params):
882
882
  "volume": chain.mixer_device.volume.value,
883
883
  "pan": chain.mixer_device.panning.value,
884
884
  }
885
+
886
+
887
+ # ── Rack Variations + Macro CRUD (Live 11+) ─────────────────────────────
888
+
889
+
890
+ def _get_rack(song, params):
891
+ """Resolve (track, device) and validate it is a Rack (can_have_chains)."""
892
+ track = get_track(song, int(params["track_index"]))
893
+ device = get_device(track, int(params["device_index"]))
894
+ if not getattr(device, "can_have_chains", False):
895
+ raise ValueError(
896
+ "Device '%s' is not a Rack (can_have_chains=False)" % device.name
897
+ )
898
+ return device
899
+
900
+
901
+ @register("get_rack_variations")
902
+ def get_rack_variations(song, params):
903
+ """Return variation count, selected index, and visible macro count for a Rack."""
904
+ from .version_detect import has_feature
905
+ if not has_feature("rack_variations_api"):
906
+ raise RuntimeError("Rack variations require Live 11+.")
907
+ rack = _get_rack(song, params)
908
+ return {
909
+ "count": int(getattr(rack, "variation_count", 0)),
910
+ "selected_index": int(getattr(rack, "selected_variation_index", -1)),
911
+ "visible_macro_count": int(getattr(rack, "visible_macro_count", 0)),
912
+ }
913
+
914
+
915
+ @register("store_rack_variation")
916
+ def store_rack_variation(song, params):
917
+ """Store current macro values as a new variation on a Rack."""
918
+ from .version_detect import has_feature
919
+ if not has_feature("rack_variations_api"):
920
+ raise RuntimeError("Rack variations require Live 11+.")
921
+ rack = _get_rack(song, params)
922
+ rack.store_variation()
923
+ count = int(rack.variation_count)
924
+ return {
925
+ "count": count,
926
+ "new_index": count - 1,
927
+ }
928
+
929
+
930
+ @register("recall_rack_variation")
931
+ def recall_rack_variation(song, params):
932
+ """Select and recall a variation on a Rack by index."""
933
+ from .version_detect import has_feature
934
+ if not has_feature("rack_variations_api"):
935
+ raise RuntimeError("Rack variations require Live 11+.")
936
+ rack = _get_rack(song, params)
937
+ idx = int(params["variation_index"])
938
+ count = int(rack.variation_count)
939
+ if count <= 0:
940
+ raise IndexError("Rack has no variations stored")
941
+ if not 0 <= idx < count:
942
+ raise IndexError(
943
+ "variation_index %d out of range (0..%d)" % (idx, count - 1)
944
+ )
945
+ rack.selected_variation_index = idx
946
+ rack.recall_selected_variation()
947
+ return {"selected_index": int(rack.selected_variation_index)}
948
+
949
+
950
+ @register("delete_rack_variation")
951
+ def delete_rack_variation(song, params):
952
+ """Delete a variation on a Rack by index (selects it first, then deletes)."""
953
+ from .version_detect import has_feature
954
+ if not has_feature("rack_variations_api"):
955
+ raise RuntimeError("Rack variations require Live 11+.")
956
+ rack = _get_rack(song, params)
957
+ idx = int(params["variation_index"])
958
+ count = int(rack.variation_count)
959
+ if count <= 0:
960
+ raise IndexError("Rack has no variations to delete")
961
+ if not 0 <= idx < count:
962
+ raise IndexError(
963
+ "variation_index %d out of range (0..%d)" % (idx, count - 1)
964
+ )
965
+ rack.selected_variation_index = idx
966
+ rack.delete_selected_variation()
967
+ return {"count": int(rack.variation_count)}
968
+
969
+
970
+ @register("randomize_rack_macros")
971
+ def randomize_rack_macros(song, params):
972
+ """Randomize the macro values on a Rack (Live's built-in dice)."""
973
+ from .version_detect import has_feature
974
+ if not has_feature("rack_variations_api"):
975
+ raise RuntimeError("Rack variations require Live 11+.")
976
+ rack = _get_rack(song, params)
977
+ rack.randomize_macros()
978
+ return {"ok": True}
979
+
980
+
981
+ @register("add_rack_macro")
982
+ def add_rack_macro(song, params):
983
+ """Add one macro to a Rack (raises visible_macro_count by 1, max 16)."""
984
+ from .version_detect import has_feature
985
+ if not has_feature("rack_variations_api"):
986
+ raise RuntimeError("Rack variations require Live 11+.")
987
+ rack = _get_rack(song, params)
988
+ rack.add_macro()
989
+ return {"visible_macro_count": int(rack.visible_macro_count)}
990
+
991
+
992
+ @register("remove_rack_macro")
993
+ def remove_rack_macro(song, params):
994
+ """Remove the last macro from a Rack (lowers visible_macro_count by 1, min 1)."""
995
+ from .version_detect import has_feature
996
+ if not has_feature("rack_variations_api"):
997
+ raise RuntimeError("Rack variations require Live 11+.")
998
+ rack = _get_rack(song, params)
999
+ rack.remove_macro()
1000
+ return {"visible_macro_count": int(rack.visible_macro_count)}
1001
+
1002
+
1003
+ @register("set_rack_visible_macros")
1004
+ def set_rack_visible_macros(song, params):
1005
+ """Set visible_macro_count on a Rack directly (1-16)."""
1006
+ from .version_detect import has_feature
1007
+ if not has_feature("rack_variations_api"):
1008
+ raise RuntimeError("Rack variations require Live 11+.")
1009
+ rack = _get_rack(song, params)
1010
+ count = int(params["count"])
1011
+ if not 1 <= count <= 16:
1012
+ raise ValueError("count must be 1-16")
1013
+ rack.visible_macro_count = count
1014
+ return {"visible_macro_count": int(rack.visible_macro_count)}
1015
+
1016
+
1017
+ # ── Simpler Slice CRUD (Live 11+) ───────────────────────────────────────
1018
+
1019
+
1020
+ def _get_simpler(song, params):
1021
+ """Resolve (track, device, sample) for a Simpler and validate.
1022
+
1023
+ Simpler's class_name is "OriginalSimpler". We match on "Simpler" so
1024
+ third-party simpler-like devices (if any ever surface) aren't silently
1025
+ accepted — but the common Original Simpler path is covered.
1026
+ """
1027
+ track = get_track(song, int(params["track_index"]))
1028
+ device = get_device(track, int(params["device_index"]))
1029
+ if "Simpler" not in str(getattr(device, "class_name", "")):
1030
+ raise ValueError(
1031
+ "Device at %d is not a Simpler (class_name=%s)"
1032
+ % (int(params["device_index"]),
1033
+ getattr(device, "class_name", "?"))
1034
+ )
1035
+ sample = getattr(device, "sample", None)
1036
+ if sample is None:
1037
+ raise RuntimeError("Simpler has no sample loaded")
1038
+ return device, sample
1039
+
1040
+
1041
+ @register("insert_simpler_slice")
1042
+ def insert_simpler_slice(song, params):
1043
+ """Insert a slice at the given sample-frame position."""
1044
+ from .version_detect import has_feature
1045
+ if not has_feature("simpler_slice_crud"):
1046
+ raise RuntimeError("Simpler slice CRUD requires Live 11+.")
1047
+ device, sample = _get_simpler(song, params)
1048
+ t = int(params["time_samples"])
1049
+ if t < 0:
1050
+ raise ValueError("time_samples must be >= 0")
1051
+ sample.insert_slice(t)
1052
+ slices = list(getattr(sample, "slices", []))
1053
+ return {"slice_count": len(slices)}
1054
+
1055
+
1056
+ @register("move_simpler_slice")
1057
+ def move_simpler_slice(song, params):
1058
+ """Move a slice from old_time_samples to new_time_samples (both sample frames)."""
1059
+ from .version_detect import has_feature
1060
+ if not has_feature("simpler_slice_crud"):
1061
+ raise RuntimeError("Simpler slice CRUD requires Live 11+.")
1062
+ device, sample = _get_simpler(song, params)
1063
+ old_t = int(params["old_time_samples"])
1064
+ new_t = int(params["new_time_samples"])
1065
+ if old_t < 0 or new_t < 0:
1066
+ raise ValueError("time values must be >= 0")
1067
+ sample.move_slice(old_t, new_t)
1068
+ return {"ok": True, "old_time_samples": old_t, "new_time_samples": new_t}
1069
+
1070
+
1071
+ @register("remove_simpler_slice")
1072
+ def remove_simpler_slice(song, params):
1073
+ """Remove the slice at the exact sample-frame position."""
1074
+ from .version_detect import has_feature
1075
+ if not has_feature("simpler_slice_crud"):
1076
+ raise RuntimeError("Simpler slice CRUD requires Live 11+.")
1077
+ device, sample = _get_simpler(song, params)
1078
+ t = int(params["time_samples"])
1079
+ sample.remove_slice(t)
1080
+ slices = list(getattr(sample, "slices", []))
1081
+ return {"slice_count": len(slices)}
1082
+
1083
+
1084
+ @register("clear_simpler_slices")
1085
+ def clear_simpler_slices(song, params):
1086
+ """Remove all manual slices from the Simpler."""
1087
+ from .version_detect import has_feature
1088
+ if not has_feature("simpler_slice_crud"):
1089
+ raise RuntimeError("Simpler slice CRUD requires Live 11+.")
1090
+ device, sample = _get_simpler(song, params)
1091
+ sample.clear_slices()
1092
+ return {"slice_count": 0}
1093
+
1094
+
1095
+ @register("reset_simpler_slices")
1096
+ def reset_simpler_slices(song, params):
1097
+ """Reset slices to Live's default detection for the current slicing_style."""
1098
+ from .version_detect import has_feature
1099
+ if not has_feature("simpler_slice_crud"):
1100
+ raise RuntimeError("Simpler slice CRUD requires Live 11+.")
1101
+ device, sample = _get_simpler(song, params)
1102
+ sample.reset_slices()
1103
+ slices = list(getattr(sample, "slices", []))
1104
+ return {"slice_count": len(slices)}
1105
+
1106
+
1107
+ @register("import_slices_from_onsets")
1108
+ def import_slices_from_onsets(song, params):
1109
+ """Set Transient-mode slicing and trigger re-detection.
1110
+
1111
+ Writes slicing_style=0 (Transient) and slicing_sensitivity, then calls
1112
+ reset_slices() so Live re-scans the sample with the new settings.
1113
+ Returns the resulting slice_count and the sensitivity that was applied.
1114
+ """
1115
+ from .version_detect import has_feature
1116
+ if not has_feature("simpler_slice_crud"):
1117
+ raise RuntimeError("Simpler slice CRUD requires Live 11+.")
1118
+ device, sample = _get_simpler(song, params)
1119
+ sensitivity = float(params.get("sensitivity", 0.5))
1120
+ if not 0.0 <= sensitivity <= 1.0:
1121
+ raise ValueError("sensitivity must be 0.0-1.0")
1122
+ # slicing_style: 0=Transient, 1=Beats, 2=Region, 3=Manual
1123
+ if hasattr(sample, "slicing_style"):
1124
+ sample.slicing_style = 0
1125
+ if hasattr(sample, "slicing_sensitivity"):
1126
+ sample.slicing_sensitivity = sensitivity
1127
+ if hasattr(sample, "reset_slices"):
1128
+ sample.reset_slices()
1129
+ slices = list(getattr(sample, "slices", []))
1130
+ return {"slice_count": len(slices), "sensitivity": sensitivity}
1131
+
1132
+
1133
+ # ── Wavetable modulation matrix (Live 11+) ──────────────────────────────
1134
+
1135
+ _WAVETABLE_SOURCES = [
1136
+ "Env 2", "Env 3", "LFO 1", "LFO 2",
1137
+ "MIDI Key", "MIDI Velocity", "MIDI Aftertouch", "MIDI Pitchbend",
1138
+ "Macro 1", "Macro 2", "Macro 3", "Macro 4",
1139
+ "Macro 5", "Macro 6", "Macro 7", "Macro 8",
1140
+ ]
1141
+
1142
+
1143
+ def _get_wavetable(song, params):
1144
+ track = get_track(song, int(params["track_index"]))
1145
+ device = get_device(track, int(params["device_index"]))
1146
+ class_name = str(getattr(device, "class_name", ""))
1147
+ if "Wavetable" not in class_name:
1148
+ raise ValueError("Device at index %d is not Wavetable (class_name=%s)"
1149
+ % (int(params["device_index"]), class_name))
1150
+ return device
1151
+
1152
+
1153
+ @register("get_wavetable_mod_targets")
1154
+ def get_wavetable_mod_targets(song, params):
1155
+ from .version_detect import has_feature
1156
+ if not has_feature("wavetable_mod_matrix"):
1157
+ raise RuntimeError("Wavetable modulation matrix requires Live 11+.")
1158
+ wt = _get_wavetable(song, params)
1159
+ targets = list(getattr(wt, "visible_modulation_target_names", []))
1160
+ return {"targets": [str(t) for t in targets]}
1161
+
1162
+
1163
+ @register("add_wavetable_mod_route")
1164
+ def add_wavetable_mod_route(song, params):
1165
+ from .version_detect import has_feature
1166
+ if not has_feature("wavetable_mod_matrix"):
1167
+ raise RuntimeError("Wavetable modulation matrix requires Live 11+.")
1168
+ wt = _get_wavetable(song, params)
1169
+ source = str(params["source"])
1170
+ target = str(params["target"])
1171
+ if not hasattr(wt, "add_parameter_to_modulation_matrix"):
1172
+ raise RuntimeError("add_parameter_to_modulation_matrix not exposed")
1173
+ wt.add_parameter_to_modulation_matrix(source, target)
1174
+ actual = ""
1175
+ if hasattr(wt, "get_modulation_target_parameter_name"):
1176
+ actual = str(wt.get_modulation_target_parameter_name(source, target))
1177
+ return {"source": source, "target": target, "actual_target": actual}
1178
+
1179
+
1180
+ @register("set_wavetable_mod_amount")
1181
+ def set_wavetable_mod_amount(song, params):
1182
+ from .version_detect import has_feature
1183
+ if not has_feature("wavetable_mod_matrix"):
1184
+ raise RuntimeError("Wavetable modulation matrix requires Live 11+.")
1185
+ wt = _get_wavetable(song, params)
1186
+ source = str(params["source"])
1187
+ target = str(params["target"])
1188
+ amount = float(params["amount"])
1189
+ if not -1.0 <= amount <= 1.0:
1190
+ raise ValueError("amount must be -1.0 to 1.0")
1191
+ if not hasattr(wt, "set_modulation_value"):
1192
+ raise RuntimeError("set_modulation_value not exposed")
1193
+ wt.set_modulation_value(source, target, amount)
1194
+ return {"source": source, "target": target, "amount": amount}
1195
+
1196
+
1197
+ @register("get_wavetable_mod_amount")
1198
+ def get_wavetable_mod_amount(song, params):
1199
+ from .version_detect import has_feature
1200
+ if not has_feature("wavetable_mod_matrix"):
1201
+ raise RuntimeError("Wavetable modulation matrix requires Live 11+.")
1202
+ wt = _get_wavetable(song, params)
1203
+ source = str(params["source"])
1204
+ target = str(params["target"])
1205
+ amount = 0.0
1206
+ actual = ""
1207
+ if hasattr(wt, "get_modulation_value"):
1208
+ amount = float(wt.get_modulation_value(source, target))
1209
+ if hasattr(wt, "get_modulation_target_parameter_name"):
1210
+ actual = str(wt.get_modulation_target_parameter_name(source, target))
1211
+ return {"source": source, "target": target, "amount": amount,
1212
+ "actual_target": actual}
1213
+
1214
+
1215
+ @register("get_wavetable_mod_matrix")
1216
+ def get_wavetable_mod_matrix(song, params):
1217
+ """Dump all non-zero modulation routings on this Wavetable."""
1218
+ from .version_detect import has_feature
1219
+ if not has_feature("wavetable_mod_matrix"):
1220
+ raise RuntimeError("Wavetable modulation matrix requires Live 11+.")
1221
+ wt = _get_wavetable(song, params)
1222
+ targets = list(getattr(wt, "visible_modulation_target_names", []))
1223
+ routings = []
1224
+ if hasattr(wt, "get_modulation_value"):
1225
+ for src in _WAVETABLE_SOURCES:
1226
+ for tgt in targets:
1227
+ try:
1228
+ amt = float(wt.get_modulation_value(src, tgt))
1229
+ except Exception:
1230
+ continue
1231
+ if abs(amt) > 1e-6:
1232
+ routings.append({"source": src, "target": str(tgt),
1233
+ "amount": amt})
1234
+ return {"routings": routings}
1235
+
1236
+
1237
+ # ── Device A/B compare (Live 12.3+) ─────────────────────────────────────
1238
+ #
1239
+ # Live 12.3 added A/B compare on every device but the LOM surface was not
1240
+ # comprehensively documented in the 12.3 release notes. The exact attribute
1241
+ # names vary across 12.3.x patches, so all three handlers use hasattr probes
1242
+ # over several plausible names. If the Live build doesn't expose the API,
1243
+ # get_device_ab_state returns "unknown" with a diagnostic note and the
1244
+ # toggle/copy handlers raise a clear error instead of faking the behavior
1245
+ # through UI shortcuts.
1246
+
1247
+ def _probe_ab_attr(device, *names):
1248
+ """Return the first attribute name that exists on device, or None."""
1249
+ for n in names:
1250
+ if hasattr(device, n):
1251
+ return n
1252
+ return None
1253
+
1254
+
1255
+ @register("get_device_ab_state")
1256
+ def get_device_ab_state(song, params):
1257
+ from .version_detect import has_feature
1258
+ if not has_feature("device_ab_compare"):
1259
+ raise RuntimeError("Device A/B compare requires Live 12.3+.")
1260
+ track = get_track(song, int(params["track_index"]))
1261
+ device = get_device(track, int(params["device_index"]))
1262
+
1263
+ # Probe for the A/B state attribute — names vary by Live 12.3.x patch
1264
+ state_attr = _probe_ab_attr(device, "ab_state", "current_ab_state",
1265
+ "ab_current", "compare_state")
1266
+ has_b_attr = _probe_ab_attr(device, "has_b_state", "has_ab_state",
1267
+ "ab_has_b")
1268
+ if state_attr is None:
1269
+ return {"current_state": "unknown", "has_b": False,
1270
+ "note": "LOM A/B attribute not exposed; UI-only in this Live build."}
1271
+ state_val = getattr(device, state_attr)
1272
+ # Normalize boolean or int to "A"/"B"
1273
+ if isinstance(state_val, bool):
1274
+ current = "B" if state_val else "A"
1275
+ elif isinstance(state_val, int):
1276
+ current = "B" if state_val else "A"
1277
+ else:
1278
+ current = str(state_val)
1279
+ has_b = bool(getattr(device, has_b_attr, True)) if has_b_attr else True
1280
+ return {"current_state": current, "has_b": has_b}
1281
+
1282
+
1283
+ @register("toggle_device_ab")
1284
+ def toggle_device_ab(song, params):
1285
+ from .version_detect import has_feature
1286
+ if not has_feature("device_ab_compare"):
1287
+ raise RuntimeError("Device A/B compare requires Live 12.3+.")
1288
+ track = get_track(song, int(params["track_index"]))
1289
+ device = get_device(track, int(params["device_index"]))
1290
+
1291
+ toggle_fn = _probe_ab_attr(device, "toggle_ab", "toggle_ab_state",
1292
+ "swap_ab")
1293
+ if toggle_fn is None:
1294
+ raise RuntimeError(
1295
+ "Device has no A/B toggle method in this Live version."
1296
+ )
1297
+ getattr(device, toggle_fn)()
1298
+ # Re-read state (best-effort)
1299
+ state_attr = _probe_ab_attr(device, "ab_state", "current_ab_state",
1300
+ "ab_current", "compare_state")
1301
+ if state_attr is None:
1302
+ return {"current_state": "unknown"}
1303
+ v = getattr(device, state_attr)
1304
+ current = ("B" if (v if isinstance(v, bool) else bool(v)) else "A")
1305
+ return {"current_state": current}
1306
+
1307
+
1308
+ @register("copy_device_state")
1309
+ def copy_device_state(song, params):
1310
+ from .version_detect import has_feature
1311
+ if not has_feature("device_ab_compare"):
1312
+ raise RuntimeError("Device A/B compare requires Live 12.3+.")
1313
+ track = get_track(song, int(params["track_index"]))
1314
+ device = get_device(track, int(params["device_index"]))
1315
+
1316
+ direction = str(params["direction"]).lower()
1317
+ if direction == "a_to_b":
1318
+ fn = _probe_ab_attr(device, "copy_a_to_b", "copy_to_b")
1319
+ elif direction == "b_to_a":
1320
+ fn = _probe_ab_attr(device, "copy_b_to_a", "copy_to_a")
1321
+ else:
1322
+ raise ValueError("direction must be 'a_to_b' or 'b_to_a'")
1323
+ if fn is None:
1324
+ raise RuntimeError(
1325
+ "Device has no copy-%s method in this Live version." % direction
1326
+ )
1327
+ getattr(device, fn)()
1328
+ return {"ok": True, "direction": direction}
@@ -1,9 +1,12 @@
1
1
  """
2
- LivePilot - Session diagnostics handler (1 command).
2
+ LivePilot - Session diagnostics handler (3 commands).
3
3
 
4
4
  Analyzes the current session and flags potential issues:
5
5
  armed tracks, mute/solo leftovers, empty clips, unnamed tracks,
6
6
  empty scenes, and device-less instrument tracks.
7
+
8
+ Also enumerates active ControlSurface instances (Push, APC, Launchkey,
9
+ etc.) so the agent can reason about user-facing hardware.
7
10
  """
8
11
 
9
12
  from .router import register
@@ -272,3 +275,51 @@ def get_session_diagnostics(song, params):
272
275
  "issues": issues,
273
276
  "stats": stats,
274
277
  }
278
+
279
+
280
+ # ── ControlSurface enumeration ──────────────────────────────────────────
281
+ # Read-only listing of active control surfaces (Push, APC, Launchkey,
282
+ # Launchpad, etc.). Import Live lazily inside each handler — the rest of
283
+ # this module operates on the `song` passed in and doesn't need the Live
284
+ # package at import time. Keeping the import local preserves that.
285
+
286
+
287
+ @register("list_control_surfaces")
288
+ def list_control_surfaces(song, params):
289
+ """Enumerate all active Live.ControlSurface instances."""
290
+ from Live import Application
291
+ app = Application.get_application()
292
+ surfaces = list(getattr(app, "control_surfaces", []) or [])
293
+ out = []
294
+ for i, cs in enumerate(surfaces):
295
+ out.append({
296
+ "index": i,
297
+ "name": str(getattr(cs, "name", "") or cs.__class__.__name__),
298
+ "class_name": cs.__class__.__name__,
299
+ })
300
+ return {"surfaces": out}
301
+
302
+
303
+ @register("get_control_surface_info")
304
+ def get_control_surface_info(song, params):
305
+ """Return detailed info about a single control surface by index."""
306
+ from Live import Application
307
+ app = Application.get_application()
308
+ surfaces = list(getattr(app, "control_surfaces", []) or [])
309
+ idx = int(params["index"])
310
+ if not 0 <= idx < len(surfaces):
311
+ upper = len(surfaces) - 1 if surfaces else -1
312
+ raise IndexError("index %d out of range (0..%d)" % (idx, upper))
313
+ cs = surfaces[idx]
314
+ component_count = 0
315
+ if hasattr(cs, "components"):
316
+ try:
317
+ component_count = len(list(cs.components))
318
+ except Exception:
319
+ component_count = 0
320
+ return {
321
+ "index": idx,
322
+ "name": str(getattr(cs, "name", "") or cs.__class__.__name__),
323
+ "class_name": cs.__class__.__name__,
324
+ "component_count": component_count,
325
+ }