livepilot 1.27.2 → 1.27.3

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 (113) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +97 -39
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +1 -1
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  28. package/mcp_server/composer/full/apply.py +40 -34
  29. package/mcp_server/composer/full/engine.py +66 -32
  30. package/mcp_server/composer/tools.py +4 -3
  31. package/mcp_server/connection.py +67 -9
  32. package/mcp_server/creative_constraints/engine.py +10 -2
  33. package/mcp_server/creative_constraints/tools.py +24 -7
  34. package/mcp_server/curves.py +30 -7
  35. package/mcp_server/device_forge/builder.py +40 -11
  36. package/mcp_server/device_forge/models.py +9 -0
  37. package/mcp_server/device_forge/tools.py +30 -11
  38. package/mcp_server/experiment/engine.py +29 -22
  39. package/mcp_server/experiment/tools.py +15 -5
  40. package/mcp_server/m4l_bridge.py +69 -7
  41. package/mcp_server/memory/taste_graph.py +43 -0
  42. package/mcp_server/memory/technique_store.py +26 -3
  43. package/mcp_server/memory/tools.py +62 -4
  44. package/mcp_server/mix_engine/critics.py +187 -30
  45. package/mcp_server/mix_engine/models.py +21 -1
  46. package/mcp_server/mix_engine/state_builder.py +87 -8
  47. package/mcp_server/mix_engine/tools.py +16 -4
  48. package/mcp_server/performance_engine/tools.py +56 -8
  49. package/mcp_server/persistence/base_store.py +11 -0
  50. package/mcp_server/persistence/project_store.py +132 -8
  51. package/mcp_server/persistence/taste_store.py +90 -7
  52. package/mcp_server/preview_studio/engine.py +40 -10
  53. package/mcp_server/preview_studio/models.py +40 -0
  54. package/mcp_server/preview_studio/tools.py +56 -12
  55. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  56. package/mcp_server/project_brain/models.py +2 -0
  57. package/mcp_server/project_brain/role_graph.py +13 -7
  58. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  59. package/mcp_server/reference_engine/profile_builder.py +47 -4
  60. package/mcp_server/reference_engine/tools.py +6 -0
  61. package/mcp_server/runtime/execution_router.py +51 -1
  62. package/mcp_server/runtime/tools.py +0 -1
  63. package/mcp_server/sample_engine/sources.py +0 -2
  64. package/mcp_server/sample_engine/tools.py +19 -38
  65. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  66. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  67. package/mcp_server/semantic_moves/resolvers.py +45 -0
  68. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  69. package/mcp_server/semantic_moves/tools.py +80 -2
  70. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  71. package/mcp_server/server.py +26 -24
  72. package/mcp_server/session_continuity/tracker.py +51 -3
  73. package/mcp_server/song_brain/builder.py +47 -5
  74. package/mcp_server/song_brain/tools.py +21 -7
  75. package/mcp_server/sound_design/critics.py +1 -0
  76. package/mcp_server/splice_client/client.py +117 -33
  77. package/mcp_server/splice_client/http_bridge.py +15 -3
  78. package/mcp_server/splice_client/quota.py +28 -0
  79. package/mcp_server/stuckness_detector/detector.py +8 -5
  80. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  81. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  82. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  83. package/mcp_server/tools/_perception_engine.py +6 -0
  84. package/mcp_server/tools/agent_os.py +4 -1
  85. package/mcp_server/tools/analyzer.py +198 -209
  86. package/mcp_server/tools/arrangement.py +7 -4
  87. package/mcp_server/tools/automation.py +24 -4
  88. package/mcp_server/tools/browser.py +25 -11
  89. package/mcp_server/tools/clips.py +6 -0
  90. package/mcp_server/tools/composition.py +33 -2
  91. package/mcp_server/tools/devices.py +53 -53
  92. package/mcp_server/tools/generative.py +14 -14
  93. package/mcp_server/tools/harmony.py +7 -7
  94. package/mcp_server/tools/mixing.py +4 -4
  95. package/mcp_server/tools/planner.py +68 -6
  96. package/mcp_server/tools/research.py +20 -2
  97. package/mcp_server/tools/theory.py +10 -10
  98. package/mcp_server/tools/transport.py +7 -2
  99. package/mcp_server/transition_engine/critics.py +13 -1
  100. package/mcp_server/user_corpus/tools.py +30 -1
  101. package/mcp_server/wonder_mode/engine.py +82 -9
  102. package/mcp_server/wonder_mode/session.py +32 -10
  103. package/mcp_server/wonder_mode/tools.py +14 -1
  104. package/package.json +1 -1
  105. package/remote_script/LivePilot/__init__.py +1 -1
  106. package/remote_script/LivePilot/arrangement.py +93 -33
  107. package/remote_script/LivePilot/browser.py +60 -4
  108. package/remote_script/LivePilot/devices.py +132 -62
  109. package/remote_script/LivePilot/mixing.py +31 -5
  110. package/remote_script/LivePilot/server.py +94 -22
  111. package/remote_script/LivePilot/transport.py +11 -0
  112. package/requirements.txt +5 -5
  113. package/server.json +2 -2
