livepilot 1.1.0

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 (63) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.mcpregistry_github_token +1 -0
  3. package/.mcpregistry_registry_token +1 -0
  4. package/.playwright-mcp/console-2026-03-17T15-47-29-021Z.log +10 -0
  5. package/.playwright-mcp/console-2026-03-17T15-51-09-247Z.log +10 -0
  6. package/.playwright-mcp/console-2026-03-17T15-52-22-831Z.log +12 -0
  7. package/.playwright-mcp/console-2026-03-17T15-52-29-709Z.log +10 -0
  8. package/.playwright-mcp/console-2026-03-17T15-53-20-147Z.log +1 -0
  9. package/.playwright-mcp/glama-snapshot.md +2140 -0
  10. package/.playwright-mcp/page-2026-03-17T15-49-02-625Z.png +0 -0
  11. package/.playwright-mcp/page-2026-03-17T15-52-15-149Z.png +0 -0
  12. package/.playwright-mcp/page-2026-03-17T15-52-57-333Z.png +0 -0
  13. package/CHANGELOG.md +33 -0
  14. package/LICENSE +21 -0
  15. package/README.md +296 -0
  16. package/bin/livepilot.js +376 -0
  17. package/installer/install.js +95 -0
  18. package/installer/paths.js +79 -0
  19. package/mcp_server/__init__.py +2 -0
  20. package/mcp_server/__main__.py +5 -0
  21. package/mcp_server/connection.py +207 -0
  22. package/mcp_server/server.py +40 -0
  23. package/mcp_server/tools/__init__.py +1 -0
  24. package/mcp_server/tools/arrangement.py +399 -0
  25. package/mcp_server/tools/browser.py +78 -0
  26. package/mcp_server/tools/clips.py +187 -0
  27. package/mcp_server/tools/devices.py +238 -0
  28. package/mcp_server/tools/mixing.py +113 -0
  29. package/mcp_server/tools/notes.py +266 -0
  30. package/mcp_server/tools/scenes.py +63 -0
  31. package/mcp_server/tools/tracks.py +148 -0
  32. package/mcp_server/tools/transport.py +113 -0
  33. package/package.json +38 -0
  34. package/plugin/.mcp.json +8 -0
  35. package/plugin/agents/livepilot-producer/AGENT.md +61 -0
  36. package/plugin/commands/beat.md +18 -0
  37. package/plugin/commands/mix.md +15 -0
  38. package/plugin/commands/session.md +13 -0
  39. package/plugin/commands/sounddesign.md +16 -0
  40. package/plugin/plugin.json +18 -0
  41. package/plugin/skills/livepilot-core/SKILL.md +160 -0
  42. package/plugin/skills/livepilot-core/references/ableton-workflow-patterns.md +831 -0
  43. package/plugin/skills/livepilot-core/references/m4l-devices.md +352 -0
  44. package/plugin/skills/livepilot-core/references/midi-recipes.md +402 -0
  45. package/plugin/skills/livepilot-core/references/mixing-patterns.md +578 -0
  46. package/plugin/skills/livepilot-core/references/overview.md +191 -0
  47. package/plugin/skills/livepilot-core/references/sound-design.md +392 -0
  48. package/remote_script/LivePilot/__init__.py +42 -0
  49. package/remote_script/LivePilot/arrangement.py +678 -0
  50. package/remote_script/LivePilot/browser.py +325 -0
  51. package/remote_script/LivePilot/clips.py +172 -0
  52. package/remote_script/LivePilot/devices.py +466 -0
  53. package/remote_script/LivePilot/diagnostics.py +198 -0
  54. package/remote_script/LivePilot/mixing.py +194 -0
  55. package/remote_script/LivePilot/notes.py +339 -0
  56. package/remote_script/LivePilot/router.py +74 -0
  57. package/remote_script/LivePilot/scenes.py +75 -0
  58. package/remote_script/LivePilot/server.py +286 -0
  59. package/remote_script/LivePilot/tracks.py +229 -0
  60. package/remote_script/LivePilot/transport.py +147 -0
  61. package/remote_script/LivePilot/utils.py +112 -0
  62. package/requirements.txt +2 -0
  63. package/server.json +20 -0
