livepilot 1.10.6 → 1.10.8

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 (163) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/README.md +12 -10
  3. package/bin/livepilot.js +168 -30
  4. package/installer/install.js +117 -11
  5. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  6. package/m4l_device/livepilot_bridge.js +215 -3
  7. package/mcp_server/__init__.py +1 -1
  8. package/mcp_server/atlas/__init__.py +132 -33
  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/creative_constraints/tools.py +206 -33
  15. package/mcp_server/experiment/engine.py +7 -9
  16. package/mcp_server/hook_hunter/analyzer.py +62 -9
  17. package/mcp_server/hook_hunter/tools.py +60 -9
  18. package/mcp_server/m4l_bridge.py +68 -12
  19. package/mcp_server/musical_intelligence/detectors.py +32 -0
  20. package/mcp_server/performance_engine/tools.py +112 -29
  21. package/mcp_server/preview_studio/engine.py +89 -8
  22. package/mcp_server/preview_studio/tools.py +22 -6
  23. package/mcp_server/project_brain/automation_graph.py +71 -19
  24. package/mcp_server/project_brain/builder.py +2 -0
  25. package/mcp_server/project_brain/tools.py +55 -5
  26. package/mcp_server/reference_engine/profile_builder.py +129 -3
  27. package/mcp_server/reference_engine/tools.py +47 -6
  28. package/mcp_server/runtime/execution_router.py +66 -2
  29. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  30. package/mcp_server/runtime/remote_commands.py +10 -2
  31. package/mcp_server/sample_engine/analyzer.py +131 -4
  32. package/mcp_server/sample_engine/critics.py +29 -8
  33. package/mcp_server/sample_engine/models.py +42 -4
  34. package/mcp_server/sample_engine/tools.py +48 -14
  35. package/mcp_server/semantic_moves/__init__.py +1 -0
  36. package/mcp_server/semantic_moves/compiler.py +9 -1
  37. package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
  38. package/mcp_server/semantic_moves/mix_compilers.py +170 -0
  39. package/mcp_server/semantic_moves/mix_moves.py +1 -1
  40. package/mcp_server/semantic_moves/models.py +5 -0
  41. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  42. package/mcp_server/semantic_moves/tools.py +15 -4
  43. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  44. package/mcp_server/server.py +75 -5
  45. package/mcp_server/services/singletons.py +68 -0
  46. package/mcp_server/session_continuity/models.py +4 -0
  47. package/mcp_server/session_continuity/tracker.py +14 -1
  48. package/mcp_server/song_brain/builder.py +110 -12
  49. package/mcp_server/song_brain/tools.py +77 -13
  50. package/mcp_server/sound_design/tools.py +112 -1
  51. package/mcp_server/splice_client/client.py +29 -8
  52. package/mcp_server/stuckness_detector/detector.py +90 -0
  53. package/mcp_server/stuckness_detector/tools.py +41 -0
  54. package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
  55. package/mcp_server/tools/_composition_engine/__init__.py +2 -2
  56. package/mcp_server/tools/_composition_engine/harmony.py +90 -0
  57. package/mcp_server/tools/_composition_engine/sections.py +47 -4
  58. package/mcp_server/tools/_harmony_engine.py +52 -8
  59. package/mcp_server/tools/_research_engine.py +98 -19
  60. package/mcp_server/tools/_theory_engine.py +138 -9
  61. package/mcp_server/tools/agent_os.py +20 -3
  62. package/mcp_server/tools/analyzer.py +105 -6
  63. package/mcp_server/tools/clips.py +46 -1
  64. package/mcp_server/tools/composition.py +66 -23
  65. package/mcp_server/tools/devices.py +22 -1
  66. package/mcp_server/tools/harmony.py +115 -14
  67. package/mcp_server/tools/midi_io.py +23 -1
  68. package/mcp_server/tools/mixing.py +35 -1
  69. package/mcp_server/tools/motif.py +49 -3
  70. package/mcp_server/tools/research.py +24 -0
  71. package/mcp_server/tools/theory.py +108 -16
  72. package/mcp_server/tools/tracks.py +1 -1
  73. package/mcp_server/tools/transport.py +1 -1
  74. package/mcp_server/transition_engine/critics.py +18 -11
  75. package/mcp_server/translation_engine/tools.py +8 -4
  76. package/package.json +25 -3
  77. package/remote_script/LivePilot/__init__.py +77 -2
  78. package/remote_script/LivePilot/arrangement.py +12 -2
  79. package/remote_script/LivePilot/browser.py +16 -6
  80. package/remote_script/LivePilot/clips.py +69 -0
  81. package/remote_script/LivePilot/devices.py +10 -5
  82. package/remote_script/LivePilot/mixing.py +117 -0
  83. package/remote_script/LivePilot/notes.py +13 -2
  84. package/remote_script/LivePilot/router.py +13 -1
  85. package/remote_script/LivePilot/server.py +51 -13
  86. package/remote_script/LivePilot/version_detect.py +7 -4
  87. package/server.json +20 -0
  88. package/.claude-plugin/marketplace.json +0 -21
  89. package/.mcpbignore +0 -57
  90. package/AGENTS.md +0 -46
  91. package/CODE_OF_CONDUCT.md +0 -27
  92. package/CONTRIBUTING.md +0 -131
  93. package/SECURITY.md +0 -48
  94. package/livepilot/.Codex-plugin/plugin.json +0 -8
  95. package/livepilot/.claude-plugin/plugin.json +0 -8
  96. package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
  97. package/livepilot/commands/arrange.md +0 -47
  98. package/livepilot/commands/beat.md +0 -77
  99. package/livepilot/commands/evaluate.md +0 -49
  100. package/livepilot/commands/memory.md +0 -22
  101. package/livepilot/commands/mix.md +0 -44
  102. package/livepilot/commands/perform.md +0 -42
  103. package/livepilot/commands/session.md +0 -13
  104. package/livepilot/commands/sounddesign.md +0 -43
  105. package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
  106. package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
  107. package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
  108. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
  109. package/livepilot/skills/livepilot-core/SKILL.md +0 -184
  110. package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
  111. package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
  112. package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
  113. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
  114. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
  115. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
  116. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
  117. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
  118. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
  119. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
  120. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
  121. package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
  122. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
  123. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
  124. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
  125. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
  126. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
  127. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
  128. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
  129. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
  130. package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
  131. package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
  132. package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
  133. package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
  134. package/livepilot/skills/livepilot-core/references/overview.md +0 -290
  135. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
  136. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
  137. package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
  138. package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
  139. package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
  140. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
  141. package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
  142. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
  143. package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
  144. package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
  145. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
  146. package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
  147. package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
  148. package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
  149. package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
  150. package/livepilot/skills/livepilot-release/SKILL.md +0 -130
  151. package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
  152. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
  153. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
  154. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
  155. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
  156. package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
  157. package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
  158. package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
  159. package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
  160. package/manifest.json +0 -91
  161. package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
  162. package/scripts/generate_tool_catalog.py +0 -131
  163. package/scripts/sync_metadata.py +0 -132
