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.
Files changed (86) hide show
  1. package/CHANGELOG.md +344 -5
  2. package/README.md +16 -15
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/atlas/__init__.py +85 -0
  7. package/mcp_server/atlas/device_atlas.json +3183 -382
  8. package/mcp_server/atlas/device_techniques_index.json +1510 -0
  9. package/mcp_server/atlas/enrichments/__init__.py +1 -0
  10. package/mcp_server/atlas/enrichments/audio_effects/amp.yaml +112 -0
  11. package/mcp_server/atlas/enrichments/audio_effects/audio_effect_rack.yaml +77 -0
  12. package/mcp_server/atlas/enrichments/audio_effects/cabinet.yaml +81 -0
  13. package/mcp_server/atlas/enrichments/audio_effects/corpus.yaml +128 -0
  14. package/mcp_server/atlas/enrichments/audio_effects/envelope_follower.yaml +99 -0
  15. package/mcp_server/atlas/enrichments/audio_effects/external_audio_effect.yaml +64 -0
  16. package/mcp_server/atlas/enrichments/audio_effects/looper.yaml +85 -0
  17. package/mcp_server/atlas/enrichments/audio_effects/pitch_hack.yaml +61 -0
  18. package/mcp_server/atlas/enrichments/audio_effects/pitchloop89.yaml +111 -0
  19. package/mcp_server/atlas/enrichments/audio_effects/re_enveloper.yaml +51 -0
  20. package/mcp_server/atlas/enrichments/audio_effects/resonators.yaml +121 -0
  21. package/mcp_server/atlas/enrichments/audio_effects/snipper.yaml +53 -0
  22. package/mcp_server/atlas/enrichments/audio_effects/spectral_blur.yaml +64 -0
  23. package/mcp_server/atlas/enrichments/audio_effects/spectrum.yaml +61 -0
  24. package/mcp_server/atlas/enrichments/audio_effects/tuner.yaml +43 -0
  25. package/mcp_server/atlas/enrichments/audio_effects/utility.yaml +118 -0
  26. package/mcp_server/atlas/enrichments/audio_effects/vocoder.yaml +94 -0
  27. package/mcp_server/atlas/enrichments/instruments/analog.yaml +11 -0
  28. package/mcp_server/atlas/enrichments/instruments/bass.yaml +11 -0
  29. package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +75 -0
  30. package/mcp_server/atlas/enrichments/instruments/collision.yaml +11 -0
  31. package/mcp_server/atlas/enrichments/instruments/drift.yaml +11 -0
  32. package/mcp_server/atlas/enrichments/instruments/drum_rack.yaml +142 -0
  33. package/mcp_server/atlas/enrichments/instruments/electric.yaml +11 -0
  34. package/mcp_server/atlas/enrichments/instruments/emit.yaml +11 -0
  35. package/mcp_server/atlas/enrichments/instruments/granulator_iii.yaml +124 -0
  36. package/mcp_server/atlas/enrichments/instruments/harmonic_drone_generator.yaml +83 -0
  37. package/mcp_server/atlas/enrichments/instruments/impulse.yaml +47 -0
  38. package/mcp_server/atlas/enrichments/instruments/meld.yaml +11 -0
  39. package/mcp_server/atlas/enrichments/instruments/operator.yaml +11 -0
  40. package/mcp_server/atlas/enrichments/instruments/poli.yaml +11 -0
  41. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +12 -0
  42. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +15 -0
  43. package/mcp_server/atlas/enrichments/instruments/sting_iftah.yaml +44 -0
  44. package/mcp_server/atlas/enrichments/instruments/tension.yaml +11 -0
  45. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +11 -0
  46. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +11 -0
  47. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +11 -0
  48. package/mcp_server/atlas/enrichments/midi_effects/expressive_chords.yaml +38 -0
  49. package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +49 -0
  50. package/mcp_server/atlas/enrichments/midi_effects/microtuner.yaml +83 -0
  51. package/mcp_server/atlas/enrichments/midi_effects/patterns_iftah.yaml +38 -0
  52. package/mcp_server/atlas/enrichments/midi_effects/phase_pattern.yaml +51 -0
  53. package/mcp_server/atlas/enrichments/midi_effects/polyrhythm.yaml +46 -0
  54. package/mcp_server/atlas/enrichments/midi_effects/retrigger.yaml +40 -0
  55. package/mcp_server/atlas/enrichments/midi_effects/slice_shuffler.yaml +39 -0
  56. package/mcp_server/atlas/enrichments/midi_effects/sq_sequencer.yaml +39 -0
  57. package/mcp_server/atlas/enrichments/midi_effects/stages.yaml +38 -0
  58. package/mcp_server/atlas/enrichments/utility/arrangement_looper.yaml +31 -0
  59. package/mcp_server/atlas/enrichments/utility/cv_clock_in.yaml +25 -0
  60. package/mcp_server/atlas/enrichments/utility/cv_clock_out.yaml +25 -0
  61. package/mcp_server/atlas/enrichments/utility/cv_envelope_follower.yaml +38 -0
  62. package/mcp_server/atlas/enrichments/utility/cv_in.yaml +26 -0
  63. package/mcp_server/atlas/enrichments/utility/cv_instrument.yaml +34 -0
  64. package/mcp_server/atlas/enrichments/utility/cv_lfo.yaml +38 -0
  65. package/mcp_server/atlas/enrichments/utility/cv_shaper.yaml +35 -0
  66. package/mcp_server/atlas/enrichments/utility/cv_triggers.yaml +26 -0
  67. package/mcp_server/atlas/enrichments/utility/cv_utility.yaml +37 -0
  68. package/mcp_server/atlas/enrichments/utility/performer.yaml +51 -0
  69. package/mcp_server/atlas/enrichments/utility/prearranger.yaml +36 -0
  70. package/mcp_server/atlas/enrichments/utility/rotating_rhythm_generator.yaml +35 -0
  71. package/mcp_server/atlas/enrichments/utility/surround_panner.yaml +40 -0
  72. package/mcp_server/atlas/enrichments/utility/variations.yaml +40 -0
  73. package/mcp_server/atlas/enrichments/utility/vector_map.yaml +57 -0
  74. package/mcp_server/atlas/tools.py +291 -0
  75. package/mcp_server/m4l_bridge.py +19 -2
  76. package/mcp_server/sample_engine/tools.py +190 -72
  77. package/mcp_server/server.py +18 -6
  78. package/mcp_server/splice_client/client.py +90 -18
  79. package/mcp_server/splice_client/http_bridge.py +414 -138
  80. package/mcp_server/splice_client/models.py +12 -0
  81. package/mcp_server/tools/analyzer.py +150 -1
  82. package/mcp_server/tools/automation.py +168 -0
  83. package/package.json +2 -2
  84. package/remote_script/LivePilot/__init__.py +1 -1
  85. package/remote_script/LivePilot/arrangement.py +216 -1
  86. 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