@@ -208,20 +208,46 @@ def _arrangement_steps(
208
208
  layer: LayerSpec,
209
209
  sections: list[dict],
210
210
  ) -> list[dict]:
211
- """Emit the full arrangement sequence for a layer.
211
+ """Emit the Session-View source-clip scaffold for a layer.
212
212
 
213
213
  For each layer that appears in at least one section, we emit:
214
214
 
215
215
  1. create_clip — a 1-bar MIDI clip in session slot 0 (the source)
216
216
  2. add_notes — a single C3 trigger note so Simpler actually sounds
217
- 3. create_arrangement_clip (×N) — one per section the layer is in,
218
- tiling the 1-bar source across each section's bar count
219
-
220
- The trigger-clip-plus-tile approach is intentionally minimal: Simpler
221
- in classic mode plays the full sample on every note, so a single C3
222
- at bar 0 is enough for a playable baseline. The suggest_sample_technique
223
- step elsewhere in the plan produces a recipe the agent can use later
224
- to replace the trigger pattern with something more musical.
217
+
218
+ The trigger-clip approach is intentionally minimal: Simpler in classic
219
+ mode plays the full sample on every note, so a single C3 at bar 0 is
220
+ enough for a playable, auditionable baseline in Session View. The
221
+ suggest_sample_technique step elsewhere in the plan produces a recipe
222
+ the agent can use later to replace the trigger pattern with something
223
+ more musical.
224
+
225
+ BUG-FULL-MODE-18 (rescoped): this used to also emit one
226
+ `create_arrangement_clip` per section, tiling the SAME 1-bar/single-note
227
+ source across every section (intro/build/drop/breakdown/outro all play
228
+ the identical loop). That isn't arrangement — it's pattern
229
+ multiplication dressed up as one, and it's worse than not placing
230
+ anything at all because it *looks* like a finished arrangement to a
231
+ caller inspecting `plan`. `ComposerEngine` is a pure-computation engine
232
+ with no per-section creative content generation (no LLM in the loop,
233
+ no per-section note variation) — that capability lives in the
234
+ agent-designed `apply_full_plan_v2` variant-slot system
235
+ (`composer/full/apply.py`), which real callers of `compose_full_apply`
236
+ already use instead of this deterministic engine (its own docstring
237
+ says as much: "Replaces the deterministic engine path that was prone
238
+ to flat single-pattern arrangements (BUG-FULL-MODE-18)").
239
+ `ComposerEngine.compose()` is reachable through a second path though —
240
+ `commit_experiment` → `escalate_composer_branch` — which has no agent
241
+ turn to design real per-section variants. Porting the variant-slot
242
+ system here would require synthesizing per-section musical content
243
+ from nothing, which is out of scope for a mechanical renumbering/
244
+ dead-code fix and would just relocate the same static content into
245
+ more slots without solving the actual "flat" complaint. So: this
246
+ engine now emits ONLY the Session-View scaffold (source clip + track),
247
+ honestly leaving Arrangement placement undone rather than faking it.
248
+ Callers that need real per-section arrangement variation should use
249
+ `compose_full_apply` (or fall back to the branch-hypothesis scaffold
250
+ plan, which never claimed arrangement placement in the first place).
225
251
  """
226
252
  active_sections = [s for s in sections if s["name"] in layer.sections]
227
253
  if not active_sections:
@@ -273,27 +299,6 @@ def _arrangement_steps(
273
299
  "role": layer.role,
274
300
  })
275
301
 
276
- # 3. One arrangement clip per section this layer appears in
277
- for section in active_sections:
278
- start_bar = section["start_bar"]
279
- bar_count = section["bars"]
280
- steps.append({
281
- "tool": "create_arrangement_clip",
282
- "params": {
283
- "track_index": track_index,
284
- "clip_slot_index": SOURCE_SLOT,
285
- "start_time": float(start_bar * 4.0), # bars → beats
286
- "length": float(bar_count * 4.0),
287
- "loop_length": SOURCE_BEATS, # tile 1-bar source
288
- },
289
- "description": (
290
- f"Arrange {layer.role} into '{section['name']}' "
291
- f"(bar {start_bar}, {bar_count} bars)"
292
- ),
293
- "role": layer.role,
294
- "section": section["name"],
295
- })
296
-
297
302
  return steps
298
303
 
299
304
 
@@ -354,9 +359,18 @@ class ComposerEngine:
354
359
  plan.append(_step_set_tempo(intent.tempo))
355
360
 
356
361
  # Step 2: Per-layer build, resolving samples at plan time
362
+ #
363
+ # BUG-FULL-MODE-15: track_index used to be `layer_idx` — the layer's
364
+ # position in the ORIGINAL (pre-drop) layer list. If an earlier layer
365
+ # dropped as unresolved (no continue happened for it, so no track was
366
+ # ever created for that index), later layers still emitted their
367
+ # stale original index — e.g. layers 0,3,4 survive out of 0..4, and
368
+ # `create_midi_track(index=3)` fails because only 1 track exists.
369
+ # `next_track_index` instead counts only ACTUAL track creations, so
370
+ # surviving layers get contiguous indices (0, 1, 2, ...) matching
371
+ # the tracks that really get created.
372
+ next_track_index = 0
357
373
  for layer_idx, layer in enumerate(layers):
358
- track_index = layer_idx
359
-
360
374
  file_path, source = await resolve_sample_for_layer(
361
375
  layer,
362
376
  search_roots=search_roots,
@@ -371,6 +385,9 @@ class ComposerEngine:
371
385
  )
372
386
  continue
373
387
 
388
+ track_index = next_track_index
389
+ next_track_index += 1
390
+
374
391
  result.resolved_samples[layer.role] = {"path": file_path, "source": source}
375
392
 
376
393
  track_step_id = f"layer_{layer_idx}_track"
@@ -387,6 +404,23 @@ class ComposerEngine:
387
404
  plan.extend(_mix_steps(track_index, layer))
388
405
  plan.extend(_arrangement_steps(track_index, layer, sections))
389
406
 
407
+ # BUG-FULL-MODE-18 (rescoped): this engine only emits a Session-View
408
+ # scaffold per layer (source clip + trigger note) — it does not
409
+ # place anything in Arrangement, since it has no per-section
410
+ # creative content to place. Flag this once so callers (e.g.
411
+ # escalate_composer_branch) don't mistake `plan` for a finished
412
+ # arrangement. Real per-section arrangement variation lives in the
413
+ # agent-designed apply_full_plan_v2 flow (compose_full_apply).
414
+ if layers:
415
+ result.warnings.append(
416
+ "This scaffold places one Session-View source clip per "
417
+ "resolved layer and does not write anything to Arrangement "
418
+ "View — per-section arrangement variation is intentionally "
419
+ "out of scope for this deterministic engine. Use "
420
+ "compose_full_apply's agent-designed variant plan for real "
421
+ "per-section arrangement content."
422
+ )
423
+
390
424
  result.plan = plan
391
425
  return result
392
426
 
@@ -19,6 +19,7 @@ from .full.engine import ComposerEngine
19
19
  from . import fast as fast_compose
20
20
  from .fast.apply import apply_fast_plan
21
21
  from .full.apply import apply_full_plan
22
+ import asyncio
22
23
  import logging
23
24
  import time
24
25
 
@@ -276,8 +277,8 @@ async def compose(
276
277
  return brief
277
278
 
278
279
  if mode == "fast":
279
- brief = _build_fast_brief(
280
- ctx, intent, bars=int(bars), reference=reference,
280
+ brief = await asyncio.to_thread(
281
+ _build_fast_brief, ctx, intent, bars=int(bars), reference=reference,
281
282
  )
282
283
  brief["prompt"] = prompt
283
284
  return brief
@@ -651,7 +652,7 @@ async def augment_with_samples(
651
652
  try:
652
653
  ableton = ctx.lifespan_context.get("ableton")
653
654
  if ableton:
654
- info = ableton.send_command("get_session_info", {})
655
+ info = await ableton.send_command_async("get_session_info", {})
655
656
  session_context["tempo"] = info.get("tempo", 120)
656
657
  session_context["track_count"] = info.get("track_count", 0)
657
658
  except Exception as exc:
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import json
6
7
  import os
7
8
  import socket
@@ -79,9 +80,20 @@ def _identify_other_tcp_client(host: str, port: int) -> str | None:
79
80
  out = subprocess.check_output(
80
81
  ["lsof", "-nP", f"-iTCP:{port}"],
81
82
  text=True,
82
- timeout=3,
83
+ # Bounded tight: this runs inside the send lock on a socket
84
+ # timeout, so a slow lsof would extend the lock-hold (blocking
85
+ # every concurrent tool). TimeoutExpired is a SubprocessError —
86
+ # NOT a CalledProcessError — so it must be caught explicitly or
87
+ # it propagates out of the error-diagnostic path and crashes it.
88
+ timeout=1,
83
89
  )
84
- except (subprocess.CalledProcessError, FileNotFoundError, ValueError, OSError):
90
+ except (
91
+ subprocess.CalledProcessError,
92
+ subprocess.TimeoutExpired,
93
+ FileNotFoundError,
94
+ ValueError,
95
+ OSError,
96
+ ):
85
97
  return None
86
98
 
87
99
  target = f"->{host}:{port}"
@@ -119,6 +131,10 @@ class AbletonConnection:
119
131
 
120
132
  def connect(self) -> None:
121
133
  """Open a TCP connection to the Remote Script."""
134
+ # Close any socket we still hold before opening a new one, otherwise the
135
+ # previous fd/socket leaks if connect() is called without a disconnect().
136
+ if self._socket is not None:
137
+ self.disconnect()
122
138
  try:
123
139
  sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
124
140
  sock.settimeout(CONNECT_TIMEOUT)
@@ -283,12 +299,36 @@ class AbletonConnection:
283
299
  message = err.get("message", str(err)) if isinstance(err, dict) else str(err)
284
300
  log_entry["error"] = code
285
301
  self._command_log.append(log_entry)
302
+ # Client-eviction visibility: when this is the single-client guard
303
+ # rejection (this process lost the socket to another connected
304
+ # client — even after the retry-once path above), always try to
305
+ # surface WHO holds it so the user doesn't have to go hunting.
306
+ if _is_single_client_state_error(response):
307
+ other_client = _identify_other_tcp_client(self.host, self.port)
308
+ if other_client:
309
+ message = (
310
+ f"{message} Another LivePilot client appears to be "
311
+ f"connected on {self.host}:{self.port} ({other_client}). "
312
+ "Disconnect the other client and retry."
313
+ )
286
314
  friendly = _friendly_error(code, message, command_type)
287
315
  raise AbletonConnectionError(friendly)
288
316
 
289
317
  self._command_log.append(log_entry)
290
318
  return response.get("result", {})
291
319
 
320
+ async def send_command_async(self, command_type: str, params: Optional[dict] = None) -> dict:
321
+ """Async wrapper around :meth:`send_command`.
322
+
323
+ ``send_command`` performs a blocking TCP round-trip guarded by
324
+ ``self._lock``. Async MCP tools run on FastMCP's single event loop, so
325
+ calling ``send_command`` directly from one would freeze every other
326
+ concurrent coroutine (other tool calls, the UDP analyzer bridge, etc.)
327
+ for the duration of the round-trip. Offload it to a worker thread so
328
+ the loop stays free.
329
+ """
330
+ return await asyncio.to_thread(self.send_command, command_type, params)
331
+
292
332
  # ------------------------------------------------------------------
293
333
  # Command log
294
334
  # ------------------------------------------------------------------
@@ -340,7 +380,9 @@ class AbletonConnection:
340
380
  err._send_completed = True
341
381
  raise err
342
382
  except socket.timeout as exc:
343
- self._recv_buf = buf
383
+ # Timeout is fatal to the connection: disconnect() below wipes
384
+ # _recv_buf to b"", so preserving the partial `buf` here would be
385
+ # dead, self-contradicting state. Drop it.
344
386
  self.disconnect()
345
387
  other_client = _identify_other_tcp_client(self.host, self.port)
346
388
  if other_client:
@@ -352,7 +394,7 @@ class AbletonConnection:
352
394
  err._send_completed = True
353
395
  raise err from exc
354
396
  err = AbletonConnectionError(
355
- f"Timeout waiting for response from Ableton ({RECV_TIMEOUT}s)"
397
+ f"Timeout waiting for response from Ableton ({recv_timeout}s)"
356
398
  )
357
399
  err._send_completed = True
358
400
  raise err from exc
@@ -368,11 +410,27 @@ class AbletonConnection:
368
410
  line, remainder = buf.split(b"\n", 1)
369
411
  self._recv_buf = remainder
370
412
  try:
371
- return json.loads(line)
372
- except json.JSONDecodeError as exc:
373
- raise AbletonConnectionError(
374
- f"Invalid JSON from Ableton: {line[:200]}"
375
- ) from exc
413
+ try:
414
+ parsed = json.loads(line)
415
+ except json.JSONDecodeError as exc:
416
+ raise AbletonConnectionError(
417
+ f"Invalid JSON from Ableton: {line[:200]}"
418
+ ) from exc
419
+
420
+ # Correlate the response against the command we just sent. The
421
+ # Remote Script echoes our request id; a mismatch means we read an
422
+ # orphan / mis-paired frame and must not return it as this
423
+ # command's result.
424
+ resp_id = parsed.get("id") if isinstance(parsed, dict) else None
425
+ if resp_id is not None and resp_id != envelope["id"]:
426
+ self.disconnect()
427
+ err = AbletonConnectionError(
428
+ f"Response id mismatch from Ableton "
429
+ f"(expected {envelope['id']}, got {resp_id})"
430
+ )
431
+ err._send_completed = True
432
+ raise err
433
+ return parsed
376
434
  finally:
377
435
  if self._socket is not None:
378
436
  try:
@@ -103,14 +103,22 @@ def validate_plan_against_constraints(
103
103
 
104
104
  elif constraint == "subtraction_only":
105
105
  add_actions = {"create_clip", "create_midi_track", "create_audio_track",
106
- "duplicate_clip", "duplicate_track"}
106
+ "duplicate_clip", "duplicate_track",
107
+ # The most common content-ADDING tools were missing, so
108
+ # subtraction_only let additions through. Real tool names:
109
+ "add_notes", "add_arrangement_notes", "create_scene",
110
+ "duplicate_scene", "insert_simpler_slice", "add_drum_rack_pad",
111
+ "create_arrangement_clip", "insert_device", "insert_rack_chain"}
107
112
  for step in steps:
108
113
  if step.get("action", "") in add_actions:
109
114
  violations.append(f"Step adds content ({step['action']}) — violates subtraction_only")
110
115
 
111
116
  elif constraint == "arrangement_only":
117
+ # NOTE: "set_track_send" is the REAL registered send tool; the old
118
+ # "set_send_level" is a dead name that never matched a compiled step,
119
+ # so send-level moves silently passed under arrangement_only.
112
120
  mix_actions = {"set_device_parameter", "set_track_volume", "set_track_pan",
113
- "set_send_level"}
121
+ "set_track_send"}
114
122
  for step in steps:
115
123
  if step.get("action", "") in mix_actions:
116
124
  violations.append(f"Step modifies mix ({step['action']}) — violates arrangement_only")
@@ -118,16 +118,23 @@ def distill_reference_principles(
118
118
  except Exception as exc:
119
119
  logger.debug("distill_reference_principles failed: %s", exc)
120
120
 
121
- # Try the built-in style profile builder
122
- if not reference_profile:
121
+ # Try the built-in style profile builder. build_style_reference_profile
122
+ # takes a LIST OF TACTIC DICTS (StyleTactic.to_dict()), not a raw string —
123
+ # get_style_tactics resolves a NAMED style (artist/genre) to that data.
124
+ # Passing the bare style_name string made the builder iterate the string's
125
+ # characters, raise AttributeError on `char.get(...)`, and silently fall
126
+ # through to the text-keyword path below — so this whole branch was dead.
127
+ if not reference_profile and style_name:
123
128
  try:
124
129
  from ..reference_engine.profile_builder import build_style_reference_profile
125
- profile = build_style_reference_profile(
126
- style_name or reference_description
127
- )
128
- reference_profile = profile.to_dict() if profile else {}
130
+ from ..tools._research_engine import get_style_tactics
131
+ tactics = get_style_tactics(style_name)
132
+ tactic_dicts = [t.to_dict() for t in tactics]
133
+ if tactic_dicts:
134
+ profile = build_style_reference_profile(tactic_dicts)
135
+ reference_profile = profile.to_dict() if profile else {}
129
136
  except Exception as exc:
130
- logger.debug("distill_reference_principles failed: %s", exc)
137
+ logger.debug("distill_reference_principles style-profile failed: %s", exc)
131
138
 
132
139
  # Text-keyword fallback ALWAYS merges in. Style tactics + profile
133
140
  # builder typically leave some fields empty; the description's
@@ -247,6 +254,14 @@ def generate_constrained_variants(
247
254
  # variants to status='blocked', and count blocked_count in the
248
255
  # response so callers can detect the "all variants filtered" case.
249
256
  blocked_count = 0
257
+ # Surface which constraints are advisory-only (recognized but NOT
258
+ # enforced by the filter). These depend on the constraint SET, not the
259
+ # plan, so compute once. Without this, a caller requesting a purely
260
+ # advisory constraint gets blocked_count=0 with no signal that
261
+ # enforcement was advisory-only.
262
+ constraint_meta = engine.validate_plan_against_constraints({"steps": []}, active)
263
+ unenforced_constraints = constraint_meta.get("unenforced_constraints", [])
264
+ constraint_warnings = constraint_meta.get("warnings", [])
250
265
  for v in ps.variants:
251
266
  v.what_preserved = (
252
267
  f"{v.what_preserved} | Constraints: "
@@ -291,6 +306,8 @@ def generate_constrained_variants(
291
306
  return {
292
307
  "preview_set": ps.to_dict(),
293
308
  "constraints_applied": active.constraints,
309
+ "unenforced_constraints": unenforced_constraints,
310
+ "constraint_warnings": constraint_warnings,
294
311
  "blocked_count": blocked_count,
295
312
  "executable_count": len(ps.variants) - blocked_count,
296
313
  "note": note,
@@ -151,9 +151,15 @@ def generate_curve(
151
151
 
152
152
  points = gen(**kwargs)
153
153
 
154
- # Auto-calculate point duration if not specified
154
+ # Auto-calculate point duration if not specified. Use the actual spacing
155
+ # between consecutive points rather than duration/len(points): density
156
+ # generators that span the closed interval [0, duration] space points at
157
+ # duration/(density-1), so duration/len would under-shoot and the final
158
+ # step would overshoot the clip end. Reading the real gap keeps the step
159
+ # width consistent with however the generator placed its points.
155
160
  if point_duration <= 0 and len(points) > 1:
156
- point_duration = duration / len(points)
161
+ spacing = points[1]["time"] - points[0]["time"]
162
+ point_duration = spacing if spacing > 0 else duration / len(points)
157
163
 
158
164
  # Apply transforms
159
165
  for p in points:
@@ -227,7 +233,9 @@ def _sine(duration: float, density: int, center: float, amplitude: float,
227
233
  """Periodic oscillation. frequency = cycles per duration."""
228
234
  points = []
229
235
  for i in range(density):
230
- t = (i / max(density - 1, 1)) if density > 1 else 0.0
236
+ # Half-open interval [0, duration): never emit t == duration, which
237
+ # for a periodic curve duplicates the t == 0 sample at the loop seam.
238
+ t = i / density if density > 0 else 0.0
231
239
  angle = 2 * math.pi * frequency * t + phase * 2 * math.pi
232
240
  points.append({
233
241
  "time": t * duration,
@@ -241,7 +249,9 @@ def _sawtooth(duration: float, density: int, start: float, end: float,
241
249
  """Ramp up then reset. frequency = resets per duration."""
242
250
  points = []
243
251
  for i in range(density):
244
- t = (i / max(density - 1, 1)) if density > 1 else 0.0
252
+ # Half-open interval [0, duration): a periodic sawtooth that emits
253
+ # t == duration duplicates the t == 0 sample at the loop seam.
254
+ t = i / density if density > 0 else 0.0
245
255
  # Position within current cycle (0.0 to 1.0)
246
256
  cycle_pos = (t * frequency) % 1.0
247
257
  points.append({
@@ -268,7 +278,9 @@ def _square(duration: float, density: int, low: float, high: float,
268
278
  """Binary on/off toggle."""
269
279
  points = []
270
280
  for i in range(density):
271
- t = (i / max(density - 1, 1)) if density > 1 else 0.0
281
+ # Half-open interval [0, duration): a periodic gate that emits
282
+ # t == duration duplicates the t == 0 sample at the loop seam.
283
+ t = i / density if density > 0 else 0.0
272
284
  cycle_pos = (t * frequency) % 1.0
273
285
  points.append({
274
286
  "time": t * duration,
@@ -483,10 +495,16 @@ def _easing(duration: float, density: int, start: float = 0.0,
483
495
  return 7.5625 * t * t + 0.984375
484
496
 
485
497
  def _elastic(t: float) -> float:
498
+ # easeOutElastic: spring-like overshoot that RINGS DOWN and settles
499
+ # at 1.0. The old easeInElastic form held near 0 for almost the whole
500
+ # span then snapped to 1 at t==1 — after the [0,1] clamp in
501
+ # generate_curve that collapsed to a near-step function (P2-42).
502
+ # easeOutElastic oscillates with decaying amplitude across the span,
503
+ # so the characteristic ring survives the clamp.
486
504
  if t == 0 or t == 1:
487
505
  return t
488
- p = 0.3
489
- return -(2 ** (10 * (t - 1))) * math.sin((t - 1 - p/4) * 2 * math.pi / p)
506
+ c4 = (2 * math.pi) / 3
507
+ return 2 ** (-10 * t) * math.sin((t * 10 - 0.75) * c4) + 1.0
490
508
 
491
509
  def _back(t: float) -> float:
492
510
  s = 1.70158 # overshoot amount
@@ -539,6 +557,11 @@ def _euclidean(duration: float, density: int, start: float = 0.0,
539
557
  5 filter opens across 8 beats. 3 reverb throws across 16 steps.
540
558
  Produces non-obvious but musically satisfying rhythmic modulation.
541
559
  """
560
+ # Guard: steps must be >= 1 — Bjorklund on 0 slots yields an empty
561
+ # pattern, and dividing duration by len([]) raises ZeroDivisionError.
562
+ if steps < 1:
563
+ raise ValueError("euclidean 'steps' must be >= 1")
564
+
542
565
  # Bjorklund algorithm
543
566
  def _bjorklund(hits_n: int, steps_n: int) -> list:
544
567
  if hits_n >= steps_n:
@@ -55,22 +55,25 @@ def _make_line(src_id: str, src_outlet: int,
55
55
 
56
56
 
57
57
  def _ensure_safety_clip(code: str) -> str:
58
- """Append safety clipping to gen~ code to prevent speaker damage."""
59
- safe = code.rstrip()
60
- if "clip(" in safe.lower():
61
- return safe
58
+ """Clamp each gen~ output to [-1, 1] to prevent speaker damage.
62
59
 
63
- # Find output assignments and wrap them
60
+ Each `outN = ...` assignment is clamped UNLESS that specific line already
61
+ clips its own output. The previous version bailed entirely when the
62
+ substring 'clip(' appeared ANYWHERE (a comment, an unrelated helper, or
63
+ clipping only ONE of several outputs), shipping the remaining outputs
64
+ unclamped — a real loud-output hazard on Live's master/instrument chain.
65
+ """
66
+ safe = code.rstrip()
64
67
  lines = safe.split("\n")
65
68
  new_lines = []
66
69
  for line in lines:
67
70
  stripped = line.strip()
68
- if stripped.startswith("out") and "=" in stripped:
71
+ new_lines.append(line)
72
+ is_output = stripped.startswith("out") and "=" in stripped
73
+ already_clipped = "clip(" in stripped.lower()
74
+ if is_output and not already_clipped:
69
75
  var = stripped.split("=")[0].strip()
70
- new_lines.append(line)
71
76
  new_lines.append(f"{var} = clip({var}, -1, 1);")
72
- else:
73
- new_lines.append(line)
74
77
  return "\n".join(new_lines)
75
78
 
76
79
 
@@ -214,12 +217,27 @@ def _build_audio_effect_patcher(spec: DeviceSpec) -> dict:
214
217
  # plugin~ R -> plugout~ R (direct passthrough)
215
218
  lines.append(_make_line(plugin_id, 1, plugout_id, 1))
216
219
 
217
- # live.dial for each parameter
220
+ # live.dial for each parameter, WIRED to the gen~ param of the same name.
221
+ # Previously the dials had no outgoing patchline, so they were decorative —
222
+ # moving a knob changed no DSP. live.dial emits its float on outlet 1
223
+ # (outlettype ["", "float"]); routing it through [prepend <name>] produces
224
+ # the "<name> <value>" message that sets the matching `param <name>` object
225
+ # inside the gen~ sub-patcher (gen~ accepts param-set messages on inlet 0
226
+ # alongside the audio signal).
218
227
  for i, param in enumerate(spec.params):
219
228
  did = nid()
220
229
  x = 10.0 + i * 54.0
221
230
  boxes.append(param.to_live_dial_json(did, [x, 10.0, 44.0, 48.0]))
222
231
 
232
+ prepend_id = nid()
233
+ boxes.append(_make_box(
234
+ prepend_id, "newobj", f"prepend {param.name}", 1, 1, [""],
235
+ [x, 70.0, 90.0, 22.0],
236
+ ))
237
+ # dial float outlet (1) -> prepend -> gen~ inlet 0
238
+ lines.append(_make_line(did, 1, prepend_id, 0))
239
+ lines.append(_make_line(prepend_id, 0, gen_id, 0))
240
+
223
241
  # Title comment
224
242
  tid = nid()
225
243
  boxes.append({
@@ -289,12 +307,23 @@ _PATCHER_BUILDERS = {
289
307
  DeviceType.AUDIO_EFFECT: _build_audio_effect_patcher,
290
308
  DeviceType.MIDI_EFFECT: _build_midi_effect_patcher,
291
309
  DeviceType.INSTRUMENT: _build_instrument_patcher,
310
+ # MIDI generator / transformation are MIDI-domain devices (Live's "Max MIDI
311
+ # Effect" preset family — see _SUBDIR_MAP), so they share the midiin→midiout
312
+ # patcher. Without these entries build_patcher_json raised an uncaught
313
+ # KeyError for two advertised, reachable device types.
314
+ DeviceType.MIDI_GENERATOR: _build_midi_effect_patcher,
315
+ DeviceType.MIDI_TRANSFORMATION: _build_midi_effect_patcher,
292
316
  }
293
317
 
294
318
 
295
319
  def build_patcher_json(spec: DeviceSpec) -> dict:
296
320
  """Build the complete .maxpat JSON patcher dict for a device spec."""
297
- return _PATCHER_BUILDERS[spec.device_type](spec)
321
+ builder = _PATCHER_BUILDERS.get(spec.device_type)
322
+ if builder is None:
323
+ raise ValueError(
324
+ f"INVALID_PARAM: no patcher builder for device_type {spec.device_type!r}"
325
+ )
326
+ return builder(spec)
298
327
 
299
328
 
300
329
  def build_amxd_binary(spec: DeviceSpec) -> bytes:
@@ -61,6 +61,15 @@ class GenExprParam:
61
61
  unit_style: int = UNIT_STYLE_FLOAT
62
62
  exponent: float = 1.0 # 1.0 = linear
63
63
 
64
+ def __post_init__(self):
65
+ # gen~ Param names and the live.dial `prepend <name>` wiring are
66
+ # single-token: a name with internal whitespace ("Filter Cutoff")
67
+ # would emit `prepend Filter Cutoff` -> gen~ reads param "Filter" plus
68
+ # stray tokens and the knob silently drives no DSP. Normalize internal
69
+ # whitespace to underscores so both the dial wiring and the gen~ `param`
70
+ # declaration stay consistent (tier1a-1).
71
+ self.name = re.sub(r"\s+", "_", str(self.name).strip())
72
+
64
73
  def to_genexpr(self) -> str:
65
74
  """Generate the Param declaration for gen~ codebox."""
66
75
  return f"Param {self.name}({self.default});"
@@ -5,6 +5,7 @@
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import asyncio
8
9
  import tempfile
9
10
  from pathlib import Path
10
11
 
@@ -48,11 +49,13 @@ async def generate_m4l_effect(
48
49
  }
49
50
  dt = type_map.get(device_type)
50
51
  if dt is None:
51
- return {"error": f"Invalid device_type: {device_type}. Use: audio_effect, midi_effect, instrument"}
52
+ return {"error": f"INVALID_PARAM: Invalid device_type: {device_type}. Use one of: {', '.join(type_map)}"}
52
53
 
53
54
  parsed_params = []
54
55
  if params:
55
56
  for p in params:
57
+ if not isinstance(p, dict) or "name" not in p:
58
+ return {"error": f"INVALID_PARAM: each param requires a 'name' key, got: {p!r}"}
56
59
  parsed_params.append(GenExprParam(
57
60
  name=p["name"],
58
61
  default=p.get("default", 0.5),
@@ -72,9 +75,16 @@ async def generate_m4l_effect(
72
75
  if install:
73
76
  subdir = _SUBDIR_MAP[dt]
74
77
  output_dir = ABLETON_USER_LIBRARY / subdir
75
- path = build_device(spec, output_dir=output_dir)
76
78
  else:
77
- path = build_device(spec, output_dir=tempfile.mkdtemp(prefix="livepilot_forge_"))
79
+ # Reuse ONE forge directory under the system temp instead of spawning a
80
+ # fresh mkdtemp() per call (those accumulated, never cleaned). The .amxd
81
+ # is the returned deliverable so it must persist for the caller.
82
+ output_dir = Path(tempfile.gettempdir()) / "livepilot_forge"
83
+
84
+ # build_device does blocking filesystem I/O (mkdir + write_bytes). This is
85
+ # an async tool running on the event loop, so offload to a worker thread to
86
+ # avoid freezing all other handlers + the bridge UDP endpoint.
87
+ path = await asyncio.to_thread(build_device, spec, output_dir)
78
88
 
79
89
  return {
80
90
  "status": "created",
@@ -113,15 +123,11 @@ async def list_genexpr_templates(
113
123
  }
114
124
 
115
125
 
116
- @mcp.tool()
117
- async def install_m4l_device(
118
- ctx: Context,
119
- source_path: str,
120
- ) -> dict:
121
- """Copy a .amxd file to Ableton's User Library.
126
+ def _install_m4l_device_sync(source_path: str) -> dict:
127
+ """Blocking filesystem work for install_m4l_device (read/mkdir/write).
122
128
 
123
- Args:
124
- source_path: Path to the .amxd file to install
129
+ Run via asyncio.to_thread from the async tool below so the event loop
130
+ (and the bridge UDP endpoint) never stalls on disk I/O.
125
131
  """
126
132
  src = Path(source_path)
127
133
  if not src.exists():
@@ -160,3 +166,16 @@ async def install_m4l_device(
160
166
  "path": str(dest),
161
167
  "hint": "Device now available in Ableton's browser.",
162
168
  }
169
+
170
+
171
+ @mcp.tool()
172
+ async def install_m4l_device(
173
+ ctx: Context,
174
+ source_path: str,
175
+ ) -> dict:
176
+ """Copy a .amxd file to Ableton's User Library.
177
+
178
+ Args:
179
+ source_path: Path to the .amxd file to install
180
+ """
181
+ return await asyncio.to_thread(_install_m4l_device_sync, source_path)