@@ -92,7 +92,7 @@ REDUCE_REPETITION = SemanticMove(
92
92
  ],
93
93
  verification_plan=[
94
94
  {"tool": "get_track_meters", "check": "all tracks still producing audio", "backend": "remote_command"},
95
- {"tool": "capture_audio", "check": "LRA > 2 LU (dynamic range should increase)", "backend": "mcp_tool"},
95
+ {"tool": "capture_audio", "check": "LRA > 2 LU (dynamic range should increase)", "backend": "bridge_command"},
96
96
  ],
97
97
  )
98
98
 
@@ -24,6 +24,10 @@ class SemanticMove:
24
24
  plan_template: list = field(default_factory=list) # [{tool, params, description}] — static metadata, NOT runtime truth
25
25
  verification_plan: list = field(default_factory=list) # [{tool, check}]
26
26
  confidence: float = 0.7
27
+ # analytical_only: move is intentionally metadata-only — no compiler is
28
+ # expected. Surfaces in discovery/wonder_mode but never executes. Set this
29
+ # to True for moves that are deliberate "hints" rather than orphan-by-bug.
30
+ analytical_only: bool = False
27
31
 
28
32
  def to_dict(self) -> dict:
29
33
  return {
@@ -36,6 +40,7 @@ class SemanticMove:
36
40
  "required_capabilities": self.required_capabilities,
37
41
  "plan_template_steps": len(self.plan_template),
38
42
  "confidence": self.confidence,
43
+ "analytical_only": self.analytical_only,
39
44
  }
40
45
 
41
46
  def to_full_dict(self) -> dict:
@@ -14,9 +14,11 @@ from . import resolvers
14
14
  def _compile_add_warmth(move: SemanticMove, kernel: dict) -> CompiledPlan:
15
15
  """Compile 'add_warmth': volume boost + reverb send for perceived warmth.
16
16
 
17
- SAFETY: Never blindly set device parameters device_index=0, parameter_index=0
18
- can kill audio if the first device isn't a Saturator. Only adjust device params
19
- when find_device_on_track confirms a Saturator is present.
17
+ SAFETY: Never target device parameters by raw index. Ableton's parameter
18
+ index 0 is "Device On" on every device, so set_device_parameter(idx=0)
19
+ with any fractional value rounds to 0 and DISABLES the device. Use sends
20
+ and volume for warmth; device-param automation is only safe once the
21
+ resolver can look parameters up by name.
20
22
  """
