livepilot 1.10.7 → 1.10.9

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 (135) hide show
  1. package/CHANGELOG.md +254 -0
  2. package/README.md +19 -17
  3. package/bin/livepilot.js +146 -28
  4. package/installer/install.js +117 -11
  5. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  6. package/m4l_device/livepilot_bridge.js +1 -1
  7. package/mcp_server/__init__.py +1 -1
  8. package/mcp_server/atlas/__init__.py +39 -7
  9. package/mcp_server/atlas/tools.py +56 -15
  10. package/mcp_server/composer/layer_planner.py +27 -0
  11. package/mcp_server/composer/prompt_parser.py +15 -6
  12. package/mcp_server/connection.py +11 -3
  13. package/mcp_server/corpus/__init__.py +14 -4
  14. package/mcp_server/evaluation/fabric.py +62 -1
  15. package/mcp_server/m4l_bridge.py +63 -12
  16. package/mcp_server/project_brain/automation_graph.py +23 -1
  17. package/mcp_server/project_brain/builder.py +2 -0
  18. package/mcp_server/project_brain/models.py +20 -1
  19. package/mcp_server/project_brain/tools.py +10 -3
  20. package/mcp_server/runtime/execution_router.py +16 -2
  21. package/mcp_server/runtime/remote_commands.py +6 -0
  22. package/mcp_server/sample_engine/models.py +22 -3
  23. package/mcp_server/semantic_moves/__init__.py +1 -0
  24. package/mcp_server/semantic_moves/compiler.py +9 -1
  25. package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
  26. package/mcp_server/semantic_moves/mix_compilers.py +170 -0
  27. package/mcp_server/semantic_moves/mix_moves.py +1 -1
  28. package/mcp_server/semantic_moves/models.py +5 -0
  29. package/mcp_server/semantic_moves/tools.py +154 -35
  30. package/mcp_server/server.py +147 -17
  31. package/mcp_server/services/singletons.py +68 -0
  32. package/mcp_server/session_continuity/models.py +13 -0
  33. package/mcp_server/session_continuity/tools.py +2 -0
  34. package/mcp_server/session_continuity/tracker.py +93 -0
  35. package/mcp_server/splice_client/client.py +29 -8
  36. package/mcp_server/tools/_analyzer_engine/__init__.py +39 -0
  37. package/mcp_server/tools/_analyzer_engine/context.py +103 -0
  38. package/mcp_server/tools/_analyzer_engine/flucoma.py +23 -0
  39. package/mcp_server/tools/_analyzer_engine/sample.py +122 -0
  40. package/mcp_server/tools/_motif_engine.py +19 -4
  41. package/mcp_server/tools/analyzer.py +25 -180
  42. package/mcp_server/tools/clips.py +240 -2
  43. package/mcp_server/tools/midi_io.py +10 -0
  44. package/mcp_server/tools/tracks.py +1 -1
  45. package/mcp_server/tools/transport.py +59 -4
  46. package/mcp_server/translation_engine/tools.py +8 -4
  47. package/package.json +25 -3
  48. package/remote_script/LivePilot/__init__.py +36 -9
  49. package/remote_script/LivePilot/arrangement.py +12 -2
  50. package/remote_script/LivePilot/browser.py +16 -6
  51. package/remote_script/LivePilot/devices.py +10 -5
  52. package/remote_script/LivePilot/notes.py +13 -2
  53. package/remote_script/LivePilot/server.py +51 -13
  54. package/remote_script/LivePilot/version_detect.py +7 -4
  55. package/server.json +20 -0
  56. package/.claude-plugin/marketplace.json +0 -21
  57. package/.mcp.json.disabled +0 -9
  58. package/.mcpbignore +0 -60
  59. package/AGENTS.md +0 -46
  60. package/BUGS.md +0 -1570
  61. package/CODE_OF_CONDUCT.md +0 -27
  62. package/CONTRIBUTING.md +0 -131
  63. package/SECURITY.md +0 -48
  64. package/livepilot/.Codex-plugin/plugin.json +0 -8
  65. package/livepilot/.claude-plugin/plugin.json +0 -8
  66. package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
  67. package/livepilot/commands/arrange.md +0 -47
  68. package/livepilot/commands/beat.md +0 -77
  69. package/livepilot/commands/evaluate.md +0 -49
  70. package/livepilot/commands/memory.md +0 -22
  71. package/livepilot/commands/mix.md +0 -44
  72. package/livepilot/commands/perform.md +0 -42
  73. package/livepilot/commands/session.md +0 -13
  74. package/livepilot/commands/sounddesign.md +0 -43
  75. package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
  76. package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
  77. package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
  78. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
  79. package/livepilot/skills/livepilot-core/SKILL.md +0 -184
  80. package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
  81. package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
  82. package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
  83. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
  84. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
  85. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
  86. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
  87. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
  88. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
  89. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
  90. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
  91. package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
  92. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
  93. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
  94. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
  95. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
  96. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
  97. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
  98. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
  99. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
  100. package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
  101. package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
  102. package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
  103. package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
  104. package/livepilot/skills/livepilot-core/references/overview.md +0 -290
  105. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
  106. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
  107. package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
  108. package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
  109. package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
  110. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
  111. package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
  112. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
  113. package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
  114. package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
  115. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
  116. package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
  117. package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
  118. package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
  119. package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
  120. package/livepilot/skills/livepilot-release/SKILL.md +0 -130
  121. package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
  122. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
  123. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
  124. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
  125. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
  126. package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
  127. package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
  128. package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
  129. package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
  130. package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
  131. package/m4l_device/LivePilot_Analyzer.maxproj +0 -53
  132. package/manifest.json +0 -91
  133. package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
  134. package/scripts/generate_tool_catalog.py +0 -106
  135. package/scripts/sync_metadata.py +0 -349