@@ -0,0 +1,466 @@
1
+ """
2
+ LivePilot - Device domain handlers (11 commands).
3
+ """
4
+
5
+ import Live
6
+
7
+ from .router import register
8
+ from .utils import get_track, get_device
9
+
10
+
11
+ def _get_browser():
12
+ """Get the browser from the Application object (not Song)."""
13
+ return Live.Application.get_application().browser
14
+
15
+
16
+ @register("get_device_info")
17
+ def get_device_info(song, params):
18
+ """Return detailed info for a single device."""
19
+ track_index = int(params["track_index"])
20
+ device_index = int(params["device_index"])
21
+ track = get_track(song, track_index)
22
+ device = get_device(track, device_index)
23
+
24
+ result = {
25
+ "name": device.name,
26
+ "class_name": device.class_name,
27
+ "is_active": device.is_active,
28
+ "can_have_chains": device.can_have_chains,
29
+ "parameter_count": len(list(device.parameters)),
30
+ }
31
+ try:
32
+ result["type"] = device.type
33
+ except Exception:
34
+ result["type"] = None
35
+ return result
36
+
37
+
38
+ @register("get_device_parameters")
39
+ def get_device_parameters(song, params):
40
+ """Return all parameters for a device."""
41
+ track_index = int(params["track_index"])
42
+ device_index = int(params["device_index"])
43
+ track = get_track(song, track_index)
44
+ device = get_device(track, device_index)
45
+
46
+ parameters = []
47
+ for i, param in enumerate(device.parameters):
48
+ parameters.append({
49
+ "index": i,
50
+ "name": param.name,
51
+ "value": param.value,
52
+ "min": param.min,
53
+ "max": param.max,
54
+ "is_quantized": param.is_quantized,
55
+ "value_string": param.str_for_value(param.value),
56
+ })
57
+ return {"parameters": parameters}
58
+
59
+
60
+ @register("set_device_parameter")
61
+ def set_device_parameter(song, params):
62
+ """Set a single device parameter by name or index."""
63
+ track_index = int(params["track_index"])
64
+ device_index = int(params["device_index"])
65
+ value = float(params["value"])
66
+ track = get_track(song, track_index)
67
+ device = get_device(track, device_index)
68
+
69
+ parameter_name = params.get("parameter_name", None)
70
+ parameter_index = params.get("parameter_index", None)
71
+
72
+ if parameter_name is not None:
73
+ param = None
74
+ for p in device.parameters:
75
+ if p.name == parameter_name:
76
+ param = p
77
+ break
78
+ if param is None:
79
+ raise ValueError(
80
+ "Parameter '%s' not found on device '%s'"
81
+ % (parameter_name, device.name)
82
+ )
83
+ elif parameter_index is not None:
84
+ parameter_index = int(parameter_index)
85
+ dev_params = list(device.parameters)
86
+ if parameter_index < 0 or parameter_index >= len(dev_params):
87
+ raise IndexError(
88
+ "Parameter index %d out of range (0..%d)"
89
+ % (parameter_index, len(dev_params) - 1)
90
+ )
91
+ param = dev_params[parameter_index]
92
+ else:
93
+ raise ValueError("Must provide parameter_name or parameter_index")
94
+
95
+ param.value = value
96
+ return {"name": param.name, "value": param.value}
97
+
98
+
99
+ @register("batch_set_parameters")
100
+ def batch_set_parameters(song, params):
101
+ """Set multiple device parameters in one call."""
102
+ track_index = int(params["track_index"])
103
+ device_index = int(params["device_index"])
104
+ parameters = params["parameters"]
105
+ track = get_track(song, track_index)
106
+ device = get_device(track, device_index)
107
+
108
+ dev_params = list(device.parameters)
109
+ results = []
110
+ for entry in parameters:
111
+ value = float(entry["value"])
112
+ name_or_index = entry.get("name_or_index")
113
+
114
+ if isinstance(name_or_index, int) or (
115
+ isinstance(name_or_index, str) and name_or_index.isdigit()
116
+ ):
117
+ idx = int(name_or_index)
118
+ if idx < 0 or idx >= len(dev_params):
119
+ raise IndexError(
120
+ "Parameter index %d out of range (0..%d)"
121
+ % (idx, len(dev_params) - 1)
122
+ )
123
+ param = dev_params[idx]
124
+ else:
125
+ param = None
126
+ for p in dev_params:
127
+ if p.name == name_or_index:
128
+ param = p
129
+ break
130
+ if param is None:
131
+ raise ValueError(
132
+ "Parameter '%s' not found on device '%s'"
133
+ % (name_or_index, device.name)
134
+ )
135
+
136
+ param.value = value
137
+ results.append({"name": param.name, "value": param.value})
138
+
139
+ return {"parameters": results}
140
+
141
+
142
+ @register("toggle_device")
143
+ def toggle_device(song, params):
144
+ """Enable or disable a device."""
145
+ track_index = int(params["track_index"])
146
+ device_index = int(params["device_index"])
147
+ active = bool(params["active"])
148
+ track = get_track(song, track_index)
149
+ device = get_device(track, device_index)
150
+
151
+ # Find the "Device On" parameter by name (safer than assuming index 0)
152
+ on_param = None
153
+ for p in device.parameters:
154
+ if p.name == "Device On":
155
+ on_param = p
156
+ break
157
+ if on_param is None:
158
+ # Fallback to parameter 0 for devices that don't use "Device On"
159
+ on_param = device.parameters[0]
160
+
161
+ on_param.value = 1.0 if active else 0.0
162
+ return {"name": device.name, "is_active": on_param.value > 0.5}
163
+
164
+
165
+ @register("delete_device")
166
+ def delete_device(song, params):
167
+ """Delete a device from a track."""
168
+ track_index = int(params["track_index"])
169
+ device_index = int(params["device_index"])
170
+ track = get_track(song, track_index)
171
+ # Validate device exists
172
+ get_device(track, device_index)
173
+ track.delete_device(device_index)
174
+ return {"deleted": device_index}
175
+
176
+
177
+ @register("load_device_by_uri")
178
+ def load_device_by_uri(song, params):
179
+ """Load a device onto a track using a browser URI.
180
+
181
+ First tries URI-based matching (exact child.uri comparison).
182
+ Falls back to name extraction from the URI's last path segment.
183
+ Searches all browser categories including user_library and samples.
184
+ """
185
+ track_index = int(params["track_index"])
186
+ uri = str(params["uri"])
187
+ track = get_track(song, track_index)
188
+ browser = _get_browser()
189
+
190
+ # All categories to search
191
+ category_attrs = (
192
+ "user_library", "samples", "instruments", "audio_effects",
193
+ "midi_effects", "packs", "sounds", "drums",
194
+ )
195
+ categories = []
196
+ for attr in category_attrs:
197
+ try:
198
+ categories.append(getattr(browser, attr))
199
+ except Exception:
200
+ pass
201
+
202
+ _iterations = [0]
203
+ MAX_ITERATIONS = 10000
204
+
205
+ # ── Strategy 1: match by URI directly ────────────────────────────
206
+ def find_by_uri(parent, target_uri, depth=0):
207
+ if depth > 8 or _iterations[0] > MAX_ITERATIONS:
208
+ return None
209
+ try:
210
+ children = list(parent.children)
211
+ except Exception:
212
+ return None
213
+ for child in children:
214
+ _iterations[0] += 1
215
+ if _iterations[0] > MAX_ITERATIONS:
216
+ return None
217
+ try:
218
+ if child.uri == target_uri and child.is_loadable:
219
+ return child
220
+ except Exception:
221
+ pass
222
+ result = find_by_uri(child, target_uri, depth + 1)
223
+ if result is not None:
224
+ return result
225
+ return None
226
+
227
+ for category in categories:
228
+ _iterations[0] = 0
229
+ found = find_by_uri(category, uri)
230
+ if found is not None:
231
+ song.view.selected_track = track
232
+ browser.load_item(found)
233
+ return {"loaded": found.name, "track_index": track_index}
234
+
235
+ # ── Strategy 2: extract name from URI, search by name ────────────
236
+ device_name = uri
237
+ if "#" in uri:
238
+ device_name = uri.split("#", 1)[1]
239
+ for sep in (":", "/"):
240
+ if sep in device_name:
241
+ device_name = device_name.rsplit(sep, 1)[1]
242
+ # URL-decode
243
+ try:
244
+ from urllib.parse import unquote
245
+ device_name = unquote(device_name)
246
+ except ImportError:
247
+ device_name = device_name.replace("%20", " ")
248
+ # Strip file extensions
249
+ for ext in (".amxd", ".adv", ".adg", ".aupreset", ".als", ".wav", ".aif", ".aiff", ".mp3"):
250
+ if device_name.lower().endswith(ext):
251
+ device_name = device_name[:-len(ext)]
252
+ break
253
+
254
+ target = device_name.lower()
255
+
256
+ def find_by_name(parent, depth=0):
257
+ if depth > 8 or _iterations[0] > MAX_ITERATIONS:
258
+ return None
259
+ try:
260
+ children = list(parent.children)
261
+ except Exception:
262
+ return None
263
+ for child in children:
264
+ _iterations[0] += 1
265
+ if _iterations[0] > MAX_ITERATIONS:
266
+ return None
267
+ child_lower = child.name.lower()
268
+ if (child_lower == target or target in child_lower) and child.is_loadable:
269
+ return child
270
+ result = find_by_name(child, depth + 1)
271
+ if result is not None:
272
+ return result
273
+ return None
274
+
275
+ for category in categories:
276
+ _iterations[0] = 0
277
+ found = find_by_name(category)
278
+ if found is not None:
279
+ song.view.selected_track = track
280
+ browser.load_item(found)
281
+ return {"loaded": found.name, "track_index": track_index}
282
+
283
+ raise ValueError(
284
+ "Device '%s' not found in browser" % device_name
285
+ )
286
+
287
+
288
+ @register("find_and_load_device")
289
+ def find_and_load_device(song, params):
290
+ """Find a device by name in the browser and load it onto a track.
291
+
292
+ Searches all browser categories including user_library for M4L devices.
293
+ Supports partial matching: 'Kickster' matches 'trnr.Kickster'.
294
+ """
295
+ track_index = int(params["track_index"])
296
+ device_name = str(params["device_name"]).lower()
297
+ track = get_track(song, track_index)
298
+ browser = _get_browser()
299
+
300
+ MAX_ITERATIONS = 10000
301
+ iterations = 0
302
+
303
+ def search_children(item, depth=0):
304
+ """Recursively search browser children up to depth 8."""
305
+ nonlocal iterations
306
+ if depth > 8:
307
+ return None
308
+ try:
309
+ children = list(item.children)
310
+ except Exception:
311
+ return None
312
+ for child in children:
313
+ iterations += 1
314
+ if iterations > MAX_ITERATIONS:
315
+ return None
316
+ child_lower = child.name.lower()
317
+ # Exact match or partial match (e.g. "kickster" in "trnr.kickster")
318
+ if (child_lower == device_name or device_name in child_lower) and child.is_loadable:
319
+ return child
320
+ result = search_children(child, depth + 1)
321
+ if result is not None:
322
+ return result
323
+ return None
324
+
325
+ # Search ALL categories — user_library first (M4L devices live there)
326
+ category_attrs = (
327
+ "user_library", "samples", "instruments", "audio_effects",
328
+ "midi_effects", "packs", "sounds", "drums",
329
+ )
330
+ categories = []
331
+ for attr in category_attrs:
332
+ try:
333
+ categories.append(getattr(browser, attr))
334
+ except Exception:
335
+ pass
336
+
337
+ for category in categories:
338
+ iterations = 0 # Reset per category so each gets a fair search budget
339
+ found = search_children(category)
340
+ if found is not None:
341
+ song.view.selected_track = track
342
+ browser.load_item(found)
343
+ return {
344
+ "loaded": found.name,
345
+ "track_index": track_index,
346
+ }
347
+
348
+ raise ValueError(
349
+ "Device '%s' not found in browser. Check spelling or use "
350
+ "search_browser to find the exact name." % params["device_name"]
351
+ )
352
+
353
+
354
+ @register("set_simpler_playback_mode")
355
+ def set_simpler_playback_mode(song, params):
356
+ """Set Simpler's playback mode (Classic/One-Shot/Slice).
357
+
358
+ playback_mode: 0=Classic, 1=One-Shot, 2=Slice
359
+ slice_by (optional, only for Slice mode): 0=Transient, 1=Beat, 2=Region, 3=Manual
360
+ sensitivity (optional, 0.0-1.0, only for Transient slicing)
361
+ """
362
+ track_index = int(params["track_index"])
363
+ device_index = int(params["device_index"])
364
+ playback_mode = int(params["playback_mode"])
365
+ track = get_track(song, track_index)
366
+ device = get_device(track, device_index)
367
+
368
+ if device.class_name != "OriginalSimpler":
369
+ raise ValueError(
370
+ "Device '%s' is %s, not Simpler"
371
+ % (device.name, device.class_name)
372
+ )
373
+ if playback_mode not in (0, 1, 2):
374
+ raise ValueError("playback_mode must be 0 (Classic), 1 (One-Shot), or 2 (Slice)")
375
+
376
+ device.playback_mode = playback_mode
377
+
378
+ result = {
379
+ "track_index": track_index,
380
+ "device_index": device_index,
381
+ "playback_mode": playback_mode,
382
+ "mode_name": ["Classic", "One-Shot", "Slice"][playback_mode],
383
+ }
384
+
385
+ # Set slicing style if in Slice mode
386
+ if playback_mode == 2:
387
+ slice_by = params.get("slice_by", None)
388
+ if slice_by is not None:
389
+ slice_by = int(slice_by)
390
+ if slice_by not in (0, 1, 2, 3):
391
+ raise ValueError(
392
+ "slice_by must be 0 (Transient), 1 (Beat), 2 (Region), or 3 (Manual)"
393
+ )
394
+ device.slicing_style = slice_by
395
+ result["slice_by"] = slice_by
396
+ result["slice_by_name"] = ["Transient", "Beat", "Region", "Manual"][slice_by]
397
+
398
+ sensitivity = params.get("sensitivity", None)
399
+ if sensitivity is not None:
400
+ sensitivity = float(sensitivity)
401
+ device.slicing_sensitivity = max(0.0, min(1.0, sensitivity))
402
+ result["sensitivity"] = device.slicing_sensitivity
403
+
404
+ return result
405
+
406
+
407
+ @register("get_rack_chains")
408
+ def get_rack_chains(song, params):
409
+ """Return chain info for a rack device."""
410
+ track_index = int(params["track_index"])
411
+ device_index = int(params["device_index"])
412
+ track = get_track(song, track_index)
413
+ device = get_device(track, device_index)
414
+
415
+ if not device.can_have_chains:
416
+ raise ValueError(
417
+ "Device '%s' is not a rack and cannot have chains" % device.name
418
+ )
419
+
420
+ chains = []
421
+ for i, chain in enumerate(device.chains):
422
+ chain_info = {
423
+ "index": i,
424
+ "name": chain.name,
425
+ "volume": chain.mixer_device.volume.value,
426
+ "pan": chain.mixer_device.panning.value,
427
+ "mute": chain.mute,
428
+ "solo": chain.solo,
429
+ }
430
+ chains.append(chain_info)
431
+ return {"chains": chains}
432
+
433
+
434
+ @register("set_chain_volume")
435
+ def set_chain_volume(song, params):
436
+ """Set volume and/or pan for a rack chain."""
437
+ track_index = int(params["track_index"])
438
+ device_index = int(params["device_index"])
439
+ chain_index = int(params["chain_index"])
440
+ track = get_track(song, track_index)
441
+ device = get_device(track, device_index)
442
+
443
+ if not device.can_have_chains:
444
+ raise ValueError(
445
+ "Device '%s' is not a rack and cannot have chains" % device.name
446
+ )
447
+
448
+ chains = list(device.chains)
449
+ if chain_index < 0 or chain_index >= len(chains):
450
+ raise IndexError(
451
+ "Chain index %d out of range (0..%d)"
452
+ % (chain_index, len(chains) - 1)
453
+ )
454
+ chain = chains[chain_index]
455
+
456
+ if "volume" in params:
457
+ chain.mixer_device.volume.value = float(params["volume"])
458
+ if "pan" in params:
459
+ chain.mixer_device.panning.value = float(params["pan"])
460
+
461
+ return {
462
+ "index": chain_index,
463
+ "name": chain.name,
464
+ "volume": chain.mixer_device.volume.value,
465
+ "pan": chain.mixer_device.panning.value,
466
+ }
@@ -0,0 +1,198 @@
1
+ """
2
+ LivePilot - Session diagnostics handler (1 command).
3
+
4
+ Analyzes the current session and flags potential issues:
5
+ armed tracks, mute/solo leftovers, empty clips, unnamed tracks,
6
+ empty scenes, and device-less instrument tracks.
7
+ """
8
+
9
+ from .router import register
10
+
11
+
12
+ # Default track names that indicate the user hasn't renamed them.
13
+ # Ableton auto-names tracks like "1-MIDI", "2-Audio", "3-MIDI", etc.
14
+ _DEFAULT_NAME_PATTERNS = frozenset([
15
+ "MIDI", "Audio", "Inst", "Return",
16
+ ])
17
+
18
+
19
+ def _looks_default_name(name):
20
+ """Check if a track name looks like an Ableton default."""
21
+ stripped = name.strip()
22
+ # Pattern: "N-Type" or just "Type" (e.g., "1-MIDI", "MIDI", "2-Audio")
23
+ for part in stripped.split("-"):
24
+ part = part.strip()
25
+ if part.isdigit():
26
+ continue
27
+ if part in _DEFAULT_NAME_PATTERNS:
28
+ return True
29
+ return False
30
+
31
+
32
+ @register("get_session_diagnostics")
33
+ def get_session_diagnostics(song, params):
34
+ """Analyze the session and return a diagnostic report."""
35
+ issues = []
36
+ stats = {
37
+ "track_count": 0,
38
+ "return_track_count": 0,
39
+ "scene_count": 0,
40
+ "total_clips": 0,
41
+ "empty_scenes": 0,
42
+ }
43
+
44
+ tracks = list(song.tracks)
45
+ scenes = list(song.scenes)
46
+ return_tracks = list(song.return_tracks)
47
+
48
+ stats["track_count"] = len(tracks)
49
+ stats["return_track_count"] = len(return_tracks)
50
+ stats["scene_count"] = len(scenes)
51
+
52
+ # ── Track-level checks ─────────────────────────────────────────────
53
+
54
+ armed_tracks = []
55
+ soloed_tracks = []
56
+ muted_tracks = []
57
+ unnamed_tracks = []
58
+ empty_tracks = [] # no clips at all
59
+ no_device_midi_tracks = [] # MIDI tracks with no instruments
60
+ track_slots = [] # cached clip_slots per track (avoid re-evaluating LOM tuple)
61
+
62
+ for i, track in enumerate(tracks):
63
+ # Armed check
64
+ if track.arm:
65
+ armed_tracks.append({"index": i, "name": track.name})
66
+
67
+ # Solo check
68
+ if track.solo:
69
+ soloed_tracks.append({"index": i, "name": track.name})
70
+
71
+ # Muted tracks (informational, only flag if many)
72
+ if track.mute:
73
+ muted_tracks.append({"index": i, "name": track.name})
74
+
75
+ # Unnamed / default name check
76
+ if _looks_default_name(track.name):
77
+ unnamed_tracks.append({"index": i, "name": track.name})
78
+
79
+ # Cache clip_slots once per track
80
+ slots = list(track.clip_slots)
81
+ track_slots.append(slots)
82
+
83
+ # Clip count
84
+ clip_count = 0
85
+ for slot in slots:
86
+ if slot.has_clip:
87
+ clip_count += 1
88
+ stats["total_clips"] += clip_count
89
+
90
+ if clip_count == 0:
91
+ empty_tracks.append({"index": i, "name": track.name})
92
+
93
+ # MIDI track with no devices (no instrument loaded)
94
+ if track.has_midi_input and len(list(track.devices)) == 0:
95
+ no_device_midi_tracks.append({"index": i, "name": track.name})
96
+
97
+ # Build issues from checks
98
+ if armed_tracks:
99
+ issues.append({
100
+ "type": "armed_tracks",
101
+ "severity": "warning",
102
+ "message": "%d track(s) left armed" % len(armed_tracks),
103
+ "details": armed_tracks,
104
+ })
105
+
106
+ if soloed_tracks:
107
+ issues.append({
108
+ "type": "soloed_tracks",
109
+ "severity": "warning",
110
+ "message": "%d track(s) soloed — other tracks are silenced" % len(soloed_tracks),
111
+ "details": soloed_tracks,
112
+ })
113
+
114
+ if len(muted_tracks) > len(tracks) * 0.5 and len(muted_tracks) > 2:
115
+ issues.append({
116
+ "type": "many_muted",
117
+ "severity": "info",
118
+ "message": "%d of %d tracks muted — consider cleaning up unused tracks" % (
119
+ len(muted_tracks), len(tracks)
120
+ ),
121
+ "details": muted_tracks,
122
+ })
123
+
124
+ if unnamed_tracks:
125
+ issues.append({
126
+ "type": "unnamed_tracks",
127
+ "severity": "info",
128
+ "message": "%d track(s) have default names" % len(unnamed_tracks),
129
+ "details": unnamed_tracks,
130
+ })
131
+
132
+ if empty_tracks:
133
+ issues.append({
134
+ "type": "empty_tracks",
135
+ "severity": "info",
136
+ "message": "%d track(s) have no clips" % len(empty_tracks),
137
+ "details": empty_tracks,
138
+ })
139
+
140
+ if no_device_midi_tracks:
141
+ issues.append({
142
+ "type": "no_instrument",
143
+ "severity": "warning",
144
+ "message": "%d MIDI track(s) have no instrument loaded" % len(no_device_midi_tracks),
145
+ "details": no_device_midi_tracks,
146
+ })
147
+
148
+ # ── Scene-level checks ──────────────────────────────────────────────
149
+
150
+ empty_scenes = []
151
+ for i, scene in enumerate(scenes):
152
+ has_any_clip = False
153
+ for slots in track_slots:
154
+ if i < len(slots) and slots[i].has_clip:
155
+ has_any_clip = True
156
+ break
157
+ if not has_any_clip:
158
+ empty_scenes.append({"index": i, "name": scene.name})
159
+
160
+ stats["empty_scenes"] = len(empty_scenes)
161
+
162
+ if empty_scenes and len(empty_scenes) > 1:
163
+ issues.append({
164
+ "type": "empty_scenes",
165
+ "severity": "info",
166
+ "message": "%d scene(s) have no clips across any track" % len(empty_scenes),
167
+ "details": empty_scenes[:10], # Cap at 10 to avoid huge payloads
168
+ })
169
+
170
+ # ── Return track checks ─────────────────────────────────────────────
171
+
172
+ soloed_returns = []
173
+ for i, track in enumerate(return_tracks):
174
+ if track.solo:
175
+ soloed_returns.append({"index": i, "name": track.name})
176
+
177
+ if soloed_returns:
178
+ issues.append({
179
+ "type": "soloed_returns",
180
+ "severity": "warning",
181
+ "message": "%d return track(s) soloed" % len(soloed_returns),
182
+ "details": soloed_returns,
183
+ })
184
+
185
+ # ── Summary ─────────────────────────────────────────────────────────
186
+
187
+ severity_counts = {"warning": 0, "info": 0}
188
+ for issue in issues:
189
+ severity_counts[issue["severity"]] = severity_counts.get(issue["severity"], 0) + 1
190
+
191
+ return {
192
+ "healthy": len(issues) == 0,
193
+ "issue_count": len(issues),
194
+ "warnings": severity_counts.get("warning", 0),
195
+ "info": severity_counts.get("info", 0),
196
+ "issues": issues,
197
+ "stats": stats,
198
+ }