livepilot 1.10.7 → 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.
- package/CHANGELOG.md +126 -0
- package/README.md +11 -9
- package/bin/livepilot.js +146 -28
- package/installer/install.js +117 -11
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +39 -7
- package/mcp_server/atlas/tools.py +56 -15
- package/mcp_server/composer/layer_planner.py +27 -0
- package/mcp_server/composer/prompt_parser.py +15 -6
- package/mcp_server/connection.py +11 -3
- package/mcp_server/corpus/__init__.py +14 -4
- package/mcp_server/m4l_bridge.py +48 -7
- package/mcp_server/runtime/execution_router.py +16 -2
- package/mcp_server/runtime/remote_commands.py +6 -0
- package/mcp_server/sample_engine/models.py +22 -3
- package/mcp_server/semantic_moves/__init__.py +1 -0
- package/mcp_server/semantic_moves/compiler.py +9 -1
- package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
- package/mcp_server/semantic_moves/mix_compilers.py +170 -0
- package/mcp_server/semantic_moves/mix_moves.py +1 -1
- package/mcp_server/semantic_moves/models.py +5 -0
- package/mcp_server/semantic_moves/tools.py +15 -4
- package/mcp_server/server.py +7 -3
- package/mcp_server/services/singletons.py +68 -0
- package/mcp_server/splice_client/client.py +29 -8
- package/mcp_server/tools/analyzer.py +7 -6
- package/mcp_server/tools/clips.py +1 -1
- package/mcp_server/tools/midi_io.py +10 -0
- package/mcp_server/tools/tracks.py +1 -1
- package/mcp_server/tools/transport.py +1 -1
- package/mcp_server/translation_engine/tools.py +8 -4
- package/package.json +25 -3
- package/remote_script/LivePilot/__init__.py +29 -9
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/remote_script/LivePilot/browser.py +16 -6
- package/remote_script/LivePilot/devices.py +10 -5
- package/remote_script/LivePilot/notes.py +13 -2
- package/remote_script/LivePilot/server.py +51 -13
- package/remote_script/LivePilot/version_detect.py +7 -4
- package/server.json +20 -0
- package/.claude-plugin/marketplace.json +0 -21
- package/.mcp.json.disabled +0 -9
- package/.mcpbignore +0 -60
- package/AGENTS.md +0 -46
- package/BUGS.md +0 -1570
- package/CODE_OF_CONDUCT.md +0 -27
- package/CONTRIBUTING.md +0 -131
- package/SECURITY.md +0 -48
- package/livepilot/.Codex-plugin/plugin.json +0 -8
- package/livepilot/.claude-plugin/plugin.json +0 -8
- package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
- package/livepilot/commands/arrange.md +0 -47
- package/livepilot/commands/beat.md +0 -77
- package/livepilot/commands/evaluate.md +0 -49
- package/livepilot/commands/memory.md +0 -22
- package/livepilot/commands/mix.md +0 -44
- package/livepilot/commands/perform.md +0 -42
- package/livepilot/commands/session.md +0 -13
- package/livepilot/commands/sounddesign.md +0 -43
- package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
- package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
- package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
- package/livepilot/skills/livepilot-core/SKILL.md +0 -184
- package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
- package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
- package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
- package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
- package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
- package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
- package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
- package/livepilot/skills/livepilot-core/references/overview.md +0 -290
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
- package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
- package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
- package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
- package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
- package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
- package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
- package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
- package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
- package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
- package/livepilot/skills/livepilot-release/SKILL.md +0 -130
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
- package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
- package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
- package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
- package/m4l_device/LivePilot_Analyzer.maxproj +0 -53
- package/manifest.json +0 -91
- package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
- package/scripts/generate_tool_catalog.py +0 -106
- package/scripts/sync_metadata.py +0 -349
|
@@ -282,6 +282,173 @@ def _compile_reduce_repetition(move: SemanticMove, kernel: dict) -> CompiledPlan
|
|
|
282
282
|
)
|
|
283
283
|
|
|
284
284
|
|
|
285
|
+
def _compile_make_kick_bass_lock(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
286
|
+
"""Compile 'make_kick_bass_lock': carve space between kick and bass.
|
|
287
|
+
|
|
288
|
+
Strategy: reduce bass level slightly (clears sub for kick), verify both
|
|
289
|
+
tracks remain active. Sidechain compressor insertion is left as a future
|
|
290
|
+
step — it requires device selection + parameter mapping that varies too
|
|
291
|
+
much across projects to hardcode safely.
|
|
292
|
+
"""
|
|
293
|
+
steps: list[CompiledStep] = []
|
|
294
|
+
warnings: list[str] = []
|
|
295
|
+
descriptions: list[str] = []
|
|
296
|
+
|
|
297
|
+
bass_tracks = resolvers.find_tracks_by_role(kernel, ["bass"])
|
|
298
|
+
kick_tracks = resolvers.find_tracks_by_role(kernel, ["drums", "percussion"])
|
|
299
|
+
|
|
300
|
+
if not bass_tracks:
|
|
301
|
+
warnings.append("No bass track found — cannot lock kick and bass")
|
|
302
|
+
if not kick_tracks:
|
|
303
|
+
warnings.append("No kick/drum track found — reference track missing")
|
|
304
|
+
|
|
305
|
+
steps.append(CompiledStep(
|
|
306
|
+
tool="get_master_spectrum",
|
|
307
|
+
params={},
|
|
308
|
+
description="Read current sub/low balance before carving",
|
|
309
|
+
verify_after=False,
|
|
310
|
+
))
|
|
311
|
+
|
|
312
|
+
if bass_tracks:
|
|
313
|
+
bass = bass_tracks[0]
|
|
314
|
+
idx = bass["index"]
|
|
315
|
+
steps.append(CompiledStep(
|
|
316
|
+
tool="set_track_volume",
|
|
317
|
+
params={"track_index": idx, "volume": 0.60},
|
|
318
|
+
description=f"Pull {bass['name']} to 0.60 to clear sub for kick",
|
|
319
|
+
))
|
|
320
|
+
descriptions.append(f"Pull {bass['name']} to 0.60")
|
|
321
|
+
|
|
322
|
+
steps.append(CompiledStep(
|
|
323
|
+
tool="get_track_meters",
|
|
324
|
+
params={"include_stereo": True},
|
|
325
|
+
description="Verify kick and bass both still producing audio",
|
|
326
|
+
))
|
|
327
|
+
|
|
328
|
+
return CompiledPlan(
|
|
329
|
+
move_id=move.move_id,
|
|
330
|
+
intent=move.intent,
|
|
331
|
+
steps=steps,
|
|
332
|
+
before_reads=[{"tool": "get_master_spectrum", "params": {}}],
|
|
333
|
+
after_reads=[
|
|
334
|
+
{"tool": "get_master_spectrum", "params": {}},
|
|
335
|
+
{"tool": "get_track_meters", "params": {"include_stereo": True}},
|
|
336
|
+
],
|
|
337
|
+
risk_level="low",
|
|
338
|
+
summary="; ".join(descriptions) if descriptions else "No kick/bass changes compiled",
|
|
339
|
+
requires_approval=(kernel.get("mode", "improve") != "explore"),
|
|
340
|
+
warnings=warnings,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _compile_create_buildup_tension(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
345
|
+
"""Compile 'create_buildup_tension': pull harmony back, raise perc energy.
|
|
346
|
+
|
|
347
|
+
We apply volume moves as the minimal, reversible tension-builder. Filter
|
|
348
|
+
rises and send ramps belong in an automation recipe — we issue a tension
|
|
349
|
+
gesture template step if the gesture engine is available, otherwise fall
|
|
350
|
+
back to direct volume changes only.
|
|
351
|
+
"""
|
|
352
|
+
steps: list[CompiledStep] = []
|
|
353
|
+
warnings: list[str] = []
|
|
354
|
+
descriptions: list[str] = []
|
|
355
|
+
|
|
356
|
+
perc_tracks = resolvers.find_tracks_by_role(kernel, ["drums", "percussion"])
|
|
357
|
+
harmony_tracks = resolvers.find_tracks_by_role(kernel, ["chords", "pad"])
|
|
358
|
+
|
|
359
|
+
if not perc_tracks and not harmony_tracks:
|
|
360
|
+
warnings.append("No percussion or harmony tracks found — cannot build tension")
|
|
361
|
+
|
|
362
|
+
# Raise perc for energy
|
|
363
|
+
for pt in perc_tracks[:1]:
|
|
364
|
+
steps.append(CompiledStep(
|
|
365
|
+
tool="set_track_volume",
|
|
366
|
+
params={"track_index": pt["index"], "volume": 0.78},
|
|
367
|
+
description=f"Push {pt['name']} to 0.78 for rising energy",
|
|
368
|
+
))
|
|
369
|
+
descriptions.append(f"Push {pt['name']} to 0.78")
|
|
370
|
+
|
|
371
|
+
# Pull harmony slightly to amplify perc contrast
|
|
372
|
+
for ht in harmony_tracks[:1]:
|
|
373
|
+
steps.append(CompiledStep(
|
|
374
|
+
tool="set_track_volume",
|
|
375
|
+
params={"track_index": ht["index"], "volume": 0.35},
|
|
376
|
+
description=f"Pull {ht['name']} to 0.35 to create harmonic vacuum before drop",
|
|
377
|
+
))
|
|
378
|
+
descriptions.append(f"Pull {ht['name']} to 0.35")
|
|
379
|
+
|
|
380
|
+
steps.append(CompiledStep(
|
|
381
|
+
tool="get_track_meters",
|
|
382
|
+
params={"include_stereo": True},
|
|
383
|
+
description="Verify tension steps did not silence any track",
|
|
384
|
+
))
|
|
385
|
+
|
|
386
|
+
return CompiledPlan(
|
|
387
|
+
move_id=move.move_id,
|
|
388
|
+
intent=move.intent,
|
|
389
|
+
steps=steps,
|
|
390
|
+
before_reads=[{"tool": "get_emotional_arc", "params": {}}],
|
|
391
|
+
after_reads=[
|
|
392
|
+
{"tool": "get_emotional_arc", "params": {}},
|
|
393
|
+
{"tool": "get_track_meters", "params": {"include_stereo": True}},
|
|
394
|
+
],
|
|
395
|
+
risk_level="medium",
|
|
396
|
+
summary="; ".join(descriptions) if descriptions else "No tracks to ratchet",
|
|
397
|
+
requires_approval=(kernel.get("mode", "improve") != "explore"),
|
|
398
|
+
warnings=warnings,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _compile_smooth_scene_handoff(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
403
|
+
"""Compile 'smooth_scene_handoff': reduce master volume briefly around the handoff.
|
|
404
|
+
|
|
405
|
+
Without knowing which two scenes are involved, the compiler can only do a
|
|
406
|
+
conservative energy dip using master volume. A future version should take
|
|
407
|
+
scene indices via kernel.intent_context and apply targeted crossfades.
|
|
408
|
+
"""
|
|
409
|
+
steps: list[CompiledStep] = []
|
|
410
|
+
warnings: list[str] = []
|
|
411
|
+
descriptions: list[str] = []
|
|
412
|
+
|
|
413
|
+
# Minimal approach — gentle master dip the agent can reverse easily.
|
|
414
|
+
steps.append(CompiledStep(
|
|
415
|
+
tool="get_master_meters",
|
|
416
|
+
params={},
|
|
417
|
+
description="Record current master level for handoff reference",
|
|
418
|
+
verify_after=False,
|
|
419
|
+
))
|
|
420
|
+
|
|
421
|
+
steps.append(CompiledStep(
|
|
422
|
+
tool="set_master_volume",
|
|
423
|
+
params={"volume": 0.78},
|
|
424
|
+
description="Gentle master dip for transition",
|
|
425
|
+
))
|
|
426
|
+
descriptions.append("Master dip to 0.78")
|
|
427
|
+
|
|
428
|
+
steps.append(CompiledStep(
|
|
429
|
+
tool="get_master_meters",
|
|
430
|
+
params={},
|
|
431
|
+
description="Verify master dip applied without clipping",
|
|
432
|
+
))
|
|
433
|
+
|
|
434
|
+
warnings.append(
|
|
435
|
+
"Scene-aware handoff (from_scene/to_scene) not yet compiled — "
|
|
436
|
+
"this is a conservative energy-dip fallback"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
return CompiledPlan(
|
|
440
|
+
move_id=move.move_id,
|
|
441
|
+
intent=move.intent,
|
|
442
|
+
steps=steps,
|
|
443
|
+
before_reads=[{"tool": "get_emotional_arc", "params": {}}],
|
|
444
|
+
after_reads=[{"tool": "get_emotional_arc", "params": {}}],
|
|
445
|
+
risk_level="low",
|
|
446
|
+
summary="; ".join(descriptions),
|
|
447
|
+
requires_approval=(kernel.get("mode", "improve") != "explore"),
|
|
448
|
+
warnings=warnings,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
285
452
|
# ── Register all compilers ──────────────────────────────────────────────────
|
|
286
453
|
|
|
287
454
|
register_compiler("make_punchier", _compile_make_punchier)
|
|
@@ -289,3 +456,6 @@ register_compiler("tighten_low_end", _compile_tighten_low_end)
|
|
|
289
456
|
register_compiler("widen_stereo", _compile_widen_stereo)
|
|
290
457
|
register_compiler("darken_without_losing_width", _compile_darken_mix)
|
|
291
458
|
register_compiler("reduce_repetition_fatigue", _compile_reduce_repetition)
|
|
459
|
+
register_compiler("make_kick_bass_lock", _compile_make_kick_bass_lock)
|
|
460
|
+
register_compiler("create_buildup_tension", _compile_create_buildup_tension)
|
|
461
|
+
register_compiler("smooth_scene_handoff", _compile_smooth_scene_handoff)
|
|
@@ -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": "
|
|
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:
|
|
@@ -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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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(
|
package/mcp_server/server.py
CHANGED
|
@@ -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).
|
|
@@ -264,9 +271,6 @@ from .device_forge import tools as device_forge_tools # noqa: F401, E40
|
|
|
264
271
|
from .sample_engine import tools as sample_engine_tools # noqa: F401, E402
|
|
265
272
|
from .atlas import tools as atlas_tools # noqa: F401, E402
|
|
266
273
|
from .composer import tools as composer_tools # noqa: F401, E402
|
|
267
|
-
import logging
|
|
268
|
-
|
|
269
|
-
logger = logging.getLogger(__name__)
|
|
270
274
|
|
|
271
275
|
# ---------------------------------------------------------------------------
|
|
272
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
|
|
@@ -27,6 +27,16 @@ _SPLICE_APP_SUPPORT = os.path.expanduser(
|
|
|
27
27
|
# Credit safety floor — never drain below this
|
|
28
28
|
CREDIT_HARD_FLOOR = 5
|
|
29
29
|
|
|
30
|
+
# Per-call gRPC timeouts. The previous implementation passed no timeout, so
|
|
31
|
+
# a hung Splice process could block the MCP event loop until gRPC's default
|
|
32
|
+
# (often infinite) deadline fired. Keep generous enough for cold searches
|
|
33
|
+
# but bounded enough that a dead socket fails the tool call, not the server.
|
|
34
|
+
SEARCH_TIMEOUT = 10.0
|
|
35
|
+
INFO_TIMEOUT = 5.0
|
|
36
|
+
CREDITS_TIMEOUT = 5.0
|
|
37
|
+
SYNC_TIMEOUT = 30.0
|
|
38
|
+
DOWNLOAD_TRIGGER_TIMEOUT = 5.0
|
|
39
|
+
|
|
30
40
|
|
|
31
41
|
def _try_import_grpc():
|
|
32
42
|
"""Import grpcio lazily — graceful degradation if not installed."""
|
|
@@ -148,7 +158,7 @@ class SpliceGRPCClient:
|
|
|
148
158
|
Page=page,
|
|
149
159
|
Purchased=purchased,
|
|
150
160
|
)
|
|
151
|
-
response = await self.stub.SearchSamples(request)
|
|
161
|
+
response = await self.stub.SearchSamples(request, timeout=SEARCH_TIMEOUT)
|
|
152
162
|
return self._parse_search_response(response)
|
|
153
163
|
except Exception as exc:
|
|
154
164
|
logger.warning(f"Splice search failed: {exc}")
|
|
@@ -213,7 +223,8 @@ class SpliceGRPCClient:
|
|
|
213
223
|
try:
|
|
214
224
|
# Trigger download
|
|
215
225
|
await self.stub.DownloadSample(
|
|
216
|
-
pb2.DownloadSampleRequest(FileHash=file_hash)
|
|
226
|
+
pb2.DownloadSampleRequest(FileHash=file_hash),
|
|
227
|
+
timeout=DOWNLOAD_TRIGGER_TIMEOUT,
|
|
217
228
|
)
|
|
218
229
|
# Wait for file to appear on disk
|
|
219
230
|
return await self._wait_for_download(file_hash, timeout)
|
|
@@ -226,11 +237,16 @@ class SpliceGRPCClient:
|
|
|
226
237
|
) -> Optional[str]:
|
|
227
238
|
"""Poll SampleInfo until LocalPath is populated."""
|
|
228
239
|
pb2 = self._pb2
|
|
229
|
-
|
|
230
|
-
|
|
240
|
+
# asyncio.get_event_loop() is deprecated when called inside an
|
|
241
|
+
# already-running coroutine on Python 3.10+. Use get_running_loop()
|
|
242
|
+
# which is the documented replacement.
|
|
243
|
+
loop = asyncio.get_running_loop()
|
|
244
|
+
deadline = loop.time() + timeout
|
|
245
|
+
while loop.time() < deadline:
|
|
231
246
|
try:
|
|
232
247
|
response = await self.stub.SampleInfo(
|
|
233
|
-
pb2.SampleInfoRequest(FileHash=file_hash)
|
|
248
|
+
pb2.SampleInfoRequest(FileHash=file_hash),
|
|
249
|
+
timeout=INFO_TIMEOUT,
|
|
234
250
|
)
|
|
235
251
|
if response.Sample.LocalPath:
|
|
236
252
|
return response.Sample.LocalPath
|
|
@@ -250,7 +266,8 @@ class SpliceGRPCClient:
|
|
|
250
266
|
pb2 = self._pb2
|
|
251
267
|
try:
|
|
252
268
|
response = await self.stub.SampleInfo(
|
|
253
|
-
pb2.SampleInfoRequest(FileHash=file_hash)
|
|
269
|
+
pb2.SampleInfoRequest(FileHash=file_hash),
|
|
270
|
+
timeout=INFO_TIMEOUT,
|
|
254
271
|
)
|
|
255
272
|
s = response.Sample
|
|
256
273
|
return SpliceSample(
|
|
@@ -282,7 +299,8 @@ class SpliceGRPCClient:
|
|
|
282
299
|
pb2 = self._pb2
|
|
283
300
|
try:
|
|
284
301
|
response = await self.stub.ValidateLogin(
|
|
285
|
-
pb2.ValidateLoginRequest()
|
|
302
|
+
pb2.ValidateLoginRequest(),
|
|
303
|
+
timeout=CREDITS_TIMEOUT,
|
|
286
304
|
)
|
|
287
305
|
return SpliceCredits(
|
|
288
306
|
credits=response.User.Credits,
|
|
@@ -315,7 +333,10 @@ class SpliceGRPCClient:
|
|
|
315
333
|
return False
|
|
316
334
|
pb2 = self._pb2
|
|
317
335
|
try:
|
|
318
|
-
await self.stub.SyncSounds(
|
|
336
|
+
await self.stub.SyncSounds(
|
|
337
|
+
pb2.SyncSoundsRequest(),
|
|
338
|
+
timeout=SYNC_TIMEOUT,
|
|
339
|
+
)
|
|
319
340
|
return True
|
|
320
341
|
except Exception as exc:
|
|
321
342
|
logger.debug("sync_sounds failed: %s", exc)
|
|
@@ -6,13 +6,20 @@ These tools are optional — all core tools work without the device.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import logging
|
|
9
10
|
import os
|
|
11
|
+
import re # used below in filename parsing helpers
|
|
10
12
|
from typing import Optional
|
|
11
13
|
|
|
12
14
|
from fastmcp import Context
|
|
13
15
|
|
|
14
16
|
from ..server import mcp, _identify_port_holder
|
|
15
17
|
|
|
18
|
+
# Logger must be defined before any helper that uses it — _require_analyzer
|
|
19
|
+
# below calls logger.debug on an exception path, so defining the logger later
|
|
20
|
+
# in the file risked NameError under unusual import orderings.
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
16
23
|
CAPTURE_DIR = os.path.expanduser("~/Documents/LivePilot/captures")
|
|
17
24
|
|
|
18
25
|
|
|
@@ -277,12 +284,6 @@ async def get_clip_file_path(
|
|
|
277
284
|
bridge = _get_m4l(ctx)
|
|
278
285
|
return await bridge.send_command("get_clip_file_path", track_index, clip_index)
|
|
279
286
|
|
|
280
|
-
import os # for filename parsing in smart-defaults helper
|
|
281
|
-
import re
|
|
282
|
-
import logging
|
|
283
|
-
|
|
284
|
-
logger = logging.getLogger(__name__)
|
|
285
|
-
|
|
286
287
|
# ── Sample loading helpers (P0-1, P1-1, P2-6 fixes) ────────────────────────
|
|
287
288
|
#
|
|
288
289
|
# Critical bug 2026-04-14 (see docs/2026-04-14-bugs-discovered.md):
|
|
@@ -114,7 +114,7 @@ def stop_clip(ctx: Context, track_index: int, clip_index: int) -> dict:
|
|
|
114
114
|
|
|
115
115
|
@mcp.tool()
|
|
116
116
|
def set_clip_name(ctx: Context, track_index: int, clip_index: int, name: str) -> dict:
|
|
117
|
-
"""Rename a clip."""
|
|
117
|
+
"""Rename a clip in the Session view. The new name appears on the clip slot and in Device Chain displays."""
|
|
118
118
|
_validate_track_index(track_index)
|
|
119
119
|
_validate_clip_index(clip_index)
|
|
120
120
|
if not name.strip():
|
|
@@ -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
|
|
|
@@ -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.
|
|
3
|
+
"version": "1.10.8",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
|
-
"description": "Agentic production system for Ableton Live 12 —
|
|
5
|
+
"description": "Agentic production system for Ableton Live 12 — 324 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,7 +5,7 @@ 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.
|
|
8
|
+
__version__ = "1.10.8"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from . import router
|
|
@@ -46,26 +46,46 @@ _HANDLER_MODULES = (
|
|
|
46
46
|
)
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def _force_reload_handlers():
|
|
49
|
+
def _force_reload_handlers(cs=None):
|
|
50
50
|
"""Force Python to re-read the handler modules from disk.
|
|
51
51
|
|
|
52
52
|
Called on every create_instance() except the first, so edits to
|
|
53
53
|
handler files take effect via Control Surface toggle without
|
|
54
54
|
restarting Ableton. Order matters: router first (clears _handlers),
|
|
55
55
|
then each handler module (re-registers its @register decorators).
|
|
56
|
+
|
|
57
|
+
When ``cs`` is provided, reload exceptions are logged through the
|
|
58
|
+
ControlSurface so a SyntaxError / NameError in an edited handler is
|
|
59
|
+
surfaced in Live's status log instead of silently swallowed. The
|
|
60
|
+
previous ``except Exception: pass`` turned any bad handler into a
|
|
61
|
+
silent NOT_FOUND at dispatch time with no hint that reload had failed.
|
|
56
62
|
"""
|
|
57
63
|
import importlib
|
|
64
|
+
def _log(msg):
|
|
65
|
+
if cs is None:
|
|
66
|
+
return
|
|
67
|
+
try:
|
|
68
|
+
cs.log_message("[LivePilot] " + msg)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
58
72
|
try:
|
|
59
73
|
importlib.reload(router)
|
|
60
|
-
except Exception:
|
|
61
|
-
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
_log("reload(router) FAILED — %s: %s. Handlers will be "
|
|
76
|
+
"stale until Ableton restart." % (type(exc).__name__, exc))
|
|
62
77
|
for mod in _HANDLER_MODULES:
|
|
63
78
|
try:
|
|
64
79
|
importlib.reload(mod)
|
|
65
|
-
except Exception:
|
|
66
|
-
# Don't block Ableton startup on a single bad reload
|
|
67
|
-
# the
|
|
68
|
-
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
# Don't block Ableton startup on a single bad reload, but do
|
|
82
|
+
# tell the user what happened — the stale handler will keep
|
|
83
|
+
# serving the OLD code until a full restart.
|
|
84
|
+
_log("reload(%s) FAILED — %s: %s. Handler is stale." % (
|
|
85
|
+
getattr(mod, "__name__", "?"),
|
|
86
|
+
type(exc).__name__,
|
|
87
|
+
exc,
|
|
88
|
+
))
|
|
69
89
|
|
|
70
90
|
|
|
71
91
|
def create_instance(c_instance):
|
|
@@ -78,7 +98,7 @@ def create_instance(c_instance):
|
|
|
78
98
|
"""
|
|
79
99
|
global _FIRST_CREATE_INSTANCE
|
|
80
100
|
if not _FIRST_CREATE_INSTANCE:
|
|
81
|
-
_force_reload_handlers()
|
|
101
|
+
_force_reload_handlers(cs=c_instance)
|
|
82
102
|
_FIRST_CREATE_INSTANCE = False
|
|
83
103
|
return LivePilot(c_instance)
|
|
84
104
|
|
|
@@ -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"])
|