21
23
  steps = []
22
24
  descriptions = []
@@ -31,24 +33,6 @@ def _compile_add_warmth(move: SemanticMove, kernel: dict) -> CompiledPlan:
31
33
  idx = t["index"]
32
34
  name = t["name"]
33
35
 
34
- # Try to find a Saturator on the track (safe device adjustment)
35
- saturator = resolvers.find_device_on_track(kernel, idx, "Saturator")
36
- if saturator:
37
- steps.append(CompiledStep(
38
- tool="set_device_parameter",
39
- params={
40
- "track_index": idx,
41
- "device_index": saturator["device_index"],
42
- "parameter_index": 0,
43
- "value": 0.3,
44
- },
45
- description=f"Gentle Saturator drive on {name}",
46
- ))
47
- descriptions.append(f"Saturate {name}")
48
- else:
49
- # No Saturator found — use volume + send instead of risky device params
50
- warnings.append(f"No Saturator on {name} — using volume+reverb for warmth")
51
-
52
36
  # Boost volume slightly for perceived warmth
53
37
  steps.append(CompiledStep(
54
38
  tool="set_track_volume",
@@ -84,32 +68,22 @@ def _compile_add_warmth(move: SemanticMove, kernel: dict) -> CompiledPlan:
84
68
 
85
69
 
86
70
  def _compile_add_texture(move: SemanticMove, kernel: dict) -> CompiledPlan:
87
- """Compile 'add_texture': perlin filter motion + delay send."""
71
+ """Compile 'add_texture': delay send for spatial texture.
72
+
73
+ Device-parameter automation (perlin filter motion) was removed because it
74
+ targeted device_index=0, parameter_index=0 without a resolver check — that
75
+ hits "Device On" on every Ableton device and would silently disable the
76
+ first device. Re-enable once resolvers.find_device_parameter lands.
77
+ """
88
78
  steps = []
89
79
  descriptions = []
80
+ warnings = []
90
81
 
91
82
  targets = resolvers.find_tracks_by_role(kernel, ["pad", "chords", "lead"])
92
83
 
93
84
  for t in targets[:1]:
94
85
  idx = t["index"]
95
86
  name = t["name"]
96
- steps.append(CompiledStep(
97
- tool="apply_automation_shape",
98
- params={
99
- "track_index": idx,
100
- "clip_index": 0,
101
- "parameter_type": "device",
102
- "device_index": 0,
103
- "parameter_index": 0,
104
- "curve_type": "perlin",
105
- "center": 0.4,
106
- "amplitude": 0.2,
107
- "duration": 8,
108
- "density": 16,
109
- },
110
- description=f"Perlin filter motion on {name} for organic texture",
111
- ))
112
- descriptions.append(f"Perlin filter on {name}")
113
87
 
114
88
  # Add delay send
115
89
  steps.append(CompiledStep(
@@ -119,6 +93,9 @@ def _compile_add_texture(move: SemanticMove, kernel: dict) -> CompiledPlan:
119
93
  ))
120
94
  descriptions.append(f"Delay texture on {name}")
121
95
 
96
+ if not targets:
97
+ warnings.append("No pad/chords/lead tracks — texture needs a melodic bed")
98
+
122
99
  steps.append(CompiledStep(
123
100
  tool="get_track_meters",
124
101
  params={"include_stereo": True},
@@ -132,14 +109,17 @@ def _compile_add_texture(move: SemanticMove, kernel: dict) -> CompiledPlan:
132
109
  risk_level="medium",
133
110
  summary="; ".join(descriptions) if descriptions else "No tracks for texture",
134
111
  requires_approval=(kernel.get("mode", "improve") != "explore"),
112
+ warnings=warnings,
135
113
  )
136
114
 
137
115
 
138
116
  def _compile_shape_transients(move: SemanticMove, kernel: dict) -> CompiledPlan:
139
117
  """Compile 'shape_transients': push drum volume for punch, adjust sends.
140
118
 
141
- SAFETY: Never blindly set device parameters. Only adjust Compressor params
142
- when find_device_on_track confirms one exists. Otherwise use volume for punch.
119
+ SAFETY: Never target device parameters by raw index. Index 0 on every
120
+ Ableton device is "Device On" — writing 0.2 rounds to 0 and disables the
121
+ device. Punch is achieved via volume + send shaping; Compressor attack
122
+ automation is only safe once the resolver can look parameters up by name.
143
123
  """
144
124
  steps = []
145
125
  descriptions = []
@@ -158,24 +138,7 @@ def _compile_shape_transients(move: SemanticMove, kernel: dict) -> CompiledPlan:
158
138
  idx = dt["index"]
159
139
  name = dt["name"]
160
140
 
161
- # Try to find a Compressor on the track
162
- compressor = resolvers.find_device_on_track(kernel, idx, "Compressor")
163
- if compressor:
164
- steps.append(CompiledStep(
165
- tool="set_device_parameter",
166
- params={
167
- "track_index": idx,
168
- "device_index": compressor["device_index"],
169
- "parameter_index": 0,
170
- "value": 0.2,
171
- },
172
- description=f"Faster Compressor attack on {name} for snap",
173
- ))
174
- descriptions.append(f"Shape {name} compressor")
175
- else:
176
- warnings.append(f"No Compressor on {name} — using volume push for punch")
177
-
178
- # Push volume for transient punch regardless
141
+ # Push volume for transient punch
179
142
  steps.append(CompiledStep(
180
143
  tool="set_track_volume",
181
144
  params={"track_index": idx, "volume": 0.75},
@@ -229,10 +229,21 @@ async def apply_semantic_move(
229
229
  # explore mode — execute through the async router
230
230
  from ..runtime.execution_router import execute_plan_steps_async
231
231
 
232
- step_dicts = [
233
- {"tool": step.tool, "params": step.params, "description": step.description}
234
- for step in plan.steps
235
- ]
232
+ # Propagate the optional backend annotation through to the router so a
233
+ # compiler that's certain about a step's backend (e.g. bridge_command for
234
+ # capture_audio) can short-circuit classify_step(). Steps without backend
235
+ # fall back to the classifier as before.
236
+ def _step_to_dict(step):
237
+ d = {
238
+ "tool": step.tool,
239
+ "params": step.params,
240
+ "description": step.description,
241
+ }
242
+ if getattr(step, "backend", None):
243
+ d["backend"] = step.backend
244
+ return d
245
+
246
+ step_dicts = [_step_to_dict(step) for step in plan.steps]
236
247
  bridge = ctx.lifespan_context.get("m4l")
237
248
  mcp_registry = ctx.lifespan_context.get("mcp_dispatch", {})
238
249
  exec_results = await execute_plan_steps_async(
@@ -8,31 +8,20 @@ from . import resolvers
8
8
 
9
9
 
10
10
  def _compile_increase_forward_motion(move: SemanticMove, kernel: dict) -> CompiledPlan:
11
- """Compile 'increase_forward_motion': rising filter + rhythm push."""
11
+ """Compile 'increase_forward_motion': rhythm push + reverb wash.
12
+
13
+ Device-parameter automation (rising filter sweep) was removed: targeting
14
+ device_index=0, parameter_index=0 without a resolver lookup hits "Device
15
+ On" on every Ableton device and would disable the first effect. Re-enable
16
+ once resolvers.find_device_parameter can locate a filter cutoff by name.
17
+ """
12
18
  steps = []
13
19
  descriptions = []
20
+ warnings = []
14
21
 
15
22
  melodic = resolvers.find_tracks_by_role(kernel, ["chords", "lead", "pad"])
16
23
  drums = resolvers.find_tracks_by_role(kernel, ["drums", "percussion"])
17
24
 
18
- for mt in melodic[:1]:
19
- steps.append(CompiledStep(
20
- tool="apply_automation_shape",
21
- params={
22
- "track_index": mt["index"],
23
- "clip_index": 0,
24
- "parameter_type": "device",
25
- "device_index": 0,
26
- "parameter_index": 0,
27
- "curve_type": "exponential",
28
- "start": 0.2,
29
- "end": 0.7,
30
- "duration": 4,
31
- },
32
- description=f"Rising filter sweep on {mt['name']} over 4 bars",
33
- ))
34
- descriptions.append(f"Rising filter on {mt['name']}")
35
-
36
25
  for dt in drums[:1]:
37
26
  steps.append(CompiledStep(
38
27
  tool="set_track_volume",
@@ -49,6 +38,9 @@ def _compile_increase_forward_motion(move: SemanticMove, kernel: dict) -> Compil
49
38
  ))
50
39
  descriptions.append(f"Reverb wash on {mt['name']}")
51
40
 
41
+ if not drums and not melodic:
42
+ warnings.append("No drum or melodic tracks — cannot build forward motion")
43
+
52
44
  steps.append(CompiledStep(
53
45
  tool="get_track_meters",
54
46
  params={"include_stereo": True},
@@ -62,6 +54,7 @@ def _compile_increase_forward_motion(move: SemanticMove, kernel: dict) -> Compil
62
54
  risk_level="low",
63
55
  summary="; ".join(descriptions) if descriptions else "No melodic tracks for motion",
64
56
  requires_approval=(kernel.get("mode", "improve") != "explore"),
57
+ warnings=warnings,
65
58
  )
66
59
 
67
60
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from contextlib import asynccontextmanager
4
4
  import asyncio
5
+ import logging
5
6
  import os
6
7
  import subprocess
7
8
 
@@ -10,6 +11,12 @@ from fastmcp import FastMCP, Context # noqa: F401
10
11
  from .connection import AbletonConnection
11
12
  from .m4l_bridge import SpectralCache, SpectralReceiver, M4LBridge
12
13
 
14
+ # Logger must be defined before any function uses it — several module-level
15
+ # helpers below (e.g. _master_has_livepilot_analyzer) call logger.debug on
16
+ # the import-time code path, so defining logger later raised NameError when
17
+ # those helpers fired from a tool module's module-level init.
18
+ logger = logging.getLogger(__name__)
19
+
13
20
 
14
21
  def _identify_port_holder(port: int) -> str | None:
15
22
  """Identify which process holds the given UDP port (for logging only).
@@ -33,13 +40,76 @@ def _identify_port_holder(port: int) -> str | None:
33
40
  text=True, timeout=2,
34
41
  ).strip()
35
42
  return f"{pid} ({cmdline[:60]})"
36
- except (subprocess.CalledProcessError, FileNotFoundError):
43
+ except (subprocess.CalledProcessError,
44
+ subprocess.TimeoutExpired,
45
+ FileNotFoundError):
37
46
  return str(pid)
38
47
  return None
39
- except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
48
+ except (subprocess.CalledProcessError,
49
+ subprocess.TimeoutExpired,
50
+ FileNotFoundError,
51
+ ValueError):
52
+ # TimeoutExpired catches the busy-system case where lsof exceeds
53
+ # the 3-second budget; we treat it as "can't identify" and return
54
+ # None so startup never stalls for slow host diagnostics.
40
55
  return None
41
56
 
42
57
 
58
+ def _check_remote_script_version(ableton: AbletonConnection) -> None:
59
+ """BUG-A1: detect stale Remote Script installs at startup.
60
+
61
+ The installed Remote Script is loaded by Ableton at its own launch time
62
+ and cached in Python's module system — source-tree edits don't take
63
+ effect until the user reinstalls + restarts Live. When the installed
64
+ copy lags behind the MCP-server source, commands added after the install
65
+ date (e.g. ``insert_device`` in v1.10.6) return "Unknown command type".
66
+
67
+ This check pings the Remote Script, compares its reported version to
68
+ the MCP server version, and logs a loud warning on mismatch. We don't
69
+ abort — the server should still work for whatever handlers the older
70
+ Remote Script does support — but we make the drift visible.
71
+ """
72
+ import sys
73
+
74
+ try:
75
+ from . import __version__ as mcp_version
76
+ except ImportError:
77
+ mcp_version = "unknown"
78
+
79
+ try:
80
+ pong = ableton.send_command("ping")
81
+ except Exception as exc:
82
+ import logging as _logging
83
+ _logging.getLogger(__name__).debug(
84
+ "Remote Script version check failed: %s", exc,
85
+ )
86
+ return
87
+
88
+ if not isinstance(pong, dict):
89
+ return
90
+ rs_version = pong.get("remote_script_version")
91
+ if rs_version is None:
92
+ # Remote Script is old enough that it doesn't even embed its version
93
+ # in ping responses — definitely stale.
94
+ msg = (
95
+ "LivePilot: Remote Script is out of date (pre-version-handshake). "
96
+ "Run 'npx livepilot --install' and restart Ableton Live to fix "
97
+ "'Unknown command type' errors for newer tools (insert_device, "
98
+ "set_clip_pitch, etc)."
99
+ )
100
+ print(msg, file=sys.stderr)
101
+ return
102
+
103
+ if str(rs_version) != str(mcp_version):
104
+ msg = (
105
+ f"LivePilot: Remote Script version {rs_version} does not match "
106
+ f"MCP server version {mcp_version}. Newer tools may fail with "
107
+ f"'Unknown command type'. Run 'npx livepilot --install' and "
108
+ f"restart Ableton Live to resync."
109
+ )
110
+ print(msg, file=sys.stderr)
111
+
112
+
43
113
  def _master_has_livepilot_analyzer(ableton: AbletonConnection) -> bool:
44
114
  """Check whether the analyzer device is currently on the master track."""
45
115
  try:
@@ -128,6 +198,9 @@ async def lifespan(server):
128
198
  }
129
199
 
130
200
  try:
201
+ # BUG-A1: detect stale Remote Script installs early so the user
202
+ # sees a clear message instead of cryptic "Unknown command type" errors.
203
+ _check_remote_script_version(ableton)
131
204
  if bridge_state["transport"] is not None:
132
205
  await _warm_analyzer_bridge(ableton, spectral)
133
206
  yield {
@@ -198,9 +271,6 @@ from .device_forge import tools as device_forge_tools # noqa: F401, E40
198
271
  from .sample_engine import tools as sample_engine_tools # noqa: F401, E402
199
272
  from .atlas import tools as atlas_tools # noqa: F401, E402
200
273
  from .composer import tools as composer_tools # noqa: F401, E402
201
- import logging
202
-
203
- logger = logging.getLogger(__name__)
204
274
 
205
275
  # ---------------------------------------------------------------------------
206
276
  # Schema coercion patch — accept strings for numeric parameters
@@ -0,0 +1,68 @@
1
+ """Thread-safe singleton helpers.
2
+
3
+ The server has several subsystems (atlas, corpus, sample-engine indexes)
4
+ that are loaded lazily into module-level globals via a check-then-set
5
+ pattern. Under FastMCP's async concurrency that pattern races: two
6
+ handlers can both observe ``None`` and both construct the (expensive)
7
+ object. Most of the time the GIL hides the race, but when it doesn't you
8
+ get redundant I/O and, worse, one thread's half-parsed state overwriting
9
+ the other's completed state.
10
+
11
+ This module provides a small helper that wraps a factory in a lock and
12
+ optionally tracks an on-disk mtime for cache invalidation. Use it in
13
+ place of hand-rolled ``_instance = None`` patterns.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from threading import Lock
19
+ from typing import Callable, TypeVar
20
+
21
+ T = TypeVar("T")
22
+
23
+
24
+ class Singleton:
25
+ """Lazy, thread-safe singleton with optional mtime-based reload.
26
+
27
+ Example:
28
+ atlas_holder = Singleton(_load_atlas)
29
+
30
+ def get_atlas():
31
+ return atlas_holder.get(reload_if_newer=atlas_path)
32
+
33
+ def on_atlas_rebuild():
34
+ atlas_holder.invalidate()
35
+ """
36
+
37
+ def __init__(self, factory: Callable[[], T]):
38
+ self._factory = factory
39
+ self._instance: T | None = None
40
+ self._mtime: float | None = None
41
+ self._lock = Lock()
42
+
43
+ def get(self, *, reload_if_newer: Path | None = None) -> T:
44
+ with self._lock:
45
+ if self._instance is None:
46
+ self._instance = self._factory()
47
+ if reload_if_newer is not None:
48
+ try:
49
+ self._mtime = reload_if_newer.stat().st_mtime
50
+ except OSError:
51
+ self._mtime = None
52
+ return self._instance
53
+
54
+ if reload_if_newer is not None:
55
+ try:
56
+ current = reload_if_newer.stat().st_mtime
57
+ except OSError:
58
+ return self._instance
59
+ if self._mtime is None or current > self._mtime:
60
+ self._instance = self._factory()
61
+ self._mtime = current
62
+ return self._instance
63
+
64
+ def invalidate(self) -> None:
65
+ """Discard the cached instance. Next .get() will re-run the factory."""
66
+ with self._lock:
67
+ self._instance = None
68
+ self._mtime = None
@@ -50,6 +50,9 @@ class SessionStory:
50
50
  """The narrative of the current session."""
51
51
 
52
52
  song_id: str = ""
53
+ # BUG-B16: link back to the SongBrain snapshot that generated the
54
+ # identity_summary so callers can tell which brain was used.
55
+ song_brain_id: str = ""
53
56
  identity_summary: str = ""
54
57
  what_changed_last: str = ""
55
58
  what_still_feels_open: list[str] = field(default_factory=list)
@@ -60,6 +63,7 @@ class SessionStory:
60
63
  def to_dict(self) -> dict:
61
64
  return {
62
65
  "song_id": self.song_id,
66
+ "song_brain_id": self.song_brain_id,
63
67
  "identity_summary": self.identity_summary,
64
68
  "what_changed_last": self.what_changed_last,
65
69
  "what_still_feels_open": self.what_still_feels_open,
@@ -50,10 +50,23 @@ def reset_story() -> None:
50
50
  def get_session_story(
51
51
  song_brain: Optional[dict] = None,
52
52
  ) -> SessionStory:
53
- """Get the current session story with identity summary."""
53
+ """Get the current session story with identity summary.
54
+
55
+ BUG-B16: now also populates song_brain_id from the passed brain so
56
+ callers can tell which brain generated the identity_summary.
57
+ Previously the field was empty and users got a half-populated
58
+ response that read as "something's wrong" even though the partial
59
+ data was correct for a fresh session.
60
+ """
54
61
  song_brain = song_brain or {}
55
62
 
56
63
  _story.identity_summary = song_brain.get("identity_core", "")
64
+ _story.song_brain_id = str(song_brain.get("brain_id", "") or "")
65
+ # Carry song_id through when present on the brain — fresh sessions
66
+ # leave this empty, which is documented below.
67
+ if not _story.song_id and song_brain.get("song_id"):
68
+ _story.song_id = str(song_brain.get("song_id"))
69
+
57
70
  _story.threads = [t for t in _threads.values() if t.status == "open"]
58
71
  _story.turns = _turns
59
72
  _story.what_still_feels_open = [
@@ -127,6 +127,14 @@ def _infer_identity_core(
127
127
  """Infer the single strongest defining idea in the session.
128
128
 
129
129
  Returns (description, confidence).
130
+
131
+ BUG-B10 fix: the old logic picked "Dominant texture: drums" at
132
+ confidence 0.5 for almost every session — because drum tracks
133
+ typically have the most notes. We now consider richer signals:
134
+ featured vocals, scene-name aesthetics, tempo+key context, and
135
+ single-instrument dominance. When multiple low-confidence signals
136
+ align (e.g. "dust" aesthetic + vocal hook + D minor key), we
137
+ combine them into a compound identity string.
130
138
  """
131
139
  candidates: list[tuple[str, float]] = []
132
140
 
@@ -144,7 +152,7 @@ def _infer_identity_core(
144
152
  if arc_type:
145
153
  candidates.append((f"Emotional arc: {arc_type}", 0.6))
146
154
 
147
- # From role graph — dominant texture
155
+ # From role graph — dominant texture (kept but gently deranked)
148
156
  # role_graph format: {track_name: {index: int, role: str}}
149
157
  if role_graph:
150
158
  role_counts = Counter(
@@ -153,9 +161,15 @@ def _infer_identity_core(
153
161
  if isinstance(info, dict)
154
162
  )
155
163
  role_counts.pop("unknown", None)
156
- if role_counts:
157
- dominant_role = role_counts.most_common(1)[0]
158
- candidates.append((f"Dominant texture: {dominant_role[0]}", 0.5))
164
+ # B10 fix: drums being the "dominant texture" is almost never
165
+ # what the song is ABOUT — it's just that drum tracks have the
166
+ # most notes. Skip drums/perc from this candidate stream.
167
+ _BORING_DOMINANT = {"drums", "percussion", "kick", "snare", "hat"}
168
+ for role, _ in role_counts.most_common(3):
169
+ if role.lower() in _BORING_DOMINANT:
170
+ continue
171
+ candidates.append((f"Dominant texture: {role}", 0.55))
172
+ break
159
173
 
160
174
  # From track analysis — genre/style cues
161
175
  track_names = [t.get("name", "").lower() for t in tracks]
@@ -163,12 +177,65 @@ def _infer_identity_core(
163
177
  if genre_cues:
164
178
  candidates.append((f"Style: {genre_cues}", 0.4))
165
179
 
180
+ # BUG-B10: featured instrument — a named vocal/pad/lead track with
181
+ # an explicit function is usually more identity-defining than
182
+ # "dominant texture: drums".
183
+ _FEATURED_TOKENS = (
184
+ ("vocal", "vocal hook", 0.75),
185
+ ("vox", "vocal hook", 0.72),
186
+ ("pad", "pad-led atmosphere", 0.55),
187
+ ("lead", "lead synth melody", 0.65),
188
+ ("rhodes", "rhodes-keys texture", 0.60),
189
+ ("piano", "piano-led harmony", 0.60),
190
+ ("guitar", "guitar-led", 0.60),
191
+ ("saxophone", "saxophone solo", 0.65),
192
+ ("brass", "brass section", 0.55),
193
+ )
194
+ for name in track_names:
195
+ for token, label, conf in _FEATURED_TOKENS:
196
+ if token in name:
197
+ candidates.append((f"Featured element: {label}", conf))
198
+ break
199
+
200
+ # BUG-B10: scene-name aesthetic cues. A scene named "Intro Dust" /
201
+ # "Outro Dust" signals a deliberate dust/lo-fi aesthetic; "Sun Peak"
202
+ # / "Peak" signals a climax-oriented structure. Pull these from the
203
+ # composition-analysis section list if present.
204
+ _AESTHETIC_TOKENS = (
205
+ ("dust", "dust-toned lo-fi"),
206
+ ("sun", "warm/sun-peaked"),
207
+ ("fog", "foggy/dreamy"),
208
+ ("glass", "brittle/glass-like"),
209
+ ("void", "void/ambient-spatial"),
210
+ ("haze", "hazy/nostalgic"),
211
+ ("bloom", "blooming/evolving"),
212
+ )
213
+ sections = composition.get("sections", []) or []
214
+ section_names = " ".join(
215
+ str(s.get("name", "") or s.get("label", "")).lower()
216
+ for s in sections
217
+ )
218
+ for token, label in _AESTHETIC_TOKENS:
219
+ if token in section_names:
220
+ candidates.append((f"Aesthetic: {label}", 0.55))
221
+ break
222
+
166
223
  if not candidates:
167
224
  # Fallback: describe by track count and tempo
168
225
  return ("Emerging piece — identity not yet established", 0.2)
169
226
 
170
- best = max(candidates, key=lambda c: c[1])
171
- return best
227
+ # BUG-B10: when no single candidate is confident (>0.6), blend the
228
+ # top 2 into a compound identity — captures "vocal hook + dust
229
+ # aesthetic" style identity rather than picking one weak signal.
230
+ candidates.sort(key=lambda c: c[1], reverse=True)
231
+ top = candidates[0]
232
+ if top[1] >= 0.6 or len(candidates) < 2:
233
+ return top
234
+ # Blend top 2
235
+ second = candidates[1]
236
+ blended_desc = f"{top[0]} + {second[0].lower()}"
237
+ blended_conf = min(0.85, (top[1] + second[1]) / 2 + 0.1)
238
+ return (blended_desc, blended_conf)
172
239
 
173
240
 
174
241
  def _detect_genre_cues(track_names: list[str]) -> str:
@@ -260,6 +327,12 @@ def _detect_sacred_elements(
260
327
  # ── Section purposes ──────────────────────────────────────────────
261
328
 
262
329
 
330
+ # Section intents that imply this is a "payoff" / arrival moment.
331
+ # Used by _infer_section_purposes to derive is_payoff consistently
332
+ # when composition returns an intent label without the explicit flag.
333
+ _PAYOFF_INTENTS = frozenset({"payoff", "drop", "chorus", "hook"})
334
+
335
+
263
336
  def _infer_section_purposes(
264
337
  scenes: list[dict],
265
338
  composition: dict,
@@ -271,12 +344,27 @@ def _infer_section_purposes(
271
344
  comp_sections = composition.get("sections", [])
272
345
  if comp_sections:
273
346
  for sec in comp_sections:
347
+ name = str(sec.get("name", ""))
348
+ # BUG-B12: skip empty placeholder sections that pollute the
349
+ # energy_arc and section_purposes list. A section with no name
350
+ # AND zero energy corresponds to an unnamed empty scene slot.
351
+ if not name.strip() and not sec.get("energy", 0):
352
+ continue
353
+ intent = sec.get("intent", sec.get("purpose", "")) or ""
354
+ # BUG-B11: derive is_payoff from the intent label when the
355
+ # explicit flag isn't set. Composition engine returns
356
+ # intent="drop"/"chorus"/"hook"/"payoff" — these all mean the
357
+ # section IS a payoff, so is_payoff must reflect that.
358
+ is_payoff = bool(
359
+ sec.get("is_payoff", False)
360
+ or intent.lower() in _PAYOFF_INTENTS
361
+ )
274
362
  sections.append(SectionPurpose(
275
- section_id=sec.get("id", sec.get("name", "")),
276
- label=sec.get("label", sec.get("name", "")),
277
- emotional_intent=sec.get("intent", sec.get("purpose", "")),
363
+ section_id=sec.get("id", name),
364
+ label=sec.get("label", name),
365
+ emotional_intent=intent,
278
366
  energy_level=sec.get("energy", 0.5),
279
- is_payoff=sec.get("is_payoff", False),
367
+ is_payoff=is_payoff,
280
368
  confidence=0.7,
281
369
  ))
282
370
  return sections
@@ -284,6 +372,10 @@ def _infer_section_purposes(
284
372
  # Fallback: infer from scene names
285
373
  for i, scene in enumerate(scenes):
286
374
  name = scene.get("name", f"Scene {i}")
375
+ # BUG-B12 (fallback path): skip empty scenes so they don't pollute
376
+ # the output even when no composition data is available.
377
+ if not str(name).strip():
378
+ continue
287
379
  label, intent, energy, is_payoff = _classify_scene_name(name, i, len(scenes))
288
380
  sections.append(SectionPurpose(
289
381
  section_id=f"scene_{i}",
@@ -389,8 +481,14 @@ def _detect_open_questions(
389
481
  ))
390
482
 
391
483
  # Missing sections (common gaps)
392
- labels = {s.label for s in sections}
393
- if len(sections) > 3 and "intro" not in labels:
484
+ # BUG-B14: check substrings across labels AND emotional intents
485
+ # (case-insensitive) so scene names like "Intro Dust" or intent "intro"
486
+ # both satisfy the check. Exact-match on the label set missed those.
487
+ signal_text = " ".join(
488
+ f"{s.label} {s.emotional_intent}".lower() for s in sections
489
+ )
490
+ has_intro = any(kw in signal_text for kw in ("intro", "opening", "opener"))
491
+ if len(sections) > 3 and not has_intro:
394
492
  questions.append(OpenQuestion(
395
493
  question="No intro section — does the track need an opening?",
396
494
  domain="arrangement",