@@ -1,10 +1,13 @@
1
1
  """Clip MCP tools — info, create, delete, duplicate, fire, stop, properties, warp.
2
2
 
3
- 11 tools matching the Remote Script clips domain.
3
+ 11 tools matching the Remote Script clips domain, plus a key-consistency
4
+ diagnostic (BUG-D1) that cross-references filename-encoded keys against
5
+ the analyzer-detected session key.
4
6
  """
5
7
 
6
8
  from __future__ import annotations
7
9
 
10
+ import re
8
11
  from typing import Optional
9
12
 
10
13
  from fastmcp import Context
@@ -17,6 +20,72 @@ def _get_ableton(ctx: Context):
17
20
  return ctx.lifespan_context["ableton"]
18
21
 
19
22
 
23
+ # ── Key-token parsing (BUG-D1) ─────────────────────────────────────────
24
+ #
25
+ # Splice filenames encode the key as one of:
26
+ # _D#min _Dmin _Dm → minor
27
+ # _Dmaj _DMaj _D → major (trailing nothing or just "maj")
28
+ # _Eb _Ebmin _Dbm → accidentals accepted as # or b
29
+ #
30
+ # We accept any of those forms and emit a canonical (root, mode) tuple.
31
+
32
+ # Note → semitone offset from C (C=0, C#=1, D=2, ...)
33
+ _NOTE_TO_SEMI = {
34
+ "c": 0, "c#": 1, "db": 1, "d": 2, "d#": 3, "eb": 3, "e": 4, "fb": 4,
35
+ "e#": 5, "f": 5, "f#": 6, "gb": 6, "g": 7, "g#": 8, "ab": 8,
36
+ "a": 9, "a#": 10, "bb": 10, "b": 11, "cb": 11,
37
+ }
38
+
39
+ # Match the trailing key token in a filename stem (everything before the
40
+ # extension, underscore-delimited). We anchor to the end of the stem so
41
+ # an earlier "D" in the filename (e.g. "Dabrye_...") doesn't match.
42
+ _KEY_RE = re.compile(
43
+ r"(?P<root>[A-Ga-g][#b]?)(?P<mode>maj|min|m|Maj|Min)?$",
44
+ flags=re.IGNORECASE,
45
+ )
46
+
47
+
48
+ def _parse_key_from_filename(filename: str) -> Optional[dict]:
49
+ """Extract key info from a Splice-style filename.
50
+
51
+ Returns ``{"root": "D#", "mode": "minor", "semi": 3, "token": "D#min"}``
52
+ or ``None`` if no recognizable key token is present in the final
53
+ underscore-segment of the filename stem.
54
+ """
55
+ if not filename:
56
+ return None
57
+ stem = filename.rsplit(".", 1)[0]
58
+ last = stem.split("_")[-1]
59
+ match = _KEY_RE.fullmatch(last)
60
+ if not match:
61
+ return None
62
+ root_raw = match.group("root").lower()
63
+ mode_raw = (match.group("mode") or "").lower()
64
+ # Normalize the root to lookup form. Canonicalize B# → C, etc. (rare
65
+ # but possible in hand-named samples).
66
+ semi = _NOTE_TO_SEMI.get(root_raw)
67
+ if semi is None:
68
+ return None
69
+ # Without an explicit mode suffix, Splice convention defaults to major.
70
+ mode = "minor" if mode_raw in ("min", "m") else "major"
71
+ # Canonical display: capitalize root, preserve #/b.
72
+ root_display = root_raw[0].upper() + root_raw[1:]
73
+ return {
74
+ "root": root_display,
75
+ "mode": mode,
76
+ "semi": semi,
77
+ "token": last,
78
+ }
79
+
80
+
81
+ def _key_to_semi(root: str, mode: str = "major") -> Optional[int]:
82
+ """Convert a session-reported key like ``"D"`` + ``"minor"`` to 0..11 semis."""
83
+ if not root:
84
+ return None
85
+ semi = _NOTE_TO_SEMI.get(root.strip().lower())
86
+ return semi
87
+
88
+
20
89
  def _validate_track_index(track_index: int):