- return {"ok": False, "error": f"SampleInfo failed: {exc}"}
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": "No preview URL available for this sample",
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
- pack = await client.get_pack_info(pack_uuid)
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 {"ok": False, "error": "Pack not found or gRPC call failed"}
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. This is NOT
1458
- on the local gRPC — the bridge proxies to api.splice.com using your
1459
- session token.
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: scaffolding complete, endpoint pending real-traffic capture.**
1462
- Until `SPLICE_DESCRIBE_ENDPOINT` env var is set (or
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
- return {"ok": True, "query": description, **(result if isinstance(result, dict) else {"raw": result})}
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
- file_hash: str,
1493
- target_key: Optional[str] = None,
1494
- target_bpm: Optional[int] = None,
1495
- count: int = 1,
1550
+ uuid: str,
1551
+ is_legacy: bool = True,
1496
1552
  ) -> dict:
1497
- """Generate AI variations of a Splice sample — the Sounds Plugin's "Variations".
1498
-
1499
- Splice's AI produces unique re-keyed / re-tempo'd versions of any
1500
- sample. Costs additional credits per variation (on top of the base
1501
- license). NOT on the local gRPC bridged via api.splice.com.
1502
-
1503
- **Status: scaffolding complete, endpoint pending real-traffic capture.**
1504
- Until `SPLICE_VARIATION_ENDPOINT` env var is set (or
1505
- `SPLICE_ALLOW_UNVERIFIED_ENDPOINTS=1`), this tool returns a structured
1506
- ENDPOINT_NOT_CONFIGURED error with actionable setup steps.
1507
-
1508
- file_hash: sample identifier (from search results)
1509
- target_key: desired key (e.g. "Am")
1510
- target_bpm: desired tempo
1511
- count: number of variations to generate (1-5)
1512
-
1513
- WARNING: this WILL spend credits when the endpoint is live.
1514
- Consider previewing the source sample with splice_preview_sample first.
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 file_hash or not file_hash.strip():
1521
- return {"ok": False, "error": "file_hash is required"}
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
- file_hash=file_hash.strip(),
1527
- target_key=target_key,
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
- return {"ok": True, "file_hash": file_hash, **(result if isinstance(result, dict) else {"raw": result})}
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
- @mcp.tool()
1539
- async def splice_search_with_sound(
1540
- ctx: Context,
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
- **Status: scaffolding complete, wiring pending real-traffic capture
1551
- (multipart upload shape is the most uncertain part of the bridge).**
1552
- Until `SPLICE_SEARCH_WITH_SOUND_ENDPOINT` is set, returns a structured
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
- audio_path: absolute path to a local audio file (.wav, .mp3, .flac)
1556
- limit: max results (default 20)
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
- bridge, err = _build_http_bridge(ctx)
1559
- if err:
1560
- return err
1561
- from ..splice_client.http_bridge import SpliceHTTPError
1562
- if not audio_path or not os.path.isfile(audio_path):
1563
- return {"ok": False, "error": f"audio_path not found: {audio_path}"}
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
- result = await bridge.search_with_sound(
1566
- audio_path=audio_path, limit=int(limit),
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
- except SpliceHTTPError as exc:
1569
- return exc.to_dict()
1570
- except Exception as exc:
1571
- return {"ok": False, "error": f"search_with_sound failed: {exc}"}
1572
- return {"ok": True, "audio_path": audio_path, **(result if isinstance(result, dict) else {"raw": result})}
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
+ }
@@ -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
- # Public-API future path (what we're asking for in the upstream FR);
382
- # harmless to probe now so that once it ships we can lift the ceiling
383
- # without touching this function again.
384
- ("list_tools", lambda: list(mcp.list_tools())),
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
- return tools
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(self, pack_uuid: str) -> Optional[SplicePack]:
662
- """Fetch metadata for a single sample pack."""
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
- response = await self.stub.SamplePackInfo(
668
- pb2.SamplePackInfoRequest(UUID=pack_uuid),
669
- timeout=INFO_TIMEOUT,
670
- )
671
- p = response.Pack
672
- return SplicePack(
673
- uuid=p.UUID,
674
- name=p.Name,
675
- cover_url=p.CoverURL,
676
- genre=p.Genre,
677
- permalink=p.Permalink,
678
- provider_name=p.ProviderName,
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
- logger.warning(f"SamplePackInfo failed: {exc}")
682
- return None
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