livepilot 1.16.0 → 1.17.0
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 +344 -5
- package/README.md +16 -15
- 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 +85 -0
- package/mcp_server/atlas/device_atlas.json +3183 -382
- package/mcp_server/atlas/device_techniques_index.json +1510 -0
- package/mcp_server/atlas/enrichments/__init__.py +1 -0
- package/mcp_server/atlas/enrichments/audio_effects/amp.yaml +112 -0
- package/mcp_server/atlas/enrichments/audio_effects/audio_effect_rack.yaml +77 -0
- package/mcp_server/atlas/enrichments/audio_effects/cabinet.yaml +81 -0
- package/mcp_server/atlas/enrichments/audio_effects/corpus.yaml +128 -0
- package/mcp_server/atlas/enrichments/audio_effects/envelope_follower.yaml +99 -0
- package/mcp_server/atlas/enrichments/audio_effects/external_audio_effect.yaml +64 -0
- package/mcp_server/atlas/enrichments/audio_effects/looper.yaml +85 -0
- package/mcp_server/atlas/enrichments/audio_effects/pitch_hack.yaml +61 -0
- package/mcp_server/atlas/enrichments/audio_effects/pitchloop89.yaml +111 -0
- package/mcp_server/atlas/enrichments/audio_effects/re_enveloper.yaml +51 -0
- package/mcp_server/atlas/enrichments/audio_effects/resonators.yaml +121 -0
- package/mcp_server/atlas/enrichments/audio_effects/snipper.yaml +53 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_blur.yaml +64 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectrum.yaml +61 -0
- package/mcp_server/atlas/enrichments/audio_effects/tuner.yaml +43 -0
- package/mcp_server/atlas/enrichments/audio_effects/utility.yaml +118 -0
- package/mcp_server/atlas/enrichments/audio_effects/vocoder.yaml +94 -0
- package/mcp_server/atlas/enrichments/instruments/analog.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/bass.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +75 -0
- package/mcp_server/atlas/enrichments/instruments/collision.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/drift.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/drum_rack.yaml +142 -0
- package/mcp_server/atlas/enrichments/instruments/electric.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/emit.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/granulator_iii.yaml +124 -0
- package/mcp_server/atlas/enrichments/instruments/harmonic_drone_generator.yaml +83 -0
- package/mcp_server/atlas/enrichments/instruments/impulse.yaml +47 -0
- package/mcp_server/atlas/enrichments/instruments/meld.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/operator.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/poli.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/sampler.yaml +12 -0
- package/mcp_server/atlas/enrichments/instruments/simpler.yaml +15 -0
- package/mcp_server/atlas/enrichments/instruments/sting_iftah.yaml +44 -0
- package/mcp_server/atlas/enrichments/instruments/tension.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +11 -0
- package/mcp_server/atlas/enrichments/midi_effects/expressive_chords.yaml +38 -0
- package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +49 -0
- package/mcp_server/atlas/enrichments/midi_effects/microtuner.yaml +83 -0
- package/mcp_server/atlas/enrichments/midi_effects/patterns_iftah.yaml +38 -0
- package/mcp_server/atlas/enrichments/midi_effects/phase_pattern.yaml +51 -0
- package/mcp_server/atlas/enrichments/midi_effects/polyrhythm.yaml +46 -0
- package/mcp_server/atlas/enrichments/midi_effects/retrigger.yaml +40 -0
- package/mcp_server/atlas/enrichments/midi_effects/slice_shuffler.yaml +39 -0
- package/mcp_server/atlas/enrichments/midi_effects/sq_sequencer.yaml +39 -0
- package/mcp_server/atlas/enrichments/midi_effects/stages.yaml +38 -0
- package/mcp_server/atlas/enrichments/utility/arrangement_looper.yaml +31 -0
- package/mcp_server/atlas/enrichments/utility/cv_clock_in.yaml +25 -0
- package/mcp_server/atlas/enrichments/utility/cv_clock_out.yaml +25 -0
- package/mcp_server/atlas/enrichments/utility/cv_envelope_follower.yaml +38 -0
- package/mcp_server/atlas/enrichments/utility/cv_in.yaml +26 -0
- package/mcp_server/atlas/enrichments/utility/cv_instrument.yaml +34 -0
- package/mcp_server/atlas/enrichments/utility/cv_lfo.yaml +38 -0
- package/mcp_server/atlas/enrichments/utility/cv_shaper.yaml +35 -0
- package/mcp_server/atlas/enrichments/utility/cv_triggers.yaml +26 -0
- package/mcp_server/atlas/enrichments/utility/cv_utility.yaml +37 -0
- package/mcp_server/atlas/enrichments/utility/performer.yaml +51 -0
- package/mcp_server/atlas/enrichments/utility/prearranger.yaml +36 -0
- package/mcp_server/atlas/enrichments/utility/rotating_rhythm_generator.yaml +35 -0
- package/mcp_server/atlas/enrichments/utility/surround_panner.yaml +40 -0
- package/mcp_server/atlas/enrichments/utility/variations.yaml +40 -0
- package/mcp_server/atlas/enrichments/utility/vector_map.yaml +57 -0
- package/mcp_server/atlas/tools.py +291 -0
- package/mcp_server/m4l_bridge.py +19 -2
- package/mcp_server/sample_engine/tools.py +190 -72
- package/mcp_server/server.py +18 -6
- package/mcp_server/splice_client/client.py +90 -18
- package/mcp_server/splice_client/http_bridge.py +414 -138
- package/mcp_server/splice_client/models.py +12 -0
- package/mcp_server/tools/analyzer.py +150 -1
- package/mcp_server/tools/automation.py +168 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +216 -1
- package/server.json +3 -3
|
@@ -205,6 +205,7 @@ async def search_samples(
|
|
|
205
205
|
max_results: int = 10,
|
|
206
206
|
free_only: bool = False,
|
|
207
207
|
q: Optional[str] = None,
|
|
208
|
+
collection_uuid: str = "",
|
|
208
209
|
) -> dict:
|
|
209
210
|
"""Search for samples across Splice library, Ableton browser, and local filesystem.
|
|
210
211
|
|
|
@@ -224,6 +225,9 @@ async def search_samples(
|
|
|
224
225
|
key: prefer samples in this key (e.g., "Cm", "F#")
|
|
225
226
|
bpm_range: "min-max" BPM range (e.g., "120-130")
|
|
226
227
|
source: "splice", "browser", "filesystem", or None for all
|
|
228
|
+
collection_uuid: scope Splice results to a user collection (Likes,
|
|
229
|
+
bass, keys, etc.). Obtain via splice_list_collections. When set,
|
|
230
|
+
browser/filesystem sources are skipped — this is taste-scoped search.
|
|
227
231
|
max_results: maximum results to return (default 10)
|
|
228
232
|
free_only: if True, only return samples that cost nothing to license
|
|
229
233
|
(IsPremium=False or Price=0). Under the Ableton Live plan these
|
|
@@ -247,6 +251,11 @@ async def search_samples(
|
|
|
247
251
|
except ValueError:
|
|
248
252
|
pass
|
|
249
253
|
|
|
254
|
+
# When scoped to a Splice collection, force source=splice and skip
|
|
255
|
+
# browser/filesystem since those don't carry collection metadata.
|
|
256
|
+
if collection_uuid and source is None:
|
|
257
|
+
source = "splice"
|
|
258
|
+
|
|
250
259
|
# Splice search — prefer gRPC online catalog when available, fall back
|
|
251
260
|
# to local SQLite index. See docs/2026-04-14-bugs-discovered.md — P0-2.
|
|
252
261
|
if source in (None, "splice"):
|
|
@@ -267,6 +276,7 @@ async def search_samples(
|
|
|
267
276
|
per_page=max_results,
|
|
268
277
|
page=1,
|
|
269
278
|
purchased_only=False,
|
|
279
|
+
collection_uuid=collection_uuid,
|
|
270
280
|
)
|
|
271
281
|
for s in grpc_result.samples[:max_results]:
|
|
272
282
|
if free_only and not s.is_free:
|
|
@@ -782,11 +792,15 @@ async def get_splice_credits(ctx: Context) -> dict:
|
|
|
782
792
|
gating = "credit_floor"
|
|
783
793
|
can_download = remaining > CREDIT_HARD_FLOOR
|
|
784
794
|
|
|
795
|
+
from ..splice_client.client import _read_plan_kind_override
|
|
796
|
+
plan_override_active = _read_plan_kind_override()
|
|
797
|
+
|
|
785
798
|
return {
|
|
786
799
|
"connected": True,
|
|
787
800
|
"username": info.username,
|
|
788
801
|
"plan_raw": info.plan,
|
|
789
802
|
"plan_kind": plan.value,
|
|
803
|
+
"plan_kind_override": plan_override_active,
|
|
790
804
|
"sounds_plan_id": info.sounds_plan_id,
|
|
791
805
|
"features": info.features,
|
|
792
806
|
"user_uuid": info.user_uuid,
|
|
@@ -1091,16 +1105,34 @@ async def splice_preview_sample(
|
|
|
1091
1105
|
if client is None or not getattr(client, "connected", False):
|
|
1092
1106
|
return {"ok": False, "error": "Splice gRPC not connected"}
|
|
1093
1107
|
|
|
1108
|
+
# Two-stage lookup: SampleInfo is the fast path but only returns
|
|
1109
|
+
# full metadata (including the signed PreviewURL) for downloaded or
|
|
1110
|
+
# purchased samples. For un-downloaded catalog items, fall back to
|
|
1111
|
+
# SearchSamples(FileHash=...) which hits the catalog index and always
|
|
1112
|
+
# returns PreviewURL. Observed live 2026-04-22.
|
|
1094
1113
|
sample = None
|
|
1095
1114
|
try:
|
|
1096
1115
|
sample = await client.get_sample_info(file_hash)
|
|
1097
1116
|
except Exception as exc:
|
|
1098
|
-
|
|
1117
|
+
logger.debug("SampleInfo lookup failed, falling back to search: %s", exc)
|
|
1118
|
+
|
|
1119
|
+
if sample is None or not sample.preview_url:
|
|
1120
|
+
try:
|
|
1121
|
+
search = await client.search_samples(file_hash=file_hash, per_page=1)
|
|
1122
|
+
if search.samples:
|
|
1123
|
+
sample = search.samples[0]
|
|
1124
|
+
except Exception as exc:
|
|
1125
|
+
return {"ok": False, "error": f"PreviewURL lookup failed: {exc}"}
|
|
1099
1126
|
|
|
1100
1127
|
if sample is None or not sample.preview_url:
|
|
1101
1128
|
return {
|
|
1102
1129
|
"ok": False,
|
|
1103
|
-
"error":
|
|
1130
|
+
"error": (
|
|
1131
|
+
"No preview URL available for this sample. Splice may "
|
|
1132
|
+
"require the sample to be in a public catalog index. "
|
|
1133
|
+
"Try splice_catalog_hunt first to obtain a fresh "
|
|
1134
|
+
"preview_url from the search result directly."
|
|
1135
|
+
),
|
|
1104
1136
|
"file_hash": file_hash,
|
|
1105
1137
|
}
|
|
1106
1138
|
|
|
@@ -1410,9 +1442,23 @@ async def splice_pack_info(ctx: Context, pack_uuid: str) -> dict:
|
|
|
1410
1442
|
return err
|
|
1411
1443
|
if not pack_uuid:
|
|
1412
1444
|
return {"ok": False, "error": "pack_uuid is required"}
|
|
1413
|
-
|
|
1445
|
+
# Pass the UUID through unchanged — Splice uses two valid UUID formats
|
|
1446
|
+
# (canonical 36-char and extended 43-char with longer last group). The
|
|
1447
|
+
# client tries BOTH forms during ListSamplePacks matching. An earlier
|
|
1448
|
+
# revision pre-truncated to 36 chars here, which incorrectly discarded
|
|
1449
|
+
# part of a legitimate extended UUID (observed 2026-04-22 live: pack
|
|
1450
|
+
# "1170db75-0ce1-5280-bb61-887a0dd7f26bf5a3951" is an owned pack but
|
|
1451
|
+
# pre-truncation made the client look for a UUID that didn't exist in
|
|
1452
|
+
# ListSamplePacks' response).
|
|
1453
|
+
submitted = pack_uuid.strip()
|
|
1454
|
+
pack, err_msg = await client.get_pack_info(submitted)
|
|
1414
1455
|
if pack is None:
|
|
1415
|
-
return {
|
|
1456
|
+
return {
|
|
1457
|
+
"ok": False,
|
|
1458
|
+
"error": err_msg or "Pack not found",
|
|
1459
|
+
"pack_uuid_submitted": submitted,
|
|
1460
|
+
"pack_uuid_original": pack_uuid,
|
|
1461
|
+
}
|
|
1416
1462
|
return {"ok": True, "pack": pack.to_dict()}
|
|
1417
1463
|
|
|
1418
1464
|
|
|
@@ -1450,23 +1496,29 @@ async def splice_describe_sound(
|
|
|
1450
1496
|
bpm: Optional[int] = None,
|
|
1451
1497
|
key: Optional[str] = None,
|
|
1452
1498
|
limit: int = 20,
|
|
1499
|
+
rephrase: bool = True,
|
|
1453
1500
|
) -> dict:
|
|
1454
1501
|
"""Natural-language sample search — the Sounds Plugin's "Describe a Sound".
|
|
1455
1502
|
|
|
1456
1503
|
Splice's AI matches free-form descriptions like "dark ambient pad with
|
|
1457
|
-
shimmer" or "tight 90s house hi-hat" to catalog samples.
|
|
1458
|
-
|
|
1459
|
-
|
|
1504
|
+
shimmer" or "tight 90s house hi-hat" to catalog samples. Hits the
|
|
1505
|
+
GraphQL `SamplesSearch` operation on `surfaces-graphql.splice.com`
|
|
1506
|
+
with `semantic=1` + `rephrase=true` enabled.
|
|
1460
1507
|
|
|
1461
|
-
**Status:
|
|
1462
|
-
|
|
1463
|
-
`SPLICE_ALLOW_UNVERIFIED_ENDPOINTS=1`), this tool returns a structured
|
|
1464
|
-
ENDPOINT_NOT_CONFIGURED error with actionable setup steps.
|
|
1508
|
+
**Status: LIVE** as of 2026-04-22. Endpoint captured via mitmproxy
|
|
1509
|
+
against Splice desktop 5.4.9 + Sounds Plugin.
|
|
1465
1510
|
|
|
1466
1511
|
description: free-text prompt ("warm analog bass under 80bpm")
|
|
1467
1512
|
bpm: optional BPM filter
|
|
1468
1513
|
key: optional musical key ("Dm", "F#")
|
|
1469
1514
|
limit: max results (default 20)
|
|
1515
|
+
rephrase: let Splice's ML rephrase the query for better matches
|
|
1516
|
+
(default True). Returned as `rephrased_query_string`.
|
|
1517
|
+
|
|
1518
|
+
Returns `{ok, query, samples[], total_hits, rephrased_query_string,
|
|
1519
|
+
tag_summary[], ...}`. Each sample has uuid/name/bpm/key/duration/
|
|
1520
|
+
instrument/tags/pack_name/files. Use the uuid with
|
|
1521
|
+
`splice_download_sample(uuid)` to pull the audio file.
|
|
1470
1522
|
"""
|
|
1471
1523
|
bridge, err = _build_http_bridge(ctx)
|
|
1472
1524
|
if err:
|
|
@@ -1478,95 +1530,161 @@ async def splice_describe_sound(
|
|
|
1478
1530
|
result = await bridge.describe_sound(
|
|
1479
1531
|
description=description.strip(),
|
|
1480
1532
|
bpm=bpm, key=key, limit=int(limit),
|
|
1533
|
+
rephrase=bool(rephrase),
|
|
1481
1534
|
)
|
|
1482
1535
|
except SpliceHTTPError as exc:
|
|
1483
1536
|
return exc.to_dict()
|
|
1484
1537
|
except Exception as exc:
|
|
1485
1538
|
return {"ok": False, "error": f"describe_sound failed: {exc}"}
|
|
1486
|
-
|
|
1539
|
+
# Don't expose the full GraphQL `raw` dict in the user-facing response
|
|
1540
|
+
# unless they asked — it adds ~270KB noise per call. Keep it for
|
|
1541
|
+
# power users via an explicit future flag.
|
|
1542
|
+
out = dict(result) if isinstance(result, dict) else {"raw": result}
|
|
1543
|
+
out.pop("raw", None)
|
|
1544
|
+
return {"ok": True, "query": description, **out}
|
|
1487
1545
|
|
|
1488
1546
|
|
|
1489
1547
|
@mcp.tool()
|
|
1490
1548
|
async def splice_generate_variation(
|
|
1491
1549
|
ctx: Context,
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
target_bpm: Optional[int] = None,
|
|
1495
|
-
count: int = 1,
|
|
1550
|
+
uuid: str,
|
|
1551
|
+
is_legacy: bool = True,
|
|
1496
1552
|
) -> dict:
|
|
1497
|
-
"""
|
|
1498
|
-
|
|
1499
|
-
Splice's
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1553
|
+
"""Find catalog samples similar to a given Splice sample — the "Variations" feature.
|
|
1554
|
+
|
|
1555
|
+
Splice's right-click "Variations" menu item surfaces other catalog
|
|
1556
|
+
samples with similar sonic character. The GraphQL operation name
|
|
1557
|
+
is `AssetSimilarSoundsQuery`. Up to 10 results per call. No credit
|
|
1558
|
+
cost (this is a recommender lookup, not AI audio synthesis — the
|
|
1559
|
+
original naming in the handoff was aspirational).
|
|
1560
|
+
|
|
1561
|
+
**Status: LIVE** as of 2026-04-22. Endpoint captured via mitmproxy
|
|
1562
|
+
against Splice desktop v5.4.9.
|
|
1563
|
+
|
|
1564
|
+
uuid: source sample's catalog uuid (from `splice_describe_sound`
|
|
1565
|
+
results or any other Splice metadata call)
|
|
1566
|
+
is_legacy: match how Splice's own client sets it — default True is
|
|
1567
|
+
correct for all mainstream catalog samples; set False only
|
|
1568
|
+
if working with post-catalog-v2 assets
|
|
1569
|
+
|
|
1570
|
+
Returns `{ok, uuid, similar_samples[], count}`. Each entry has the
|
|
1571
|
+
same flat shape as a describe_sound sample (uuid/name/bpm/key/
|
|
1572
|
+
duration/tags/pack_name/files). Use the uuid of any result with
|
|
1573
|
+
`splice_download_sample()` to pull the audio.
|
|
1515
1574
|
"""
|
|
1516
1575
|
bridge, err = _build_http_bridge(ctx)
|
|
1517
1576
|
if err:
|
|
1518
1577
|
return err
|
|
1519
1578
|
from ..splice_client.http_bridge import SpliceHTTPError
|
|
1520
|
-
if not
|
|
1521
|
-
return {"ok": False, "error": "
|
|
1522
|
-
if count < 1 or count > 5:
|
|
1523
|
-
return {"ok": False, "error": "count must be 1-5"}
|
|
1579
|
+
if not uuid or not uuid.strip():
|
|
1580
|
+
return {"ok": False, "error": "uuid is required"}
|
|
1524
1581
|
try:
|
|
1525
1582
|
result = await bridge.generate_variation(
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
target_bpm=target_bpm,
|
|
1529
|
-
count=int(count),
|
|
1583
|
+
uuid=uuid.strip(),
|
|
1584
|
+
is_legacy=bool(is_legacy),
|
|
1530
1585
|
)
|
|
1531
1586
|
except SpliceHTTPError as exc:
|
|
1532
1587
|
return exc.to_dict()
|
|
1533
1588
|
except Exception as exc:
|
|
1534
1589
|
return {"ok": False, "error": f"generate_variation failed: {exc}"}
|
|
1535
|
-
|
|
1590
|
+
out = dict(result) if isinstance(result, dict) else {"raw": result}
|
|
1591
|
+
out.pop("raw", None) # drop verbose debug payload
|
|
1592
|
+
return {"ok": True, "uuid": uuid, **out}
|
|
1536
1593
|
|
|
1537
1594
|
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
audio_path: str,
|
|
1542
|
-
limit: int = 20,
|
|
1543
|
-
) -> dict:
|
|
1544
|
-
"""Reference-audio search — the Sounds Plugin's "Search with Sound".
|
|
1595
|
+
# NOTE: splice_search_with_sound was removed 2026-04-22 — user does this
|
|
1596
|
+
# in-Splice manually. If someone wants to resurrect it, the capture recipe
|
|
1597
|
+
# is still at docs/2026-04-22-splice-https-capture-recipe.md.
|
|
1545
1598
|
|
|
1546
|
-
Uploads a local audio file to Splice's AI and returns catalog samples
|
|
1547
|
-
with similar character. Complements `splice_describe_sound` (text)
|
|
1548
|
-
and `search_samples` (keyword).
|
|
1549
1599
|
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
NOT_YET_IMPLEMENTED error.
|
|
1600
|
+
@mcp.tool()
|
|
1601
|
+
async def splice_http_diagnose(ctx: Context) -> dict:
|
|
1602
|
+
"""Diagnose the Splice HTTPS bridge configuration and readiness.
|
|
1554
1603
|
|
|
1555
|
-
|
|
1556
|
-
|
|
1604
|
+
Reports which endpoints are configured, whether a session token is
|
|
1605
|
+
reachable from the gRPC client, and what the next step is to unblock
|
|
1606
|
+
`splice_describe_sound` and `splice_generate_variation`.
|
|
1607
|
+
|
|
1608
|
+
Use this BEFORE calling either tool if you want a clear readout of
|
|
1609
|
+
"what's missing, and how do I fix it" instead of per-tool
|
|
1610
|
+
ENDPOINT_NOT_CONFIGURED errors.
|
|
1557
1611
|
"""
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1612
|
+
from ..splice_client.http_bridge import SpliceHTTPConfig
|
|
1613
|
+
|
|
1614
|
+
cfg = SpliceHTTPConfig.from_env()
|
|
1615
|
+
endpoints = {
|
|
1616
|
+
"describe": cfg.describe_endpoint,
|
|
1617
|
+
"variation": cfg.variation_endpoint,
|
|
1618
|
+
}
|
|
1619
|
+
verified = {
|
|
1620
|
+
"describe": cfg.describe_verified,
|
|
1621
|
+
"variation": cfg.variation_verified,
|
|
1622
|
+
}
|
|
1623
|
+
unverified = [name for name, ok in verified.items() if not ok]
|
|
1624
|
+
configured_count = sum(1 for v in endpoints.values() if v not in (None, ""))
|
|
1625
|
+
|
|
1626
|
+
# Try to read the session token via the gRPC client the SAME way
|
|
1627
|
+
# the real tools do — reach into ctx.lifespan_context["splice_client"]
|
|
1628
|
+
# and actually attempt a GetSession fetch. Walking a different
|
|
1629
|
+
# engine-nested path (earlier mistake) reported "token unavailable"
|
|
1630
|
+
# while the bridge's real request path succeeded — a misleading
|
|
1631
|
+
# diagnostic is worse than no diagnostic.
|
|
1632
|
+
session_token_available = False
|
|
1633
|
+
session_token_error = None
|
|
1634
|
+
grpc_client = None
|
|
1564
1635
|
try:
|
|
1565
|
-
|
|
1566
|
-
|
|
1636
|
+
grpc_client = ctx.lifespan_context.get("splice_client")
|
|
1637
|
+
except AttributeError:
|
|
1638
|
+
pass
|
|
1639
|
+
if grpc_client is None or not getattr(grpc_client, "connected", False):
|
|
1640
|
+
session_token_error = "Splice gRPC not connected"
|
|
1641
|
+
else:
|
|
1642
|
+
# Connection is up; confirm a token actually comes back.
|
|
1643
|
+
from ..splice_client.http_bridge import fetch_session_token
|
|
1644
|
+
try:
|
|
1645
|
+
token = await fetch_session_token(grpc_client)
|
|
1646
|
+
if token:
|
|
1647
|
+
session_token_available = True
|
|
1648
|
+
else:
|
|
1649
|
+
session_token_error = (
|
|
1650
|
+
"GetSession RPC returned no token — user may be "
|
|
1651
|
+
"logged out or gRPC schema drifted"
|
|
1652
|
+
)
|
|
1653
|
+
except Exception as exc:
|
|
1654
|
+
session_token_error = f"GetSession call failed: {exc}"
|
|
1655
|
+
|
|
1656
|
+
next_steps: list = []
|
|
1657
|
+
if "describe" in unverified:
|
|
1658
|
+
next_steps.append(
|
|
1659
|
+
"Describe endpoint unverified — reset config to defaults "
|
|
1660
|
+
"(delete ~/.livepilot/splice.json or unset env vars) so the "
|
|
1661
|
+
"captured surfaces-graphql.splice.com/graphql endpoint is used."
|
|
1567
1662
|
)
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1663
|
+
if "variation" in unverified:
|
|
1664
|
+
next_steps.append(
|
|
1665
|
+
"Variation GraphQL operation not yet captured. Right-click "
|
|
1666
|
+
"a Splice sample and click Variations with mitmproxy running. "
|
|
1667
|
+
"See docs/2026-04-22-splice-https-capture-recipe.md."
|
|
1668
|
+
)
|
|
1669
|
+
if not session_token_available:
|
|
1670
|
+
next_steps.append(
|
|
1671
|
+
"Splice desktop app is not reachable — the bridge reads the "
|
|
1672
|
+
"session token via gRPC GetSession RPC. Ensure the app is "
|
|
1673
|
+
"running and logged in."
|
|
1674
|
+
)
|
|
1675
|
+
if not next_steps:
|
|
1676
|
+
next_steps.append("Bridge fully ready — test with splice_describe_sound.")
|
|
1677
|
+
|
|
1678
|
+
return {
|
|
1679
|
+
"ok": True,
|
|
1680
|
+
"base_url": cfg.base_url,
|
|
1681
|
+
"endpoints": endpoints,
|
|
1682
|
+
"verified": verified,
|
|
1683
|
+
"configured_count": configured_count,
|
|
1684
|
+
"unverified_endpoints": unverified,
|
|
1685
|
+
"is_user_configured": cfg.is_user_configured,
|
|
1686
|
+
"session_token_available": session_token_available,
|
|
1687
|
+
"session_token_error": session_token_error,
|
|
1688
|
+
"next_steps": next_steps,
|
|
1689
|
+
"docs": "docs/2026-04-22-splice-https-capture-recipe.md",
|
|
1690
|
+
}
|
package/mcp_server/server.py
CHANGED
|
@@ -378,11 +378,21 @@ def _get_all_tools():
|
|
|
378
378
|
"_local_provider._tools",
|
|
379
379
|
lambda: list(mcp._local_provider._tools.values()),
|
|
380
380
|
),
|
|
381
|
-
#
|
|
382
|
-
#
|
|
383
|
-
#
|
|
384
|
-
(
|
|
381
|
+
# NB: mcp.list_tools() IS the public API, but it's a coroutine —
|
|
382
|
+
# can't be iterated in a sync context. We skip it here and rely on
|
|
383
|
+
# the internal probes. When FastMCP exposes a sync view we'll add
|
|
384
|
+
# it back. (Earlier form tried `list(mcp.list_tools())` which
|
|
385
|
+
# raised `RuntimeWarning: coroutine was never awaited` at every
|
|
386
|
+
# module import — removed 2026-04-22.)
|
|
385
387
|
]
|
|
388
|
+
# Observation 2026-04-22: some FastMCP 3.x builds keep BOTH the legacy
|
|
389
|
+
# `_tool_manager._tools` dict AND the newer `_local_provider._components`
|
|
390
|
+
# registry populated at the same time — but the legacy one lags the
|
|
391
|
+
# newer one (385 vs 422 during a recent startup). Returning the FIRST
|
|
392
|
+
# non-empty probe accidentally picked the stale view. Instead, collect
|
|
393
|
+
# every working probe and return the LARGEST view (the registry with
|
|
394
|
+
# the most tools is the authoritative one).
|
|
395
|
+
best: list = []
|
|
386
396
|
for label, fn in probes:
|
|
387
397
|
try:
|
|
388
398
|
tools = fn()
|
|
@@ -390,8 +400,10 @@ def _get_all_tools():
|
|
|
390
400
|
continue
|
|
391
401
|
except Exception: # noqa: BLE001 — any error from an internal probe means "skip"
|
|
392
402
|
continue
|
|
393
|
-
if tools:
|
|
394
|
-
|
|
403
|
+
if tools and len(tools) > len(best):
|
|
404
|
+
best = tools
|
|
405
|
+
if best:
|
|
406
|
+
return best
|
|
395
407
|
|
|
396
408
|
# All probes empty. Surface fastmcp version + attempted paths so the
|
|
397
409
|
# breakage is diagnosable without re-reading the code.
|
|
@@ -119,6 +119,30 @@ def _try_import_protos():
|
|
|
119
119
|
return None, None
|
|
120
120
|
|
|
121
121
|
|
|
122
|
+
def _read_plan_kind_override() -> Optional[str]:
|
|
123
|
+
"""Read `plan_kind_override` from ~/.livepilot/splice.json, if present.
|
|
124
|
+
|
|
125
|
+
Lets the user pin their Splice plan_kind when gRPC data is ambiguous.
|
|
126
|
+
Example config:
|
|
127
|
+
{"plan_kind_override": "ableton_live"}
|
|
128
|
+
Returns None silently on any I/O or JSON error.
|
|
129
|
+
"""
|
|
130
|
+
import json
|
|
131
|
+
path = os.path.expanduser("~/.livepilot/splice.json")
|
|
132
|
+
if not os.path.isfile(path):
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
136
|
+
data = json.load(f)
|
|
137
|
+
if isinstance(data, dict):
|
|
138
|
+
value = data.get("plan_kind_override")
|
|
139
|
+
if isinstance(value, str) and value.strip():
|
|
140
|
+
return value.strip()
|
|
141
|
+
except (OSError, json.JSONDecodeError):
|
|
142
|
+
pass
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
122
146
|
class SpliceGRPCClient:
|
|
123
147
|
"""Async gRPC client for Splice desktop's App service."""
|
|
124
148
|
|
|
@@ -486,10 +510,15 @@ class SpliceGRPCClient:
|
|
|
486
510
|
int(user.SoundsPlan) if hasattr(user, "SoundsPlan") else 0
|
|
487
511
|
)
|
|
488
512
|
uuid_str = str(user.UUID) if hasattr(user, "UUID") else ""
|
|
513
|
+
# Read optional plan_kind_override from ~/.livepilot/splice.json.
|
|
514
|
+
# Users who know their Splice plan can pin the classification
|
|
515
|
+
# here when the gRPC data is ambiguous. See models.classify_plan.
|
|
516
|
+
override = _read_plan_kind_override()
|
|
489
517
|
plan_kind = classify_plan(
|
|
490
518
|
sounds_status=user.SoundsStatus,
|
|
491
519
|
sounds_plan=sounds_plan,
|
|
492
520
|
features=features,
|
|
521
|
+
override=override,
|
|
493
522
|
)
|
|
494
523
|
creds = SpliceCredits(
|
|
495
524
|
credits=user.Credits,
|
|
@@ -658,28 +687,71 @@ class SpliceGRPCClient:
|
|
|
658
687
|
|
|
659
688
|
# ── Packs ───────────────────────────────────────────────────────
|
|
660
689
|
|
|
661
|
-
async def get_pack_info(
|
|
662
|
-
|
|
690
|
+
async def get_pack_info(
|
|
691
|
+
self, pack_uuid: str, max_pages: int = 5,
|
|
692
|
+
) -> tuple[Optional[SplicePack], Optional[str]]:
|
|
693
|
+
"""Fetch metadata for a single sample pack.
|
|
694
|
+
|
|
695
|
+
Splice's gRPC `App` service does NOT expose a per-UUID
|
|
696
|
+
`SamplePackInfo` RPC (only `ListSamplePacks` is published in the
|
|
697
|
+
service definition — the `SamplePackInfoRequest` / `...Response`
|
|
698
|
+
messages exist in the descriptor but no RPC binds them). So this
|
|
699
|
+
implementation paginates `ListSamplePacks` and matches client-side.
|
|
700
|
+
|
|
701
|
+
Only finds packs the user has engaged with (owned / downloaded /
|
|
702
|
+
in their library). Catalog-only packs return None with an
|
|
703
|
+
explanatory error.
|
|
704
|
+
|
|
705
|
+
Returns (pack, error) — `error` is a user-readable string when the
|
|
706
|
+
lookup didn't find a match.
|
|
707
|
+
"""
|
|
663
708
|
if not self.connected:
|
|
664
|
-
return None
|
|
709
|
+
return None, "Splice gRPC not connected"
|
|
665
710
|
pb2 = self._pb2
|
|
711
|
+
target = pack_uuid.strip()
|
|
712
|
+
# Splice uses two UUID formats: canonical 36-char and an "extended"
|
|
713
|
+
# form with a longer last group (observed 43 chars, e.g.
|
|
714
|
+
# "1170db75-0ce1-5280-bb61-887a0dd7f26bf5a3951"). Both variants
|
|
715
|
+
# appear in sounds.db and search results. We match BOTH when the
|
|
716
|
+
# caller submits one form — the other form might be the one the
|
|
717
|
+
# server returns for the same pack. Observed 2026-04-22.
|
|
718
|
+
canonical = target[:36] if len(target) > 36 else target
|
|
719
|
+
targets = {target, canonical}
|
|
720
|
+
next_token = 0
|
|
666
721
|
try:
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
722
|
+
for _page in range(max(1, int(max_pages))):
|
|
723
|
+
response = await self.stub.ListSamplePacks(
|
|
724
|
+
pb2.ListSamplePacksRequest(NextToken=next_token),
|
|
725
|
+
timeout=INFO_TIMEOUT,
|
|
726
|
+
)
|
|
727
|
+
for p in response.SamplePacks:
|
|
728
|
+
p_uuid = p.UUID
|
|
729
|
+
p_canonical = p_uuid[:36] if len(p_uuid) > 36 else p_uuid
|
|
730
|
+
if p_uuid in targets or p_canonical in targets:
|
|
731
|
+
return SplicePack(
|
|
732
|
+
uuid=p_uuid,
|
|
733
|
+
name=p.Name,
|
|
734
|
+
cover_url=p.CoverURL,
|
|
735
|
+
genre=p.Genre,
|
|
736
|
+
permalink=p.Permalink,
|
|
737
|
+
provider_name=p.ProviderName,
|
|
738
|
+
), None
|
|
739
|
+
# If no next-page token, we've exhausted the list.
|
|
740
|
+
new_token = int(response.NextToken)
|
|
741
|
+
if new_token == 0 or new_token == next_token:
|
|
742
|
+
break
|
|
743
|
+
next_token = new_token
|
|
680
744
|
except Exception as exc:
|
|
681
|
-
|
|
682
|
-
|
|
745
|
+
msg = f"ListSamplePacks gRPC call failed: {type(exc).__name__}: {exc}"
|
|
746
|
+
logger.warning(msg)
|
|
747
|
+
return None, msg
|
|
748
|
+
return None, (
|
|
749
|
+
f"Pack '{target}' not found in the user's library. "
|
|
750
|
+
"Splice's gRPC only lists packs the user has engaged with "
|
|
751
|
+
"(owned/downloaded/in library). Catalog-only packs can't be "
|
|
752
|
+
"looked up via this RPC. Use the Splice website or Desktop app "
|
|
753
|
+
"to browse un-engaged packs."
|
|
754
|
+
)
|
|
683
755
|
|
|
684
756
|
# ── Presets ─────────────────────────────────────────────────────
|
|
685
757
|
|