21
90
  """Validate track index. Must be >= 0 for regular tracks."""
22
91
  if track_index < 0:
@@ -114,7 +183,7 @@ def stop_clip(ctx: Context, track_index: int, clip_index: int) -> dict:
114
183
 
115
184
  @mcp.tool()
116
185
  def set_clip_name(ctx: Context, track_index: int, clip_index: int, name: str) -> dict:
117
- """Rename a clip."""
186
+ """Rename a clip in the Session view. The new name appears on the clip slot and in Device Chain displays."""
118
187
  _validate_track_index(track_index)
119
188
  _validate_clip_index(clip_index)
120
189
  if not name.strip():
@@ -265,3 +334,172 @@ def set_clip_warp_mode(
265
334
  if warping is not None:
266
335
  params["warping"] = warping
267
336
  return _get_ableton(ctx).send_command("set_clip_warp_mode", params)
337
+
338
+
339
+ @mcp.tool()
340
+ async def check_clip_key_consistency(
341
+ ctx: Context,
342
+ track_index: int,
343
+ clip_index: int,
344
+ ) -> dict:
345
+ """Cross-check a clip's filename-encoded key against the session key (BUG-D1).
346
+
347
+ Splice-style sample filenames encode the sample's key (e.g.
348
+ ``AU_THF2_128_vocal_..._D#min.wav``). This tool parses that token,
349
+ compares it to the analyzer-detected session key, and — when they
350
+ disagree — computes the semitone delta needed to realign, returning
351
+ the exact ``set_clip_pitch(coarse=...)`` call that would correct it.
352
+
353
+ Return shape::
354
+
355
+ {
356
+ "track_index": 6,
357
+ "clip_index": 0,
358
+ "filename_key": {"root": "D#", "mode": "minor", "token": "D#min"},
359
+ "session_key": {"root": "D", "mode": "minor"},
360
+ "status": "mismatch" | "match" | "unknown",
361
+ "semitone_delta": -1, # clip needs to shift DOWN 1
362
+ "recommended_fix": {
363
+ "tool": "set_clip_pitch",
364
+ "args": {"track_index": 6, "clip_index": 0, "coarse": -1}
365
+ },
366
+ "reason": "Clip is D#min, session is Dm — shift -1 semitone."
367
+ }
368
+
369
+ Returns ``status="unknown"`` (not an error) when:
370
+ - the clip is MIDI (no audio file path)
371
+ - the filename has no parseable key token
372
+ - the analyzer hasn't detected a session key yet
373
+
374
+ Requires the M4L bridge for both ``get_clip_file_path`` and
375
+ ``get_detected_key``. Degrades gracefully without it.
376
+ """
377
+ _validate_track_index(track_index)
378
+ _validate_clip_index(clip_index)
379
+
380
+ # 1) Resolve the clip's file path. Relies on the M4L bridge.
381
+ try:
382
+ from .analyzer import get_clip_file_path as _get_path
383
+ # get_clip_file_path is an @mcp.tool, but FastMCP decorators preserve
384
+ # the underlying function — we can call it directly for composition.
385
+ path_resp = await _get_path.fn(ctx, track_index, clip_index)
386
+ except Exception as exc:
387
+ return {
388
+ "track_index": track_index,
389
+ "clip_index": clip_index,
390
+ "status": "unknown",
391
+ "reason": f"Could not resolve clip file path: {exc}",
392
+ }
393
+ if not isinstance(path_resp, dict) or path_resp.get("error"):
394
+ return {
395
+ "track_index": track_index,
396
+ "clip_index": clip_index,
397
+ "status": "unknown",
398
+ "reason": path_resp.get("error", "No file path available (MIDI clip?)."),
399
+ }
400
+ file_path = path_resp.get("path") or path_resp.get("file_path") or ""
401
+
402
+ # 2) Parse key token from the filename.
403
+ import os
404
+ filename_key = _parse_key_from_filename(os.path.basename(file_path))
405
+ if filename_key is None:
406
+ return {
407
+ "track_index": track_index,
408
+ "clip_index": clip_index,
409
+ "file_path": file_path,
410
+ "status": "unknown",
411
+ "reason": "Filename has no recognizable key token.",
412
+ }
413
+
414
+ # 3) Query the session-detected key (needs the analyzer).
415
+ try:
416
+ from .analyzer import get_detected_key as _get_key
417
+ key_resp = await _get_key.fn(ctx)
418
+ except Exception as exc:
419
+ return {
420
+ "track_index": track_index,
421
+ "clip_index": clip_index,
422
+ "file_path": file_path,
423
+ "filename_key": filename_key,
424
+ "status": "unknown",
425
+ "reason": f"Analyzer unavailable: {exc}",
426
+ }
427
+ if not isinstance(key_resp, dict) or key_resp.get("error") or not key_resp.get("key"):
428
+ return {
429
+ "track_index": track_index,
430
+ "clip_index": clip_index,
431
+ "file_path": file_path,
432
+ "filename_key": filename_key,
433
+ "status": "unknown",
434
+ "reason": key_resp.get(
435
+ "error", "Session key not yet detected — play 4-8 bars."
436
+ ),
437
+ }
438
+ session_root = str(key_resp.get("key", ""))
439
+ session_mode = str(key_resp.get("scale", "major")).lower()
440
+ session_semi = _key_to_semi(session_root)
441
+
442
+ # 4) Classify + compute fix.
443
+ file_semi = filename_key["semi"]
444
+ if session_semi is None or file_semi is None:
445
+ return {
446
+ "track_index": track_index,
447
+ "clip_index": clip_index,
448
+ "file_path": file_path,
449
+ "filename_key": filename_key,
450
+ "session_key": {"root": session_root, "mode": session_mode},
451
+ "status": "unknown",
452
+ "reason": "Could not resolve semitone offsets for comparison.",
453
+ }
454
+
455
+ if filename_key["mode"] != session_mode:
456
+ mode_note = (
457
+ f" (clip is {filename_key['mode']}, session is {session_mode} — "
458
+ "mode mismatch is often OK for ambient/background use)"
459
+ )
460
+ else:
461
+ mode_note = ""
462
+
463
+ if file_semi == session_semi and filename_key["mode"] == session_mode:
464
+ return {
465
+ "track_index": track_index,
466
+ "clip_index": clip_index,
467
+ "file_path": file_path,
468
+ "filename_key": filename_key,
469
+ "session_key": {"root": session_root, "mode": session_mode},
470
+ "status": "match",
471
+ "semitone_delta": 0,
472
+ "recommended_fix": None,
473
+ "reason": "Clip key matches session.",
474
+ }
475
+
476
+ # Semitone delta: how much the clip should shift to align with the
477
+ # session root. Choose the smaller magnitude (shift up or down).
478
+ raw_delta = (session_semi - file_semi) % 12
479
+ if raw_delta > 6:
480
+ raw_delta -= 12 # prefer the nearer direction (−1 over +11)
481
+ delta = raw_delta
482
+
483
+ return {
484
+ "track_index": track_index,
485
+ "clip_index": clip_index,
486
+ "file_path": file_path,
487
+ "filename_key": filename_key,
488
+ "session_key": {"root": session_root, "mode": session_mode},
489
+ "status": "mismatch",
490
+ "semitone_delta": delta,
491
+ "recommended_fix": {
492
+ "tool": "set_clip_pitch",
493
+ "args": {
494
+ "track_index": track_index,
495
+ "clip_index": clip_index,
496
+ "coarse": delta,
497
+ },
498
+ },
499
+ "reason": (
500
+ f"Clip is {filename_key['root']}{filename_key['mode'][:3]}, "
501
+ f"session is {session_root}{session_mode[:3]} — "
502
+ f"shift {delta:+d} semitone{'' if abs(delta) == 1 else 's'}."
503
+ f"{mode_note}"
504
+ ),
505
+ }
@@ -144,6 +144,16 @@ def export_clip_midi(
144
144
  else:
145
145
  out_path = _safe_output_path(_output_dir(), filename)
146
146
 
147
+ # Extension guard: after path resolution, confirm the final file really
148
+ # has a MIDI extension. Blocks a model-supplied path like
149
+ # "/etc/cron.d/evil" that accidentally drops its extension through the
150
+ # resolve() step, or a caller that passed "evil.mid/../evil".
151
+ if out_path.suffix.lower() not in {".mid", ".midi"}:
152
+ raise ValueError(
153
+ f"Refusing to write non-MIDI file: {out_path}. "
154
+ f"export_clip_midi requires a .mid or .midi extension."
155
+ )
156
+
147
157
  midi = MIDIFile(1)
148
158
  midi.addTempo(0, 0, tempo)
149
159
 
@@ -106,7 +106,7 @@ def duplicate_track(ctx: Context, track_index: int) -> dict:
106
106
 
107
107
  @mcp.tool()
108
108
  def set_track_name(ctx: Context, track_index: int, name: str) -> dict:
109
- """Rename a track."""
109
+ """Rename a track. The new name appears in both the Session and Arrangement views and survives session save."""
110
110
  _validate_track_index(track_index)
111
111
  if not name.strip():
112
112
  raise ValueError("Track name cannot be empty")
@@ -62,7 +62,7 @@ def start_playback(ctx: Context) -> dict:
62
62
 
63
63
  @mcp.tool()
64
64
  def stop_playback(ctx: Context) -> dict:
65
- """Stop playback."""
65
+ """Stop playback — halts the session transport and the arrangement cursor returns to its last position."""
66
66
  return _get_ableton(ctx).send_command("stop_playback")
67
67
 
68
68
 
@@ -130,6 +130,61 @@ def get_recent_actions(ctx: Context, limit: int = 20) -> dict:
130
130
 
131
131
 
132
132
  @mcp.tool()
133
- def get_session_diagnostics(ctx: Context) -> dict:
134
- """Analyze the session for potential issues: armed tracks, solo/mute leftovers, unnamed tracks, empty clips/scenes, MIDI tracks without instruments. Returns issues with severity (warning/info) and stats."""
135
- return _get_ableton(ctx).send_command("get_session_diagnostics")
133
+ async def get_session_diagnostics(ctx: Context, check_clip_keys: bool = False) -> dict:
134
+ """Analyze the session for potential issues: armed tracks, solo/mute leftovers, unnamed tracks, empty clips/scenes, MIDI tracks without instruments. Returns issues with severity (warning/info) and stats.
135
+
136
+ check_clip_keys: when True, also cross-checks every audio clip's
137
+ filename-encoded key against the detected session key (BUG-D1 scan).
138
+ Each mismatch appears as a diagnostic entry with the exact
139
+ set_clip_pitch call that would correct it. Requires the M4L bridge
140
+ (uses get_clip_file_path + get_detected_key); skipped gracefully if
141
+ the bridge is unavailable. Off by default because it round-trips
142
+ the bridge once per audio clip and can add noticeable latency on
143
+ large sessions.
144
+ """
145
+ result = _get_ableton(ctx).send_command("get_session_diagnostics")
146
+
147
+ if not check_clip_keys:
148
+ return result
149
+ if not isinstance(result, dict):
150
+ return result
151
+
152
+ # Augment with per-clip key-consistency checks. Each mismatch is added
153
+ # as a diagnostic with severity="warning"; "unknown" results are
154
+ # skipped so we don't drown the user in "no key detected yet" noise.
155
+ from .clips import check_clip_key_consistency # local import to avoid cycles
156
+
157
+ audio_mismatches: list[dict] = []
158
+ session_info = _get_ableton(ctx).send_command("get_session_info")
159
+ tracks = (session_info or {}).get("tracks", []) if isinstance(session_info, dict) else []
160
+ for track in tracks:
161
+ t_idx = track.get("index")
162
+ if t_idx is None:
163
+ continue
164
+ # We don't know which slots hold audio clips without probing, so
165
+ # iterate the first N scene slots conservatively. A session with
166
+ # many scenes would benefit from a scene-count cap; 32 is a
167
+ # reasonable upper bound for typical production sessions.
168
+ for clip_idx in range(min(32, len(session_info.get("scenes", []) or []) or 8)):
169
+ try:
170
+ check = await check_clip_key_consistency.fn(ctx, t_idx, clip_idx)
171
+ except Exception: # noqa: BLE001 — any failure means "skip this clip"
172
+ continue
173
+ if not isinstance(check, dict):
174
+ continue
175
+ if check.get("status") == "mismatch":
176
+ audio_mismatches.append({
177
+ "severity": "warning",
178
+ "category": "clip_key_mismatch",
179
+ "track_index": t_idx,
180
+ "clip_index": clip_idx,
181
+ "message": check.get("reason", ""),
182
+ "recommended_fix": check.get("recommended_fix"),
183
+ })
184
+
185
+ if audio_mismatches:
186
+ issues = result.setdefault("issues", [])
187
+ issues.extend(audio_mismatches)
188
+ result["clip_key_mismatch_count"] = len(audio_mismatches)
189
+
190
+ return result
@@ -6,11 +6,19 @@ then delegates to pure-computation critics.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import logging
10
+
9
11
  from fastmcp import Context
10
12
 
11
13
  from ..server import mcp
12
14
  from .critics import build_translation_report, run_all_translation_critics
13
15
 
16
+ # Logger defined at module top: _fetch_translation_data below calls
17
+ # logger.debug on an exception path. The previous version buried the logger
18
+ # definition inside a docstring mid-file, which meant the name was never
19
+ # actually bound at module level — any exception path would raise NameError.
20
+ logger = logging.getLogger(__name__)
21
+
14
22
 
15
23
  # ── Helpers ─────────────────────────────────────────────────────────
16
24
 
@@ -97,10 +105,6 @@ def get_translation_issues(ctx: Context) -> dict:
97
105
 
98
106
  Lighter than check_translation — returns only detected issues
99
107
  from the 5 playback robustness critics.
100
- import logging
101
-
102
- logger = logging.getLogger(__name__)
103
-
104
108
  """
105
109
  mix_snapshot = _fetch_translation_data(ctx)
106
110
  issues = run_all_translation_critics(mix_snapshot)
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.10.7",
3
+ "version": "1.10.9",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 323 tools, 45 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
5
+ "description": "Agentic production system for Ableton Live 12 — 325 tools, 45 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
6
6
  "author": "Pilot Studio",
7
7
  "license": "BSL-1.1",
8
8
  "type": "commonjs",
@@ -43,5 +43,27 @@
43
43
  ],
44
44
  "engines": {
45
45
  "node": ">=18.0.0"
46
- }
46
+ },
47
+ "files": [
48
+ "bin/**/*.js",
49
+ "installer/**/*.js",
50
+ "mcp_server/**/*.py",
51
+ "mcp_server/**/*.json",
52
+ "mcp_server/**/*.yaml",
53
+ "mcp_server/**/*.md",
54
+ "mcp_server/**/*.db",
55
+ "remote_script/**/*.py",
56
+ "m4l_device/LivePilot_Analyzer.amxd",
57
+ "m4l_device/LivePilot_Analyzer.adv",
58
+ "m4l_device/livepilot_bridge.js",
59
+ "m4l_device/BUILD_GUIDE.md",
60
+ "requirements.txt",
61
+ "README.md",
62
+ "LICENSE",
63
+ "CHANGELOG.md",
64
+ "server.json",
65
+ "!**/__pycache__/**",
66
+ "!**/*.pyc",
67
+ "!**/.DS_Store"
68
+ ]
47
69
  }
@@ -5,11 +5,12 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
5
5
  when this script is selected in Preferences > Link, Tempo & MIDI.
6
6
  """
7
7
 
8
- __version__ = "1.10.7"
8
+ __version__ = "1.10.9"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
12
12
  from .server import LivePilotServer
13
+ from . import utils # noqa: F401 — shared helpers (get_track, get_device)
13
14
  from . import transport # noqa: F401 — registers transport handlers
14
15
  from . import tracks # noqa: F401 — registers track handlers
15
16
  from . import clips # noqa: F401 — registers clip handlers
@@ -36,36 +37,62 @@ from . import version_detect # noqa: F401 — version detection
36
37
  # @register decorators with the updated code). Result: a Control Surface
37
38
  # toggle now behaves like a fresh module reload, so live-editing mixing.py
38
39
  # / devices.py / etc. and re-toggling is enough — no Ableton restart.
40
+ #
41
+ # Order matters: utils comes first because every handler imports
42
+ # ``from .utils import get_track, get_device``. If utils isn't reloaded
43
+ # first, those re-imports during ``importlib.reload(devices)`` still
44
+ # resolve to the stale ``utils`` module object in ``sys.modules``.
39
45
 
40
46
  _FIRST_CREATE_INSTANCE = True
41
47
 
42
48
  _HANDLER_MODULES = (
49
+ utils,
43
50
  transport, tracks, clips, notes, devices, scenes,
44
51
  mixing, browser, arrangement, diagnostics,
45
52
  clip_automation, version_detect,
46
53
  )
47
54
 
48
55
 
49
- def _force_reload_handlers():
56
+ def _force_reload_handlers(cs=None):
50
57
  """Force Python to re-read the handler modules from disk.
51
58
 
52
59
  Called on every create_instance() except the first, so edits to
53
60
  handler files take effect via Control Surface toggle without
54
61
  restarting Ableton. Order matters: router first (clears _handlers),
55
62
  then each handler module (re-registers its @register decorators).
63
+
64
+ When ``cs`` is provided, reload exceptions are logged through the
65
+ ControlSurface so a SyntaxError / NameError in an edited handler is
66
+ surfaced in Live's status log instead of silently swallowed. The
67
+ previous ``except Exception: pass`` turned any bad handler into a
68
+ silent NOT_FOUND at dispatch time with no hint that reload had failed.
56
69
  """
57
70
  import importlib
71
+ def _log(msg):
72
+ if cs is None:
73
+ return
74
+ try:
75
+ cs.log_message("[LivePilot] " + msg)
76
+ except Exception:
77
+ pass
78
+
58
79
  try:
59
80
  importlib.reload(router)
60
- except Exception:
61
- pass
81
+ except Exception as exc:
82
+ _log("reload(router) FAILED — %s: %s. Handlers will be "
83
+ "stale until Ableton restart." % (type(exc).__name__, exc))
62
84
  for mod in _HANDLER_MODULES:
63
85
  try:
64
86
  importlib.reload(mod)
65
- except Exception:
66
- # Don't block Ableton startup on a single bad reload
67
- # the stale version will still work for that handler
68
- pass
87
+ except Exception as exc:
88
+ # Don't block Ableton startup on a single bad reload, but do
89
+ # tell the user what happened the stale handler will keep
90
+ # serving the OLD code until a full restart.
91
+ _log("reload(%s) FAILED — %s: %s. Handler is stale." % (
92
+ getattr(mod, "__name__", "?"),
93
+ type(exc).__name__,
94
+ exc,
95
+ ))
69
96
 
70
97
 
71
98
  def create_instance(c_instance):
@@ -78,7 +105,7 @@ def create_instance(c_instance):
78
105
  """
79
106
  global _FIRST_CREATE_INSTANCE
80
107
  if not _FIRST_CREATE_INSTANCE:
81
- _force_reload_handlers()
108
+ _force_reload_handlers(cs=c_instance)
82
109
  _FIRST_CREATE_INSTANCE = False
83
110
  return LivePilot(c_instance)
84
111
 
@@ -407,11 +407,21 @@ def modify_arrangement_notes(song, params):
407
407
  for note in all_notes:
408
408
  note_map[note.note_id] = note
409
409
 
410
+ # Two-pass: validate all note_ids BEFORE mutating any notes. See the
411
+ # identical fix in notes.py:modify_notes — partial mid-loop mutation on
412
+ # the C++ NoteVector was leaving the clip in a half-modified state that
413
+ # never got committed.
414
+ missing = [int(mod["note_id"]) for mod in modifications
415
+ if int(mod["note_id"]) not in note_map]
416
+ if missing:
417
+ raise ValueError(
418
+ "Note IDs not found in arrangement clip: %s. "
419
+ "No modifications applied." % missing
420
+ )
421
+
410
422
  modified_count = 0
411
423
  for mod in modifications:
412
424
  note_id = int(mod["note_id"])
413
- if note_id not in note_map:
414
- raise ValueError("Note ID %d not found in clip" % note_id)
415
425
  note = note_map[note_id]
416
426
  if "pitch" in mod:
417
427
  note.pitch = int(mod["pitch"])
@@ -287,11 +287,20 @@ def load_browser_item(song, params):
287
287
  # and use the subcategory or the full fragment for matching
288
288
  if "FileId_" in device_name:
289
289
  # URI contains an internal file ID — name-based search won't work.
290
- # Try one more URI pass with a much higher iteration limit.
290
+ # We fall back to one URI walk, but with a TIGHT iteration budget:
291
+ # this runs synchronously on Ableton's audio/main thread, and the
292
+ # previous 200 000-node walk could stall audio and GUI for several
293
+ # seconds on large libraries (documented in CLAUDE.md).
294
+ #
295
+ # If the item isn't found inside the budget, we return a clean
296
+ # STATE_ERROR pointing the caller at search_browser(), which does
297
+ # the same walk lazily from a cached Python-side index without
298
+ # hogging the audio thread.
291
299
  _iterations[0] = 0
292
- DEEP_MAX = 200000
300
+ DEEP_MAX = 20000 # was 200_000 — 10x reduction
301
+ DEEP_DEPTH_MAX = 8 # was 12 — shallower depth is usually enough
293
302
  def find_by_uri_deep(parent, target_uri, depth=0):
294
- if depth > 12 or _iterations[0] > DEEP_MAX:
303
+ if depth > DEEP_DEPTH_MAX or _iterations[0] > DEEP_MAX:
295
304
  return None
296
305
  try:
297
306
  children = list(parent.children)
@@ -326,9 +335,10 @@ def load_browser_item(song, params):
326
335
  }
327
336
 
328
337
  raise ValueError(
329
- "Item '%s' not found in browser (FileId URI — try "
330
- "search_browser to find the item, then use find_and_load_device "
331
- "with the exact name instead)" % uri
338
+ "Item '%s' not found inside deep-scan budget (FileId URI). "
339
+ "Use search_browser(query=...) to locate it without stalling "
340
+ "Ableton's audio thread, then call load_browser_item with the "
341
+ "returned URI." % uri
332
342
  )
333
343
 
334
344
  for sep in (":", "/"):
@@ -199,17 +199,22 @@ def toggle_device(song, params):
199
199
  track = get_track(song, track_index)
200
200
  device = get_device(track, device_index)
201
201
 
202
- # Find the "Device On" parameter by name (safer than assuming index 0)
202
+ # Find the "Device On" parameter by name the previous fallback
203
+ # blindly assumed parameters[0] was an on/off switch, which for many
204
+ # devices is actually "Filter Frequency", "Gain", or similar. The
205
+ # fallback silently mutated an arbitrary parameter while reporting
206
+ # is_active as if toggling had worked. Now refuse to guess.
203
207
  on_param = None
204
208
  for p in device.parameters:
205
209
  if p.name == "Device On":
206
210
  on_param = p
207
211
  break
208
212
  if on_param is None:
209
- # Fallback to parameter 0 for devices that don't use "Device On"
210
- if not list(device.parameters):
211
- raise ValueError("Device '%s' has no parameters to toggle" % device.name)
212
- on_param = device.parameters[0]
213
+ raise ValueError(
214
+ "Device '%s' exposes no 'Device On' parameter and cannot be "
215
+ "toggled programmatically. Use delete_device or disable it "
216
+ "through the UI." % device.name
217
+ )
213
218
 
214
219
  on_param.value = 1.0 if active else 0.0
215
220
  return {"name": device.name, "is_active": on_param.value > 0.5}
@@ -155,12 +155,23 @@ def modify_notes(song, params):
155
155
  for note in all_notes:
156
156
  note_map[note.note_id] = note
157
157
 
158
+ # Two-pass: validate every note_id BEFORE mutating any notes. The previous
159
+ # one-pass version raised ValueError mid-loop after some notes had already
160
+ # been mutated in place on the C++ NoteVector — yielding a half-modified
161
+ # state where the caller saw an error but earlier edits silently stuck
162
+ # (until apply_note_modifications was called, which it never was in the
163
+ # error path). Fail-all or apply-all is the safer contract.
164
+ missing = [int(mod["note_id"]) for mod in modifications
165
+ if int(mod["note_id"]) not in note_map]
166
+ if missing:
167
+ raise ValueError(
168
+ "Note IDs not found in clip: %s. No modifications applied." % missing
169
+ )
170
+
158
171
  # Apply modifications in-place on the original NoteVector's objects
159
172
  modified_count = 0
160
173
  for mod in modifications:
161
174
  note_id = int(mod["note_id"])
162
- if note_id not in note_map:
163
- raise ValueError("Note ID %d not found in clip" % note_id)
164
175
  note = note_map[note_id]
165
176
  if "pitch" in mod:
166
177
  note.pitch = int(mod["pitch"])