livepilot 1.10.4 → 1.10.6
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/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +148 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +6 -6
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +4 -4
- package/livepilot/skills/livepilot-core/references/overview.md +3 -3
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +5 -5
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +12 -1
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/composer/sample_resolver.py +10 -6
- package/mcp_server/composer/tools.py +10 -6
- package/mcp_server/connection.py +6 -1
- package/mcp_server/creative_constraints/tools.py +9 -8
- package/mcp_server/experiment/engine.py +9 -5
- package/mcp_server/experiment/tools.py +9 -9
- package/mcp_server/hook_hunter/tools.py +14 -9
- package/mcp_server/m4l_bridge.py +11 -0
- package/mcp_server/memory/taste_graph.py +7 -2
- package/mcp_server/mix_engine/tools.py +8 -3
- package/mcp_server/musical_intelligence/tools.py +15 -10
- package/mcp_server/performance_engine/tools.py +6 -2
- package/mcp_server/preview_studio/tools.py +21 -15
- package/mcp_server/project_brain/tools.py +18 -10
- package/mcp_server/reference_engine/tools.py +7 -5
- package/mcp_server/runtime/capability_probe.py +10 -4
- package/mcp_server/runtime/tools.py +8 -2
- package/mcp_server/sample_engine/tools.py +394 -33
- package/mcp_server/semantic_moves/tools.py +5 -1
- package/mcp_server/server.py +10 -9
- package/mcp_server/services/motif_service.py +9 -3
- package/mcp_server/session_continuity/tools.py +7 -3
- package/mcp_server/session_continuity/tracker.py +9 -8
- package/mcp_server/song_brain/tools.py +17 -12
- package/mcp_server/splice_client/client.py +19 -6
- package/mcp_server/stuckness_detector/tools.py +8 -5
- package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +134 -0
- package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
- package/mcp_server/tools/_agent_os_engine/models.py +132 -0
- package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
- package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
- package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
- package/mcp_server/tools/_composition_engine/__init__.py +67 -0
- package/mcp_server/tools/_composition_engine/analysis.py +174 -0
- package/mcp_server/tools/_composition_engine/critics.py +522 -0
- package/mcp_server/tools/_composition_engine/gestures.py +230 -0
- package/mcp_server/tools/_composition_engine/harmony.py +70 -0
- package/mcp_server/tools/_composition_engine/models.py +193 -0
- package/mcp_server/tools/_composition_engine/sections.py +371 -0
- package/mcp_server/tools/_perception_engine.py +18 -11
- package/mcp_server/tools/agent_os.py +23 -15
- package/mcp_server/tools/analyzer.py +166 -7
- package/mcp_server/tools/automation.py +6 -1
- package/mcp_server/tools/composition.py +25 -16
- package/mcp_server/tools/devices.py +10 -6
- package/mcp_server/tools/motif.py +7 -2
- package/mcp_server/tools/planner.py +6 -2
- package/mcp_server/tools/research.py +13 -10
- package/mcp_server/transition_engine/tools.py +6 -1
- package/mcp_server/translation_engine/tools.py +8 -6
- package/mcp_server/wonder_mode/engine.py +8 -3
- package/mcp_server/wonder_mode/tools.py +29 -21
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/requirements.txt +6 -0
- package/livepilot.mcpb +0 -0
- package/mcp_server/tools/_agent_os_engine.py +0 -947
- package/mcp_server/tools/_composition_engine.py +0 -1530
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
"""Sample Engine MCP tools —
|
|
1
|
+
"""Sample Engine MCP tools — intelligence-layer tools.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Wraps analyzer, critics, planner, technique library, and (as of v1.10.5)
|
|
4
|
+
direct Splice online catalog hunt/download via the gRPC client.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
9
11
|
from typing import Optional
|
|
10
12
|
|
|
11
13
|
from fastmcp import Context
|
|
12
14
|
|
|
13
15
|
from ..server import mcp
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
14
18
|
from .models import SampleProfile, SampleIntent, SampleFitReport
|
|
15
19
|
from .analyzer import build_profile_from_filename
|
|
16
20
|
from .critics import run_all_sample_critics
|
|
@@ -46,8 +50,8 @@ async def analyze_sample(
|
|
|
46
50
|
)
|
|
47
51
|
if not result.get("error"):
|
|
48
52
|
file_path = result.get("file_path")
|
|
49
|
-
except Exception:
|
|
50
|
-
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
logger.warning("m4l get_clip_file_path failed: %s", exc)
|
|
51
55
|
|
|
52
56
|
if file_path is None:
|
|
53
57
|
return {"error": "Could not determine file path — provide file_path directly"}
|
|
@@ -96,7 +100,8 @@ def evaluate_sample_fit(
|
|
|
96
100
|
name = track_info.get("name", "").lower()
|
|
97
101
|
if name:
|
|
98
102
|
existing_roles.append(name)
|
|
99
|
-
except Exception:
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
logger.debug("get_track_info(%d) skipped: %s", i, exc)
|
|
100
105
|
continue
|
|
101
106
|
|
|
102
107
|
# Detect key from MIDI tracks
|
|
@@ -118,12 +123,13 @@ def evaluate_sample_fit(
|
|
|
118
123
|
mode_suffix = "m" if "minor" in mode else ""
|
|
119
124
|
song_key = f"{key_result['tonic_name']}{mode_suffix}"
|
|
120
125
|
break
|
|
121
|
-
except Exception:
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
logger.debug("key detection on track %d skipped: %s", i, exc)
|
|
122
128
|
continue
|
|
123
129
|
except ImportError:
|
|
124
130
|
pass
|
|
125
|
-
except Exception:
|
|
126
|
-
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
logger.warning("session context for evaluate_sample_fit failed: %s", exc)
|
|
127
133
|
|
|
128
134
|
critics = run_all_sample_critics(
|
|
129
135
|
profile=profile,
|
|
@@ -155,7 +161,7 @@ def evaluate_sample_fit(
|
|
|
155
161
|
|
|
156
162
|
|
|
157
163
|
@mcp.tool()
|
|
158
|
-
def search_samples(
|
|
164
|
+
async def search_samples(
|
|
159
165
|
ctx: Context,
|
|
160
166
|
query: str,
|
|
161
167
|
material_type: Optional[str] = None,
|
|
@@ -169,6 +175,12 @@ def search_samples(
|
|
|
169
175
|
Searches all enabled sources in parallel and ranks results.
|
|
170
176
|
Splice results include rich metadata (key, BPM, genre, tags, pack info).
|
|
171
177
|
|
|
178
|
+
When the Splice desktop app is running AND grpcio is installed, this
|
|
179
|
+
searches Splice's ONLINE catalog (19,690+ hits for a generic query)
|
|
180
|
+
and returns un-downloaded items alongside local files. When gRPC is
|
|
181
|
+
unavailable, it falls back to the local SQLite index and only returns
|
|
182
|
+
already-downloaded samples.
|
|
183
|
+
|
|
172
184
|
query: search text like "dark vocal", "breakbeat", "foley metal"
|
|
173
185
|
material_type: filter by type (vocal, drum_loop, texture, etc.)
|
|
174
186
|
key: prefer samples in this key (e.g., "Cm", "F#")
|
|
@@ -188,21 +200,74 @@ def search_samples(
|
|
|
188
200
|
except ValueError:
|
|
189
201
|
pass
|
|
190
202
|
|
|
191
|
-
# Splice search
|
|
203
|
+
# Splice search — prefer gRPC online catalog when available, fall back
|
|
204
|
+
# to local SQLite index. See docs/2026-04-14-bugs-discovered.md — P0-2.
|
|
192
205
|
if source in (None, "splice"):
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
+
grpc_client = None
|
|
207
|
+
try:
|
|
208
|
+
grpc_client = ctx.lifespan_context.get("splice_client")
|
|
209
|
+
except AttributeError:
|
|
210
|
+
grpc_client = None
|
|
211
|
+
|
|
212
|
+
used_grpc = False
|
|
213
|
+
if grpc_client is not None and getattr(grpc_client, "connected", False):
|
|
214
|
+
try:
|
|
215
|
+
grpc_result = await grpc_client.search_samples(
|
|
216
|
+
query=query,
|
|
217
|
+
key=(key or "").lower().rstrip("m") if key else "",
|
|
218
|
+
bpm_min=int(bpm_min) if bpm_min else 0,
|
|
219
|
+
bpm_max=int(bpm_max) if bpm_max else 0,
|
|
220
|
+
per_page=max_results,
|
|
221
|
+
page=1,
|
|
222
|
+
purchased_only=False,
|
|
223
|
+
)
|
|
224
|
+
for s in grpc_result.samples[:max_results]:
|
|
225
|
+
results.append({
|
|
226
|
+
"source": "splice",
|
|
227
|
+
"name": s.filename,
|
|
228
|
+
"file_path": s.local_path or None,
|
|
229
|
+
"uri": None,
|
|
230
|
+
"freesound_id": None,
|
|
231
|
+
"relevance_score": 0,
|
|
232
|
+
"source_priority": 1,
|
|
233
|
+
"splice_catalog": True,
|
|
234
|
+
"downloaded": bool(s.local_path),
|
|
235
|
+
"file_hash": s.file_hash,
|
|
236
|
+
"metadata": {
|
|
237
|
+
"key": s.audio_key,
|
|
238
|
+
"bpm": s.bpm,
|
|
239
|
+
"tags": ",".join(s.tags) if s.tags else "",
|
|
240
|
+
"genre": s.genre or None,
|
|
241
|
+
"sample_type": s.sample_type,
|
|
242
|
+
"material_type": "vocal" if "vocal" in (s.tags or []) else "unknown",
|
|
243
|
+
"pack": s.provider_name,
|
|
244
|
+
"pack_uuid": s.pack_uuid,
|
|
245
|
+
"duration": s.duration_ms / 1000.0 if s.duration_ms else 0.0,
|
|
246
|
+
"is_premium": s.is_premium,
|
|
247
|
+
"chord_type": s.chord_type,
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
used_grpc = True
|
|
251
|
+
except Exception as exc:
|
|
252
|
+
logger.warning("Splice gRPC search failed, falling back to SQL: %s", exc)
|
|
253
|
+
used_grpc = False
|
|
254
|
+
|
|
255
|
+
# Also query local index (if not already covered by gRPC) to surface
|
|
256
|
+
# downloaded-only samples that might not appear in catalog results.
|
|
257
|
+
if not used_grpc:
|
|
258
|
+
splice = SpliceSource()
|
|
259
|
+
if splice.enabled:
|
|
260
|
+
splice_results = splice.search(
|
|
261
|
+
query=query,
|
|
262
|
+
max_results=max_results,
|
|
263
|
+
key=key,
|
|
264
|
+
bpm_min=bpm_min,
|
|
265
|
+
bpm_max=bpm_max,
|
|
266
|
+
)
|
|
267
|
+
for candidate in splice_results:
|
|
268
|
+
d = candidate.to_dict()
|
|
269
|
+
d["source_priority"] = 1
|
|
270
|
+
results.append(d)
|
|
206
271
|
|
|
207
272
|
# Browser search
|
|
208
273
|
if source in (None, "browser"):
|
|
@@ -223,10 +288,11 @@ def search_samples(
|
|
|
223
288
|
d = candidate.to_dict()
|
|
224
289
|
d["source_priority"] = 2
|
|
225
290
|
results.append(d)
|
|
226
|
-
except Exception:
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
logger.debug("browser search %s skipped: %s", category, exc)
|
|
227
293
|
continue
|
|
228
|
-
except Exception:
|
|
229
|
-
|
|
294
|
+
except Exception as exc:
|
|
295
|
+
logger.warning("browser search unavailable: %s", exc)
|
|
230
296
|
|
|
231
297
|
# Filesystem search
|
|
232
298
|
if source in (None, "filesystem"):
|
|
@@ -383,7 +449,8 @@ def get_sample_opportunities(ctx: Context) -> dict:
|
|
|
383
449
|
try:
|
|
384
450
|
ableton = ctx.lifespan_context["ableton"]
|
|
385
451
|
info = ableton.send_command("get_session_info", {})
|
|
386
|
-
except Exception:
|
|
452
|
+
except Exception as exc:
|
|
453
|
+
logger.warning("get_sample_opportunities: Ableton not reachable: %s", exc)
|
|
387
454
|
return {"opportunities": [], "note": "Cannot read session — Ableton not connected"}
|
|
388
455
|
|
|
389
456
|
track_count = info.get("track_count", 0)
|
|
@@ -399,7 +466,8 @@ def get_sample_opportunities(ctx: Context) -> dict:
|
|
|
399
466
|
for d in devices:
|
|
400
467
|
if d.get("class_name") in ("OriginalSimpler", "MultiSampler"):
|
|
401
468
|
has_sampler = True
|
|
402
|
-
except Exception:
|
|
469
|
+
except Exception as exc:
|
|
470
|
+
logger.debug("track scan idx=%d skipped: %s", i, exc)
|
|
403
471
|
continue
|
|
404
472
|
|
|
405
473
|
# No organic texture
|
|
@@ -483,8 +551,8 @@ def plan_slice_workflow(
|
|
|
483
551
|
if ableton:
|
|
484
552
|
info = ableton.send_command("get_session_info", {})
|
|
485
553
|
tempo = float(info.get("tempo", 120.0))
|
|
486
|
-
except Exception:
|
|
487
|
-
|
|
554
|
+
except Exception as exc:
|
|
555
|
+
logger.debug("plan_slice_workflow tempo fetch failed (using 120): %s", exc)
|
|
488
556
|
|
|
489
557
|
# Read slice count from existing Simpler if track provided
|
|
490
558
|
slice_count = 8 # Default transient slice count
|
|
@@ -497,8 +565,8 @@ def plan_slice_workflow(
|
|
|
497
565
|
})
|
|
498
566
|
if isinstance(slices, dict) and slices.get("slice_count"):
|
|
499
567
|
slice_count = slices["slice_count"]
|
|
500
|
-
except Exception:
|
|
501
|
-
|
|
568
|
+
except Exception as exc:
|
|
569
|
+
logger.debug("get_simpler_slices failed (using default 8): %s", exc)
|
|
502
570
|
|
|
503
571
|
# Build the plan
|
|
504
572
|
plan = plan_slice_steps(
|
|
@@ -543,3 +611,296 @@ def plan_slice_workflow(
|
|
|
543
611
|
plan["style_hint"] = style_hint
|
|
544
612
|
|
|
545
613
|
return plan
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# ── v1.10.5 Splice online catalog tools ───────────────────────────────────
|
|
617
|
+
#
|
|
618
|
+
# These expose the SpliceGRPCClient's catalog capabilities as first-class MCP
|
|
619
|
+
# tools so the agent can drive hunt→download→load without a standalone helper
|
|
620
|
+
# script. See docs/2026-04-14-bugs-discovered.md — P0-2.
|
|
621
|
+
#
|
|
622
|
+
# Prerequisites:
|
|
623
|
+
# - Splice desktop app running (port.conf present in ~/Library/Application
|
|
624
|
+
# Support/com.splice.Splice/)
|
|
625
|
+
# - grpcio and protobuf installed (added to requirements.txt in v1.10.5)
|
|
626
|
+
#
|
|
627
|
+
# Credit model (as of 2026-04-14):
|
|
628
|
+
# - Even with `SoundsStatus: subscribed`, the gRPC `DownloadSample` endpoint
|
|
629
|
+
# always decrements a monthly credit counter (default 100/month on most
|
|
630
|
+
# subscription plans).
|
|
631
|
+
# - The "unlimited downloads in Ableton" the Splice marketing references
|
|
632
|
+
# only applies to the Splice Sounds.vst3 plugin, which uses a different
|
|
633
|
+
# HTTPS API that these tools cannot drive.
|
|
634
|
+
# - `CREDIT_HARD_FLOOR = 5` in client.py reserves 5 credits as a safety
|
|
635
|
+
# margin — downloads will refuse below the floor.
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
_SPLICE_USER_LIB_DEST = "~/Music/Ableton/User Library/Samples/Splice"
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
@mcp.tool()
|
|
642
|
+
async def get_splice_credits(ctx: Context) -> dict:
|
|
643
|
+
"""Get the user's current Splice credit balance and subscription tier.
|
|
644
|
+
|
|
645
|
+
Returns: {
|
|
646
|
+
"connected": bool, # whether Splice desktop gRPC is reachable
|
|
647
|
+
"username": str,
|
|
648
|
+
"plan": str, # e.g. "subscribed", "free"
|
|
649
|
+
"credits_remaining": int,
|
|
650
|
+
"credit_floor": int, # safety reserve (typically 5)
|
|
651
|
+
"can_download": bool, # credits_remaining > credit_floor
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
Returns connected=False (with zero credits) when the Splice desktop app
|
|
655
|
+
isn't running or grpcio isn't installed.
|
|
656
|
+
"""
|
|
657
|
+
from ..splice_client.client import CREDIT_HARD_FLOOR
|
|
658
|
+
|
|
659
|
+
client = None
|
|
660
|
+
try:
|
|
661
|
+
client = ctx.lifespan_context.get("splice_client")
|
|
662
|
+
except AttributeError:
|
|
663
|
+
pass
|
|
664
|
+
|
|
665
|
+
if client is None or not getattr(client, "connected", False):
|
|
666
|
+
return {
|
|
667
|
+
"connected": False,
|
|
668
|
+
"username": "",
|
|
669
|
+
"plan": "",
|
|
670
|
+
"credits_remaining": 0,
|
|
671
|
+
"credit_floor": CREDIT_HARD_FLOOR,
|
|
672
|
+
"can_download": False,
|
|
673
|
+
"hint": (
|
|
674
|
+
"Splice gRPC not connected. Ensure Splice desktop app is "
|
|
675
|
+
"running and grpcio+protobuf are installed in the LivePilot "
|
|
676
|
+
"venv (pip install grpcio protobuf)."
|
|
677
|
+
),
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
try:
|
|
681
|
+
info = await client.get_credits()
|
|
682
|
+
except Exception as exc:
|
|
683
|
+
return {
|
|
684
|
+
"connected": False,
|
|
685
|
+
"error": f"get_credits failed: {exc}",
|
|
686
|
+
"credit_floor": CREDIT_HARD_FLOOR,
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
remaining = int(info.credits)
|
|
690
|
+
return {
|
|
691
|
+
"connected": True,
|
|
692
|
+
"username": info.username,
|
|
693
|
+
"plan": info.plan,
|
|
694
|
+
"credits_remaining": remaining,
|
|
695
|
+
"credit_floor": CREDIT_HARD_FLOOR,
|
|
696
|
+
"can_download": remaining > CREDIT_HARD_FLOOR,
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@mcp.tool()
|
|
701
|
+
async def splice_catalog_hunt(
|
|
702
|
+
ctx: Context,
|
|
703
|
+
query: str,
|
|
704
|
+
bpm_min: int = 0,
|
|
705
|
+
bpm_max: int = 0,
|
|
706
|
+
key: str = "",
|
|
707
|
+
sample_type: str = "",
|
|
708
|
+
genre: str = "",
|
|
709
|
+
per_page: int = 10,
|
|
710
|
+
page: int = 1,
|
|
711
|
+
) -> dict:
|
|
712
|
+
"""Search Splice's ONLINE catalog via gRPC.
|
|
713
|
+
|
|
714
|
+
Unlike `search_samples` which can fall back to the local SQLite index,
|
|
715
|
+
this tool ONLY queries the online catalog — if Splice isn't connected
|
|
716
|
+
it returns an error instead of local-only results. Use this when you
|
|
717
|
+
specifically want fresh catalog content.
|
|
718
|
+
|
|
719
|
+
query: free-text search ("mellotron", "lofi chord", "soul vocal")
|
|
720
|
+
bpm_min: minimum BPM (0 = no lower bound)
|
|
721
|
+
bpm_max: maximum BPM (0 = no upper bound)
|
|
722
|
+
key: musical key (e.g. "cm", "f#", "a")
|
|
723
|
+
sample_type: "loop", "oneshot", or "" for any
|
|
724
|
+
genre: genre filter (e.g. "hip hop", "ambient")
|
|
725
|
+
per_page: results per page (1-50)
|
|
726
|
+
page: page number (1-indexed)
|
|
727
|
+
|
|
728
|
+
Returns: {
|
|
729
|
+
"connected": bool,
|
|
730
|
+
"total_hits": int, # total catalog matches
|
|
731
|
+
"samples": [...], # sample metadata with file_hash for download
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
Each sample entry contains `file_hash` which you can pass to
|
|
735
|
+
`splice_download_sample` to trigger a download.
|
|
736
|
+
"""
|
|
737
|
+
client = None
|
|
738
|
+
try:
|
|
739
|
+
client = ctx.lifespan_context.get("splice_client")
|
|
740
|
+
except AttributeError:
|
|
741
|
+
pass
|
|
742
|
+
|
|
743
|
+
if client is None or not getattr(client, "connected", False):
|
|
744
|
+
return {
|
|
745
|
+
"connected": False,
|
|
746
|
+
"error": "Splice gRPC not connected",
|
|
747
|
+
"hint": (
|
|
748
|
+
"Ensure Splice desktop app is running. Also verify grpcio "
|
|
749
|
+
"and protobuf are installed: `pip install grpcio protobuf`."
|
|
750
|
+
),
|
|
751
|
+
"samples": [],
|
|
752
|
+
"total_hits": 0,
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
try:
|
|
756
|
+
result = await client.search_samples(
|
|
757
|
+
query=query,
|
|
758
|
+
key=key.lower().rstrip("m") if key else "",
|
|
759
|
+
chord_type="minor" if key and key.lower().endswith("m") else "",
|
|
760
|
+
bpm_min=int(bpm_min),
|
|
761
|
+
bpm_max=int(bpm_max),
|
|
762
|
+
sample_type=sample_type,
|
|
763
|
+
genre=genre,
|
|
764
|
+
per_page=max(1, min(per_page, 50)),
|
|
765
|
+
page=max(1, int(page)),
|
|
766
|
+
purchased_only=False,
|
|
767
|
+
)
|
|
768
|
+
except Exception as exc:
|
|
769
|
+
return {
|
|
770
|
+
"connected": False,
|
|
771
|
+
"error": f"Splice search failed: {exc}",
|
|
772
|
+
"samples": [],
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
samples_out = []
|
|
776
|
+
for s in result.samples:
|
|
777
|
+
samples_out.append({
|
|
778
|
+
"file_hash": s.file_hash,
|
|
779
|
+
"filename": s.filename,
|
|
780
|
+
"key": s.audio_key,
|
|
781
|
+
"chord_type": s.chord_type,
|
|
782
|
+
"bpm": s.bpm,
|
|
783
|
+
"duration_sec": round((s.duration_ms or 0) / 1000.0, 2),
|
|
784
|
+
"genre": s.genre,
|
|
785
|
+
"sample_type": s.sample_type,
|
|
786
|
+
"tags": list(s.tags) if s.tags else [],
|
|
787
|
+
"pack": s.provider_name,
|
|
788
|
+
"pack_uuid": s.pack_uuid,
|
|
789
|
+
"is_premium": bool(s.is_premium),
|
|
790
|
+
"is_downloaded": bool(s.local_path),
|
|
791
|
+
"local_path": s.local_path or None,
|
|
792
|
+
"preview_url": s.preview_url,
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
"connected": True,
|
|
797
|
+
"query": query,
|
|
798
|
+
"total_hits": result.total_hits,
|
|
799
|
+
"returned": len(samples_out),
|
|
800
|
+
"samples": samples_out,
|
|
801
|
+
"matching_tags": dict(result.matching_tags) if result.matching_tags else {},
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
@mcp.tool()
|
|
806
|
+
async def splice_download_sample(
|
|
807
|
+
ctx: Context,
|
|
808
|
+
file_hash: str,
|
|
809
|
+
copy_to_user_library: bool = True,
|
|
810
|
+
) -> dict:
|
|
811
|
+
"""Download a Splice sample by file_hash (costs 1 credit).
|
|
812
|
+
|
|
813
|
+
Use `splice_catalog_hunt` first to find samples and get their file_hash.
|
|
814
|
+
This tool will:
|
|
815
|
+
1. Check credit balance against the safety floor (refuses if < 5)
|
|
816
|
+
2. Trigger the download via the Splice desktop gRPC
|
|
817
|
+
3. Poll until the file appears on disk (up to 30s)
|
|
818
|
+
4. Optionally copy the file into `~/Music/Ableton/User Library/Samples/
|
|
819
|
+
Splice/` so Ableton's browser indexes it — this makes the sample
|
|
820
|
+
loadable via `load_browser_item` with a `query:UserLibrary#Samples:...`
|
|
821
|
+
URI.
|
|
822
|
+
|
|
823
|
+
Returns: {
|
|
824
|
+
"ok": bool,
|
|
825
|
+
"local_path": str, # Splice's own download path
|
|
826
|
+
"user_library_path": str, # if copy_to_user_library=True
|
|
827
|
+
"browser_uri": str, # ready for load_browser_item
|
|
828
|
+
"credits_remaining": int,
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
Note: even with an "unlimited" subscription, this gRPC path always
|
|
832
|
+
decrements credits (typically 100/month allotment). The unlimited
|
|
833
|
+
downloads inside Ableton's Splice Sounds VST3 use a different API
|
|
834
|
+
that LivePilot cannot drive programmatically yet.
|
|
835
|
+
"""
|
|
836
|
+
import shutil
|
|
837
|
+
|
|
838
|
+
client = None
|
|
839
|
+
try:
|
|
840
|
+
client = ctx.lifespan_context.get("splice_client")
|
|
841
|
+
except AttributeError:
|
|
842
|
+
pass
|
|
843
|
+
|
|
844
|
+
if client is None or not getattr(client, "connected", False):
|
|
845
|
+
return {
|
|
846
|
+
"ok": False,
|
|
847
|
+
"error": "Splice gRPC not connected",
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
# Credit safety check
|
|
851
|
+
try:
|
|
852
|
+
can, remaining = await client.can_afford(1, budget=10)
|
|
853
|
+
except Exception as exc:
|
|
854
|
+
return {"ok": False, "error": f"Credit check failed: {exc}"}
|
|
855
|
+
if not can:
|
|
856
|
+
return {
|
|
857
|
+
"ok": False,
|
|
858
|
+
"error": (
|
|
859
|
+
f"Credit safety floor hit (remaining={remaining}, "
|
|
860
|
+
f"hard floor=5). Skipping download."
|
|
861
|
+
),
|
|
862
|
+
"credits_remaining": remaining,
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
# Trigger download
|
|
866
|
+
try:
|
|
867
|
+
local_path = await client.download_sample(file_hash, timeout=30.0)
|
|
868
|
+
except Exception as exc:
|
|
869
|
+
return {"ok": False, "error": f"Download failed: {exc}"}
|
|
870
|
+
|
|
871
|
+
if not local_path:
|
|
872
|
+
return {
|
|
873
|
+
"ok": False,
|
|
874
|
+
"error": "Download did not complete within 30s timeout",
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
response: dict = {
|
|
878
|
+
"ok": True,
|
|
879
|
+
"local_path": local_path,
|
|
880
|
+
"filename": os.path.basename(local_path),
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
# Copy into User Library so Ableton's browser indexes it
|
|
884
|
+
if copy_to_user_library:
|
|
885
|
+
dest_dir = os.path.expanduser(_SPLICE_USER_LIB_DEST)
|
|
886
|
+
try:
|
|
887
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
888
|
+
dest_path = os.path.join(dest_dir, os.path.basename(local_path))
|
|
889
|
+
if not os.path.exists(dest_path):
|
|
890
|
+
shutil.copy2(local_path, dest_path)
|
|
891
|
+
response["user_library_path"] = dest_path
|
|
892
|
+
# URI format Ableton uses for user_library samples
|
|
893
|
+
response["browser_uri"] = (
|
|
894
|
+
f"query:UserLibrary#Samples:Splice:{os.path.basename(local_path)}"
|
|
895
|
+
)
|
|
896
|
+
except Exception as exc:
|
|
897
|
+
response["copy_warning"] = f"Failed to copy to User Library: {exc}"
|
|
898
|
+
|
|
899
|
+
# Post-credit count
|
|
900
|
+
try:
|
|
901
|
+
info = await client.get_credits()
|
|
902
|
+
response["credits_remaining"] = int(info.credits)
|
|
903
|
+
except Exception as exc:
|
|
904
|
+
logger.warning("post-download credit check failed: %s", exc)
|
|
905
|
+
|
|
906
|
+
return response
|
|
@@ -14,6 +14,9 @@ from fastmcp import Context
|
|
|
14
14
|
|
|
15
15
|
from ..server import mcp
|
|
16
16
|
from . import registry
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
@mcp.tool()
|
|
@@ -80,7 +83,8 @@ def preview_semantic_move(
|
|
|
80
83
|
info = ableton.send_command("get_session_info")
|
|
81
84
|
if isinstance(info, dict):
|
|
82
85
|
session_info = info
|
|
83
|
-
except Exception:
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
logger.debug("preview_semantic_move failed: %s", exc)
|
|
84
88
|
session_info = {}
|
|
85
89
|
|
|
86
90
|
state = build_capability_state(
|
package/mcp_server/server.py
CHANGED
|
@@ -44,9 +44,9 @@ def _master_has_livepilot_analyzer(ableton: AbletonConnection) -> bool:
|
|
|
44
44
|
"""Check whether the analyzer device is currently on the master track."""
|
|
45
45
|
try:
|
|
46
46
|
track = ableton.send_command("get_master_track")
|
|
47
|
-
except Exception:
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
logger.debug("_master_has_livepilot_analyzer failed: %s", exc)
|
|
48
49
|
return False
|
|
49
|
-
|
|
50
50
|
devices = track.get("devices", []) if isinstance(track, dict) else []
|
|
51
51
|
for device in devices:
|
|
52
52
|
normalized = " ".join(
|
|
@@ -92,7 +92,8 @@ async def lifespan(server):
|
|
|
92
92
|
splice_client = SpliceGRPCClient()
|
|
93
93
|
try:
|
|
94
94
|
await splice_client.connect()
|
|
95
|
-
except Exception:
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
logger.debug("lifespan failed: %s", exc)
|
|
96
97
|
pass # client remains in disconnected state
|
|
97
98
|
|
|
98
99
|
# Start UDP listener for incoming M4L spectral data (port 9880)
|
|
@@ -144,10 +145,8 @@ async def lifespan(server):
|
|
|
144
145
|
ableton.disconnect()
|
|
145
146
|
try:
|
|
146
147
|
await splice_client.disconnect()
|
|
147
|
-
except Exception:
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
logger.debug("lifespan failed: %s", exc)
|
|
151
150
|
mcp = FastMCP("LivePilot", lifespan=lifespan)
|
|
152
151
|
|
|
153
152
|
# Import tool modules so they register with `mcp`
|
|
@@ -199,7 +198,9 @@ from .device_forge import tools as device_forge_tools # noqa: F401, E40
|
|
|
199
198
|
from .sample_engine import tools as sample_engine_tools # noqa: F401, E402
|
|
200
199
|
from .atlas import tools as atlas_tools # noqa: F401, E402
|
|
201
200
|
from .composer import tools as composer_tools # noqa: F401, E402
|
|
201
|
+
import logging
|
|
202
202
|
|
|
203
|
+
logger = logging.getLogger(__name__)
|
|
203
204
|
|
|
204
205
|
# ---------------------------------------------------------------------------
|
|
205
206
|
# Schema coercion patch — accept strings for numeric parameters
|
|
@@ -213,6 +214,7 @@ from .composer import tools as composer_tools # noqa: F401, E40
|
|
|
213
214
|
# "5" → 5 and "0.75" → 0.75 automatically, so no tool code changes needed.
|
|
214
215
|
# ---------------------------------------------------------------------------
|
|
215
216
|
|
|
217
|
+
|
|
216
218
|
def _coerce_schema_property(prop: dict) -> None:
|
|
217
219
|
"""Widen a single JSON Schema property to also accept strings."""
|
|
218
220
|
if prop.get("type") in ("integer", "number") and "anyOf" not in prop:
|
|
@@ -253,6 +255,7 @@ def _get_all_tools():
|
|
|
253
255
|
if hasattr(mcp, "_local_provider") and hasattr(mcp._local_provider, "_components"):
|
|
254
256
|
return list(mcp._local_provider._components.values())
|
|
255
257
|
import sys
|
|
258
|
+
|
|
256
259
|
print(
|
|
257
260
|
"LivePilot: WARNING — could not access FastMCP tool registry, "
|
|
258
261
|
"string-to-number schema coercion will not work",
|
|
@@ -273,7 +276,6 @@ def _patch_tool_schemas() -> None:
|
|
|
273
276
|
if isinstance(definition, dict):
|
|
274
277
|
_coerce_schema_property(definition)
|
|
275
278
|
|
|
276
|
-
|
|
277
279
|
_patch_tool_schemas()
|
|
278
280
|
|
|
279
281
|
|
|
@@ -281,6 +283,5 @@ def main():
|
|
|
281
283
|
"""Run the MCP server over stdio."""
|
|
282
284
|
mcp.run(transport="stdio")
|
|
283
285
|
|
|
284
|
-
|
|
285
286
|
if __name__ == "__main__":
|
|
286
287
|
main()
|
|
@@ -9,6 +9,9 @@ Pure computation — no I/O. Callers provide pre-fetched data.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
from typing import Optional
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
def get_motif_data(
|
|
@@ -28,13 +31,15 @@ def get_motif_data(
|
|
|
28
31
|
|
|
29
32
|
try:
|
|
30
33
|
from ..tools import _motif_engine as motif_engine
|
|
34
|
+
|
|
31
35
|
motifs = motif_engine.detect_motifs(notes_by_track)
|
|
32
36
|
return {
|
|
33
37
|
"motifs": [m.to_dict() for m in motifs],
|
|
34
38
|
"motif_count": len(motifs),
|
|
35
39
|
"tracks_analyzed": len(notes_by_track),
|
|
36
40
|
}
|
|
37
|
-
except Exception:
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
logger.debug("get_motif_data failed: %s", exc)
|
|
38
43
|
return {"motifs": [], "motif_count": 0, "tracks_analyzed": 0}
|
|
39
44
|
|
|
40
45
|
|
|
@@ -60,8 +65,9 @@ def fetch_notes_from_ableton(ableton, tracks: list[dict], max_clips: int = 8) ->
|
|
|
60
65
|
"clip_index": clip_idx,
|
|
61
66
|
})
|
|
62
67
|
track_notes.extend(result.get("notes", []))
|
|
63
|
-
except Exception:
|
|
64
|
-
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
logger.debug("fetch_notes_from_ableton failed: %s", exc)
|
|
70
|
+
|
|
65
71
|
if track_notes:
|
|
66
72
|
notes_by_track[t_idx] = track_notes
|
|
67
73
|
return notes_by_track
|
|
@@ -15,6 +15,9 @@ from fastmcp import Context
|
|
|
15
15
|
|
|
16
16
|
from ..server import mcp
|
|
17
17
|
from . import tracker
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
@mcp.tool()
|
|
@@ -200,7 +203,6 @@ def explain_preference_vs_identity(
|
|
|
200
203
|
"note": "Identity has stronger weight inside a session (0.65 vs 0.35)",
|
|
201
204
|
}
|
|
202
205
|
|
|
203
|
-
|
|
204
206
|
# ── Helpers ───────────────────────────────────────────────────────
|
|
205
207
|
|
|
206
208
|
|
|
@@ -222,9 +224,11 @@ def _get_taste_graph(ctx: Context) -> dict:
|
|
|
222
224
|
from ..memory.taste_graph import build_taste_graph
|
|
223
225
|
from ..memory.taste_memory import TasteMemoryStore
|
|
224
226
|
from ..memory.anti_memory import AntiMemoryStore
|
|
227
|
+
|
|
225
228
|
taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
|
|
226
229
|
anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
|
|
227
230
|
return build_taste_graph(taste_store=taste_store, anti_store=anti_store).to_dict()
|
|
228
|
-
except Exception:
|
|
229
|
-
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
logger.debug("_get_taste_graph failed: %s", exc)
|
|
233
|
+
|
|
230
234
|
return {}
|