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.
- package/CHANGELOG.md +245 -0
- package/README.md +7 -7
- 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/m4l_bridge.py +488 -13
- package/mcp_server/runtime/execution_router.py +7 -0
- package/mcp_server/runtime/mcp_dispatch.py +32 -0
- package/mcp_server/runtime/remote_commands.py +54 -0
- package/mcp_server/sample_engine/slice_classifier.py +169 -0
- package/mcp_server/server.py +11 -3
- package/mcp_server/tools/analyzer.py +187 -7
- package/mcp_server/tools/clips.py +65 -0
- package/mcp_server/tools/devices.py +517 -5
- package/mcp_server/tools/diagnostics.py +42 -0
- package/mcp_server/tools/follow_actions.py +202 -0
- package/mcp_server/tools/grooves.py +142 -0
- package/mcp_server/tools/miditool.py +280 -0
- package/mcp_server/tools/scales.py +126 -0
- package/mcp_server/tools/take_lanes.py +135 -0
- package/mcp_server/tools/tracks.py +46 -3
- package/mcp_server/tools/transport.py +62 -1
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +8 -4
- package/remote_script/LivePilot/clips.py +62 -0
- package/remote_script/LivePilot/devices.py +444 -0
- package/remote_script/LivePilot/diagnostics.py +52 -1
- package/remote_script/LivePilot/follow_actions.py +235 -0
- package/remote_script/LivePilot/grooves.py +185 -0
- package/remote_script/LivePilot/scales.py +138 -0
- package/remote_script/LivePilot/take_lanes.py +175 -0
- package/remote_script/LivePilot/tracks.py +59 -1
- package/remote_script/LivePilot/transport.py +90 -1
- package/remote_script/LivePilot/version_detect.py +9 -0
- 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 (
|
|
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
|
+
}
|