livepilot 1.14.1 → 1.16.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 (33) hide show
  1. package/CHANGELOG.md +176 -1
  2. package/README.md +6 -6
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/mcp_server/__init__.py +1 -1
  5. package/mcp_server/atlas/device_atlas.json +91219 -7161
  6. package/mcp_server/atlas/tools.py +30 -2
  7. package/mcp_server/runtime/live_version.py +4 -2
  8. package/mcp_server/runtime/remote_commands.py +5 -0
  9. package/mcp_server/sample_engine/tools.py +692 -60
  10. package/mcp_server/splice_client/client.py +511 -65
  11. package/mcp_server/splice_client/http_bridge.py +361 -0
  12. package/mcp_server/splice_client/models.py +266 -2
  13. package/mcp_server/splice_client/quota.py +229 -0
  14. package/mcp_server/tools/_analyzer_engine/__init__.py +4 -0
  15. package/mcp_server/tools/_analyzer_engine/sample.py +73 -0
  16. package/mcp_server/tools/analyzer.py +666 -6
  17. package/mcp_server/tools/browser.py +164 -13
  18. package/mcp_server/tools/devices.py +56 -11
  19. package/mcp_server/tools/mixing.py +64 -15
  20. package/mcp_server/tools/scales.py +18 -6
  21. package/mcp_server/tools/tracks.py +92 -4
  22. package/package.json +2 -2
  23. package/remote_script/LivePilot/__init__.py +2 -1
  24. package/remote_script/LivePilot/_clip_helpers.py +86 -0
  25. package/remote_script/LivePilot/_drum_helpers.py +40 -0
  26. package/remote_script/LivePilot/_scale_helpers.py +87 -0
  27. package/remote_script/LivePilot/arrangement.py +44 -15
  28. package/remote_script/LivePilot/clips.py +182 -2
  29. package/remote_script/LivePilot/devices.py +82 -2
  30. package/remote_script/LivePilot/notes.py +17 -2
  31. package/remote_script/LivePilot/scales.py +31 -16
  32. package/remote_script/LivePilot/simpler_sample.py +186 -0
  33. package/server.json +3 -3
@@ -203,11 +203,14 @@ async def search_samples(
203
203
  bpm_range: Optional[str] = None,
204
204
  source: Optional[str] = None,
205
205
  max_results: int = 10,
206
+ free_only: bool = False,
207
+ q: Optional[str] = None,
206
208
  ) -> dict:
207
209
  """Search for samples across Splice library, Ableton browser, and local filesystem.
208
210
 
209
211
  Searches all enabled sources in parallel and ranks results.
210
- Splice results include rich metadata (key, BPM, genre, tags, pack info).
212
+ Splice results include rich metadata (key, BPM, genre, tags, pack info,
213
+ is_premium, price, is_free, preview_url).
211
214
 
212
215
  When the Splice desktop app is running AND grpcio is installed, this
213
216
  searches Splice's ONLINE catalog (19,690+ hits for a generic query)
@@ -216,12 +219,22 @@ async def search_samples(
216
219
  already-downloaded samples.
217
220
 
218
221
  query: search text like "dark vocal", "breakbeat", "foley metal"
222
+ q: alias for `query` (accepts either name for ergonomics)
219
223
  material_type: filter by type (vocal, drum_loop, texture, etc.)
220
224
  key: prefer samples in this key (e.g., "Cm", "F#")
221
225
  bpm_range: "min-max" BPM range (e.g., "120-130")
222
226
  source: "splice", "browser", "filesystem", or None for all
223
227
  max_results: maximum results to return (default 10)
228
+ free_only: if True, only return samples that cost nothing to license
229
+ (IsPremium=False or Price=0). Under the Ableton Live plan these
230
+ don't deplete the daily quota; under credit-metered plans they
231
+ bypass the credit floor.
224
232
  """
233
+ # Accept `q` as an alias for `query` — BUG-FIX #4 from 2026-04-22 bug doc.
234
+ if not query and q:
235
+ query = q
236
+ if not query:
237
+ return {"error": "query is required (or use `q` alias)"}
225
238
  results: list[dict] = []
226
239
 
227
240
  # Parse BPM range
@@ -256,6 +269,8 @@ async def search_samples(
256
269
  purchased_only=False,
257
270
  )
258
271
  for s in grpc_result.samples[:max_results]:
272
+ if free_only and not s.is_free:
273
+ continue
259
274
  results.append({
260
275
  "source": "splice",
261
276
  "name": s.filename,
@@ -267,6 +282,8 @@ async def search_samples(
267
282
  "splice_catalog": True,
268
283
  "downloaded": bool(s.local_path),
269
284
  "file_hash": s.file_hash,
285
+ "preview_url": s.preview_url,
286
+ "is_free": s.is_free,
270
287
  "metadata": {
271
288
  "key": s.audio_key,
272
289
  "bpm": s.bpm,
@@ -278,6 +295,8 @@ async def search_samples(
278
295
  "pack_uuid": s.pack_uuid,
279
296
  "duration": s.duration_ms / 1000.0 if s.duration_ms else 0.0,
280
297
  "is_premium": s.is_premium,
298
+ "price": s.price,
299
+ "is_free": s.is_free,
281
300
  "chord_type": s.chord_type,
282
301
  },
283
302
  })
@@ -658,37 +677,63 @@ def plan_slice_workflow(
658
677
  # Support/com.splice.Splice/)
659
678
  # - grpcio and protobuf installed (added to requirements.txt in v1.10.5)
660
679
  #
661
- # Credit model (as of 2026-04-14):
662
- # - Even with `SoundsStatus: subscribed`, the gRPC `DownloadSample` endpoint
663
- # always decrements a monthly credit counter (default 100/month on most
664
- # subscription plans).
665
- # - The "unlimited downloads in Ableton" the Splice marketing references
666
- # only applies to the Splice Sounds.vst3 plugin, which uses a different
667
- # HTTPS API that these tools cannot drive.
668
- # - `CREDIT_HARD_FLOOR = 5` in client.py reserves 5 credits as a safety
669
- # margindownloads will refuse below the floor.
680
+ # Credit model (corrected 2026-04-22 — see project_splice_subscription_model.md):
681
+ # Splice has a TWO-POCKET model that our earlier code conflated:
682
+ #
683
+ # 1. Daily sample quota — 100/day unmetered on the Splice x Ableton Live plan
684
+ # ($12.99/mo). Sample downloads deplete this counter, NOT credits.
685
+ # Resets at UTC midnight. We track locally in ~/.livepilot/splice_quota.json
686
+ # (see splice_client/quota.py) and warn at 90/100.
687
+ #
688
+ # 2. Splice.com credits used for presets, MIDI, Splice Instrument content.
689
+ # ALL plans have some credits (100 intro on Ableton Live, or monthly
690
+ # allotment on Creator/Sounds+). CREDIT_HARD_FLOOR = 5 keeps a safety
691
+ # reserve so agents can't drain you to zero.
692
+ #
693
+ # Free samples (Sample.IsPremium=False or Price=0) bypass BOTH gates — they're
694
+ # free under any plan.
695
+ #
696
+ # `SpliceGRPCClient.decide_download()` runs the full gating logic and returns
697
+ # a DownloadDecision with plan_kind and gating_mode. Use that, not the raw
698
+ # credit check, for any new download path.
670
699
 
671
700
 
672
701
  _SPLICE_USER_LIB_DEST = "~/Music/Ableton/User Library/Samples/Splice"
702
+ _SPLICE_PREVIEW_CACHE = "~/Library/Caches/LivePilot/splice_previews"
673
703
 
674
704
 
675
705
  @mcp.tool()
676
706
  async def get_splice_credits(ctx: Context) -> dict:
677
- """Get the user's current Splice credit balance and subscription tier.
678
-
679
- Returns: {
680
- "connected": bool, # whether Splice desktop gRPC is reachable
681
- "username": str,
682
- "plan": str, # e.g. "subscribed", "free"
683
- "credits_remaining": int,
684
- "credit_floor": int, # safety reserve (typically 5)
685
- "can_download": bool, # credits_remaining > credit_floor
686
- }
707
+ """Get the user's current Splice plan, credits, and daily sample quota.
708
+
709
+ Returns both pockets of the Splice subscription model:
710
+ - `credits_remaining`: Splice.com credits for presets/MIDI/Instrument
711
+ - `daily_quota`: sample-download counter (Ableton Live plan only)
712
+
713
+ Example (Ableton Live plan):
714
+ {
715
+ "connected": true,
716
+ "username": "user-1367453956",
717
+ "plan_raw": "subscribed",
718
+ "plan_kind": "ableton_live",
719
+ "sounds_plan_id": 12,
720
+ "features": {"ableton_unmetered": true, ...},
721
+ "credits_remaining": 80,
722
+ "credit_floor": 5,
723
+ "daily_quota": {
724
+ "used_today": 3, "remaining_today": 97, "daily_limit": 100,
725
+ "near_limit": false, "at_limit": false,
726
+ },
727
+ "can_download_sample": true,
728
+ "download_gating": "daily_quota", # or "credit_floor"
729
+ }
687
730
 
688
731
  Returns connected=False (with zero credits) when the Splice desktop app
689
732
  isn't running or grpcio isn't installed.
690
733
  """
691
734
  from ..splice_client.client import CREDIT_HARD_FLOOR
735
+ from ..splice_client.models import PlanKind
736
+ from ..splice_client.quota import get_tracker
692
737
 
693
738
  client = None
694
739
  try:
@@ -696,14 +741,19 @@ async def get_splice_credits(ctx: Context) -> dict:
696
741
  except AttributeError:
697
742
  pass
698
743
 
744
+ quota_summary = get_tracker().summary()
745
+
699
746
  if client is None or not getattr(client, "connected", False):
700
747
  return {
701
748
  "connected": False,
702
749
  "username": "",
703
- "plan": "",
750
+ "plan_raw": "",
751
+ "plan_kind": PlanKind.UNKNOWN.value,
704
752
  "credits_remaining": 0,
705
753
  "credit_floor": CREDIT_HARD_FLOOR,
706
- "can_download": False,
754
+ "daily_quota": quota_summary,
755
+ "can_download_sample": False,
756
+ "download_gating": "blocked",
707
757
  "hint": (
708
758
  "Splice gRPC not connected. Ensure Splice desktop app is "
709
759
  "running and grpcio+protobuf are installed in the LivePilot "
@@ -718,16 +768,39 @@ async def get_splice_credits(ctx: Context) -> dict:
718
768
  "connected": False,
719
769
  "error": f"get_credits failed: {exc}",
720
770
  "credit_floor": CREDIT_HARD_FLOOR,
771
+ "daily_quota": quota_summary,
721
772
  }
722
773
 
723
774
  remaining = int(info.credits)
775
+ plan = info.plan_kind
776
+
777
+ # Compute `can_download_sample` using the same logic decide_download uses.
778
+ if plan == PlanKind.ABLETON_LIVE:
779
+ gating = "daily_quota"
780
+ can_download = not quota_summary["at_limit"]
781
+ else:
782
+ gating = "credit_floor"
783
+ can_download = remaining > CREDIT_HARD_FLOOR
784
+
724
785
  return {
725
786
  "connected": True,
726
787
  "username": info.username,
727
- "plan": info.plan,
788
+ "plan_raw": info.plan,
789
+ "plan_kind": plan.value,
790
+ "sounds_plan_id": info.sounds_plan_id,
791
+ "features": info.features,
792
+ "user_uuid": info.user_uuid,
728
793
  "credits_remaining": remaining,
729
794
  "credit_floor": CREDIT_HARD_FLOOR,
730
- "can_download": remaining > CREDIT_HARD_FLOOR,
795
+ "daily_quota": quota_summary,
796
+ "can_download_sample": can_download,
797
+ "download_gating": gating,
798
+ "note": (
799
+ "This plan gets 100 samples/day unmetered via drag-drop; "
800
+ "the 80 credits are for presets/MIDI only."
801
+ if plan == PlanKind.ABLETON_LIVE
802
+ else None
803
+ ),
731
804
  }
732
805
 
733
806
 
@@ -742,6 +815,8 @@ async def splice_catalog_hunt(
742
815
  genre: str = "",
743
816
  per_page: int = 10,
744
817
  page: int = 1,
818
+ free_only: bool = False,
819
+ collection_uuid: str = "",
745
820
  ) -> dict:
746
821
  """Search Splice's ONLINE catalog via gRPC.
747
822
 
@@ -798,6 +873,7 @@ async def splice_catalog_hunt(
798
873
  per_page=max(1, min(per_page, 50)),
799
874
  page=max(1, int(page)),
800
875
  purchased_only=False,
876
+ collection_uuid=collection_uuid,
801
877
  )
802
878
  except Exception as exc:
803
879
  return {
@@ -808,6 +884,8 @@ async def splice_catalog_hunt(
808
884
 
809
885
  samples_out = []
810
886
  for s in result.samples:
887
+ if free_only and not s.is_free:
888
+ continue
811
889
  samples_out.append({
812
890
  "file_hash": s.file_hash,
813
891
  "filename": s.filename,
@@ -821,6 +899,8 @@ async def splice_catalog_hunt(
821
899
  "pack": s.provider_name,
822
900
  "pack_uuid": s.pack_uuid,
823
901
  "is_premium": bool(s.is_premium),
902
+ "price": int(s.price),
903
+ "is_free": s.is_free,
824
904
  "is_downloaded": bool(s.local_path),
825
905
  "local_path": s.local_path or None,
826
906
  "preview_url": s.preview_url,
@@ -841,31 +921,38 @@ async def splice_download_sample(
841
921
  ctx: Context,
842
922
  file_hash: str,
843
923
  copy_to_user_library: bool = True,
924
+ force: bool = False,
844
925
  ) -> dict:
845
- """Download a Splice sample by file_hash (costs 1 credit).
846
-
847
- Use `splice_catalog_hunt` first to find samples and get their file_hash.
848
- This tool will:
849
- 1. Check credit balance against the safety floor (refuses if < 5)
850
- 2. Trigger the download via the Splice desktop gRPC
851
- 3. Poll until the file appears on disk (up to 30s)
852
- 4. Optionally copy the file into `~/Music/Ableton/User Library/Samples/
853
- Splice/` so Ableton's browser indexes it — this makes the sample
854
- loadable via `load_browser_item` with a `query:UserLibrary#Samples:...`
855
- URI.
926
+ """Download a Splice sample by file_hash plan-aware gating.
927
+
928
+ Use `splice_catalog_hunt` or `search_samples` first to find samples
929
+ and their `file_hash`. The gating logic runs BEFORE any network call:
930
+
931
+ - Ableton Live plan: uses your 100/day unmetered quota (not credits).
932
+ Tracked locally in ~/.livepilot/splice_quota.json so repeated runs
933
+ warn at 90/100 and refuse at 100 (resets at UTC midnight).
934
+ - Credit-metered plans (Sounds+/Creator): enforces CREDIT_HARD_FLOOR=5
935
+ so the agent can't drain your monthly allotment.
936
+ - Free samples (IsPremium=False or Price=0): bypass both gates.
937
+
938
+ Arguments:
939
+ file_hash: the sample identifier from search results
940
+ copy_to_user_library: if True (default), also copies to
941
+ ~/Music/Ableton/User Library/Samples/Splice/ so Ableton's browser
942
+ can reach it via `load_browser_item` with a `query:UserLibrary#...`
943
+ URI.
944
+ force: bypass local quota checks (still honors server-side limits).
945
+ Use for deterministic tests — NOT for production flows.
856
946
 
857
947
  Returns: {
858
- "ok": bool,
859
- "local_path": str, # Splice's own download path
860
- "user_library_path": str, # if copy_to_user_library=True
861
- "browser_uri": str, # ready for load_browser_item
862
- "credits_remaining": int,
948
+ "ok": bool,
949
+ "local_path": str, # Splice's own download path
950
+ "user_library_path": str, # if copy_to_user_library=True
951
+ "browser_uri": str, # ready for load_browser_item
952
+ "decision": {...}, # plan-aware gating summary
953
+ "credits_remaining": int,
954
+ "daily_quota": {...}, # post-download quota snapshot
863
955
  }
864
-
865
- Note: even with an "unlimited" subscription, this gRPC path always
866
- decrements credits (typically 100/month allotment). The unlimited
867
- downloads inside Ableton's Splice Sounds VST3 use a different API
868
- that LivePilot cannot drive programmatically yet.
869
956
  """
870
957
  import shutil
871
958
 
@@ -881,24 +968,35 @@ async def splice_download_sample(
881
968
  "error": "Splice gRPC not connected",
882
969
  }
883
970
 
884
- # Credit safety check
971
+ # Try to fetch the sample metadata so we can detect free samples and
972
+ # bypass gating when Price=0. This is one extra round-trip but saves
973
+ # credits/quota for catalog items marked free.
974
+ sample = None
885
975
  try:
886
- can, remaining = await client.can_afford(1, budget=10)
976
+ sample = await client.get_sample_info(file_hash)
887
977
  except Exception as exc:
888
- return {"ok": False, "error": f"Credit check failed: {exc}"}
889
- if not can:
890
- return {
891
- "ok": False,
892
- "error": (
893
- f"Credit safety floor hit (remaining={remaining}, "
894
- f"hard floor=5). Skipping download."
895
- ),
896
- "credits_remaining": remaining,
897
- }
978
+ logger.debug("get_sample_info failed pre-gating: %s", exc)
898
979
 
899
- # Trigger download
980
+ # Run the full gating logic before touching the network.
981
+ if not force:
982
+ try:
983
+ decision = await client.decide_download(file_hash, sample=sample)
984
+ except Exception as exc:
985
+ return {"ok": False, "error": f"Gating check failed: {exc}"}
986
+ if not decision.allowed:
987
+ return {
988
+ "ok": False,
989
+ "error": decision.reason,
990
+ "decision": decision.to_dict(),
991
+ }
992
+ else:
993
+ decision = None
994
+
995
+ # Trigger download (client.download_sample also re-runs the gate defensively)
900
996
  try:
901
- local_path = await client.download_sample(file_hash, timeout=30.0)
997
+ local_path = await client.download_sample(
998
+ file_hash, timeout=30.0, sample=sample,
999
+ )
902
1000
  except Exception as exc:
903
1001
  return {"ok": False, "error": f"Download failed: {exc}"}
904
1002
 
@@ -912,6 +1010,7 @@ async def splice_download_sample(
912
1010
  "ok": True,
913
1011
  "local_path": local_path,
914
1012
  "filename": os.path.basename(local_path),
1013
+ "decision": decision.to_dict() if decision else None,
915
1014
  }
916
1015
 
917
1016
  # Copy into User Library so Ableton's browser indexes it
@@ -930,7 +1029,12 @@ async def splice_download_sample(
930
1029
  except Exception as exc:
931
1030
  response["copy_warning"] = f"Failed to copy to User Library: {exc}"
932
1031
 
933
- # Post-credit count
1032
+ # Post-download state
1033
+ try:
1034
+ from ..splice_client.quota import get_tracker
1035
+ response["daily_quota"] = get_tracker().summary()
1036
+ except Exception as exc:
1037
+ logger.debug("post-download quota snapshot failed: %s", exc)
934
1038
  try:
935
1039
  info = await client.get_credits()
936
1040
  response["credits_remaining"] = int(info.credits)
@@ -938,3 +1042,531 @@ async def splice_download_sample(
938
1042
  logger.warning("post-download credit check failed: %s", exc)
939
1043
 
940
1044
  return response
1045
+
1046
+
1047
+ # ────────────────────────────────────────────────────────────────────────
1048
+ # Zero-cost preview — fetches Sample.PreviewURL which is always free.
1049
+ # ────────────────────────────────────────────────────────────────────────
1050
+
1051
+
1052
+ @mcp.tool()
1053
+ async def splice_preview_sample(
1054
+ ctx: Context,
1055
+ file_hash: str,
1056
+ cache: bool = True,
1057
+ ) -> dict:
1058
+ """Fetch a Splice sample's preview audio — ZERO credits, ZERO quota cost.
1059
+
1060
+ Every catalog sample has a `PreviewURL` (low-bitrate MP3) that Splice
1061
+ streams freely. Use this to audition before calling
1062
+ `splice_download_sample`. Perfect for:
1063
+ - Quickly hearing 10 candidates before committing to one download
1064
+ - Staying under the daily sample quota on the Ableton Live plan
1065
+ - Letting agents judge fit without spending anything
1066
+
1067
+ Arguments:
1068
+ file_hash: the sample identifier from search results
1069
+ cache: if True (default), write the preview to
1070
+ ~/Library/Caches/LivePilot/splice_previews/ for Ableton to load
1071
+
1072
+ Returns: {
1073
+ "ok": bool,
1074
+ "preview_url": str,
1075
+ "local_preview_path": str, # if cache=True and download succeeded
1076
+ "filename": str,
1077
+ "duration_sec": float,
1078
+ "cost": "free", # always, for every plan
1079
+ }
1080
+ """
1081
+ import hashlib
1082
+ import urllib.request
1083
+ import urllib.error
1084
+
1085
+ client = None
1086
+ try:
1087
+ client = ctx.lifespan_context.get("splice_client")
1088
+ except AttributeError:
1089
+ pass
1090
+
1091
+ if client is None or not getattr(client, "connected", False):
1092
+ return {"ok": False, "error": "Splice gRPC not connected"}
1093
+
1094
+ sample = None
1095
+ try:
1096
+ sample = await client.get_sample_info(file_hash)
1097
+ except Exception as exc:
1098
+ return {"ok": False, "error": f"SampleInfo failed: {exc}"}
1099
+
1100
+ if sample is None or not sample.preview_url:
1101
+ return {
1102
+ "ok": False,
1103
+ "error": "No preview URL available for this sample",
1104
+ "file_hash": file_hash,
1105
+ }
1106
+
1107
+ response: dict = {
1108
+ "ok": True,
1109
+ "preview_url": sample.preview_url,
1110
+ "filename": sample.filename,
1111
+ "duration_sec": round(sample.duration_seconds, 2),
1112
+ "cost": "free",
1113
+ "file_hash": file_hash,
1114
+ "is_free_sample": sample.is_free,
1115
+ "key": sample.key_display,
1116
+ "bpm": sample.bpm,
1117
+ "tags": sample.tags,
1118
+ }
1119
+
1120
+ if cache:
1121
+ cache_dir = os.path.expanduser(_SPLICE_PREVIEW_CACHE)
1122
+ try:
1123
+ os.makedirs(cache_dir, exist_ok=True)
1124
+ # Short deterministic filename based on file_hash
1125
+ digest = hashlib.md5(file_hash.encode()).hexdigest()[:12]
1126
+ ext = os.path.splitext(sample.preview_url.split("?")[0])[1] or ".mp3"
1127
+ dest = os.path.join(cache_dir, f"preview_{digest}{ext}")
1128
+ if not os.path.isfile(dest):
1129
+ def _download():
1130
+ req = urllib.request.Request(
1131
+ sample.preview_url,
1132
+ headers={"User-Agent": "LivePilot/1.0"},
1133
+ )
1134
+ with urllib.request.urlopen(req, timeout=15) as r:
1135
+ data = r.read()
1136
+ with open(dest, "wb") as f:
1137
+ f.write(data)
1138
+ return dest
1139
+ # Run sync urllib in a thread to avoid blocking the event loop
1140
+ import asyncio as _aio
1141
+ await _aio.get_running_loop().run_in_executor(None, _download)
1142
+ response["local_preview_path"] = dest
1143
+ except (urllib.error.URLError, OSError, ValueError) as exc:
1144
+ response["cache_warning"] = f"Preview cache write failed: {exc}"
1145
+
1146
+ return response
1147
+
1148
+
1149
+ # ────────────────────────────────────────────────────────────────────────
1150
+ # Collections — user's personal sample organization (Likes, bass, keys, …).
1151
+ # These call the gRPC Collection* RPCs already wrapped in AppStub.
1152
+ # ────────────────────────────────────────────────────────────────────────
1153
+
1154
+
1155
+ def _require_splice_client(ctx: Context) -> tuple[object, Optional[dict]]:
1156
+ """Fetch the Splice client from context, or return an error dict."""
1157
+ client = None
1158
+ try:
1159
+ client = ctx.lifespan_context.get("splice_client")
1160
+ except AttributeError:
1161
+ pass
1162
+ if client is None or not getattr(client, "connected", False):
1163
+ return None, {"ok": False, "error": "Splice gRPC not connected"}
1164
+ return client, None
1165
+
1166
+
1167
+ @mcp.tool()
1168
+ async def splice_list_collections(
1169
+ ctx: Context, page: int = 1, per_page: int = 50,
1170
+ ) -> dict:
1171
+ """List the user's Splice Collections (Likes, custom folders, Daily Picks…).
1172
+
1173
+ Collections are user-curated sample/preset/pack bookmarks. They are
1174
+ the strongest available taste signal: each one represents the user's
1175
+ deliberate grouping. Use `splice_search_in_collection` to scope a
1176
+ search to one collection's samples — better than keyword-only search.
1177
+
1178
+ Returns: {
1179
+ "ok": true,
1180
+ "total_count": int,
1181
+ "collections": [
1182
+ {"uuid": "...", "name": "Likes", "sample_count": 47, ...},
1183
+ ],
1184
+ }
1185
+ """
1186
+ client, err = _require_splice_client(ctx)
1187
+ if err:
1188
+ return err
1189
+ total, collections = await client.list_collections(
1190
+ page=max(1, int(page)), per_page=max(1, min(int(per_page), 100)),
1191
+ )
1192
+ return {
1193
+ "ok": True,
1194
+ "total_count": total,
1195
+ "returned": len(collections),
1196
+ "page": page,
1197
+ "collections": [c.to_dict() for c in collections],
1198
+ }
1199
+
1200
+
1201
+ @mcp.tool()
1202
+ async def splice_search_in_collection(
1203
+ ctx: Context,
1204
+ collection_uuid: str,
1205
+ page: int = 1,
1206
+ per_page: int = 50,
1207
+ ) -> dict:
1208
+ """List samples inside a Splice Collection by UUID.
1209
+
1210
+ Get the UUID from `splice_list_collections`. The returned samples
1211
+ carry full metadata (key, BPM, is_free, preview_url) identical to
1212
+ `splice_catalog_hunt` — you can feed them straight into
1213
+ `splice_preview_sample` or `splice_download_sample`.
1214
+ """
1215
+ client, err = _require_splice_client(ctx)
1216
+ if err:
1217
+ return err
1218
+ total, samples = await client.collection_samples(
1219
+ uuid=collection_uuid,
1220
+ page=max(1, int(page)),
1221
+ per_page=max(1, min(int(per_page), 100)),
1222
+ )
1223
+ return {
1224
+ "ok": True,
1225
+ "collection_uuid": collection_uuid,
1226
+ "total_hits": total,
1227
+ "returned": len(samples),
1228
+ "samples": [s.to_dict() for s in samples],
1229
+ }
1230
+
1231
+
1232
+ @mcp.tool()
1233
+ async def splice_add_to_collection(
1234
+ ctx: Context, collection_uuid: str, file_hashes: list[str],
1235
+ ) -> dict:
1236
+ """Add one or more samples to a user Collection.
1237
+
1238
+ Persists server-side — the change appears in the Splice desktop app
1239
+ and web UI immediately. Use this to let LivePilot "save for later"
1240
+ items it finds during composition work.
1241
+ """
1242
+ client, err = _require_splice_client(ctx)
1243
+ if err:
1244
+ return err
1245
+ if not file_hashes:
1246
+ return {"ok": False, "error": "file_hashes must be a non-empty list"}
1247
+ success = await client.add_to_collection(collection_uuid, list(file_hashes))
1248
+ return {
1249
+ "ok": success,
1250
+ "collection_uuid": collection_uuid,
1251
+ "added_count": len(file_hashes) if success else 0,
1252
+ }
1253
+
1254
+
1255
+ @mcp.tool()
1256
+ async def splice_remove_from_collection(
1257
+ ctx: Context, collection_uuid: str, file_hashes: list[str],
1258
+ ) -> dict:
1259
+ """Remove one or more samples from a user Collection (server-side)."""
1260
+ client, err = _require_splice_client(ctx)
1261
+ if err:
1262
+ return err
1263
+ if not file_hashes:
1264
+ return {"ok": False, "error": "file_hashes must be a non-empty list"}
1265
+ success = await client.remove_from_collection(
1266
+ collection_uuid, list(file_hashes),
1267
+ )
1268
+ return {
1269
+ "ok": success,
1270
+ "collection_uuid": collection_uuid,
1271
+ "removed_count": len(file_hashes) if success else 0,
1272
+ }
1273
+
1274
+
1275
+ @mcp.tool()
1276
+ async def splice_create_collection(ctx: Context, name: str) -> dict:
1277
+ """Create a new user Collection. Returns the new UUID on success."""
1278
+ client, err = _require_splice_client(ctx)
1279
+ if err:
1280
+ return err
1281
+ name = (name or "").strip()
1282
+ if not name:
1283
+ return {"ok": False, "error": "Collection name cannot be empty"}
1284
+ collection = await client.create_collection(name)
1285
+ if collection is None:
1286
+ return {"ok": False, "error": "Collection create returned no result"}
1287
+ return {"ok": True, "collection": collection.to_dict()}
1288
+
1289
+
1290
+ # ────────────────────────────────────────────────────────────────────────
1291
+ # Presets — Splice Instrument / VST presets the user has purchased.
1292
+ # ────────────────────────────────────────────────────────────────────────
1293
+
1294
+
1295
+ @mcp.tool()
1296
+ async def splice_list_presets(
1297
+ ctx: Context,
1298
+ page: int = 1,
1299
+ per_page: int = 50,
1300
+ sort: str = "",
1301
+ sort_order: str = "",
1302
+ ) -> dict:
1303
+ """List presets the user has purchased from Splice.
1304
+
1305
+ Covers Splice Instrument and Rent-to-Own plugin presets. Each entry
1306
+ includes `plugin_name` so the agent can route loading to the right
1307
+ plugin — e.g., a Serum preset vs. a Splice Instrument preset.
1308
+
1309
+ Returns: {
1310
+ "ok": true,
1311
+ "total_hits": int,
1312
+ "presets": [
1313
+ {"uuid": "...", "filename": "Deep House Pluck.fxp",
1314
+ "plugin_name": "Serum", "local_path": "...", ...},
1315
+ ],
1316
+ }
1317
+ """
1318
+ client, err = _require_splice_client(ctx)
1319
+ if err:
1320
+ return err
1321
+ total, presets = await client.list_purchased_presets(
1322
+ page=max(1, int(page)),
1323
+ per_page=max(1, min(int(per_page), 100)),
1324
+ sort=sort, sort_order=sort_order,
1325
+ )
1326
+ return {
1327
+ "ok": True,
1328
+ "total_hits": total,
1329
+ "returned": len(presets),
1330
+ "presets": [p.to_dict() for p in presets],
1331
+ }
1332
+
1333
+
1334
+ @mcp.tool()
1335
+ async def splice_preset_info(
1336
+ ctx: Context,
1337
+ uuid: str = "",
1338
+ file_hash: str = "",
1339
+ plugin_name: str = "",
1340
+ ) -> dict:
1341
+ """Fetch metadata for a single preset (uuid, file_hash, or plugin_name)."""
1342
+ client, err = _require_splice_client(ctx)
1343
+ if err:
1344
+ return err
1345
+ if not (uuid or file_hash or plugin_name):
1346
+ return {"ok": False, "error": "Provide at least one of uuid, file_hash, plugin_name"}
1347
+ info = await client.get_preset_info(
1348
+ uuid=uuid, file_hash=file_hash, plugin_name=plugin_name,
1349
+ )
1350
+ if info is None:
1351
+ return {"ok": False, "error": "Preset not found"}
1352
+ return {"ok": True, **info}
1353
+
1354
+
1355
+ @mcp.tool()
1356
+ async def splice_download_preset(ctx: Context, uuid: str) -> dict:
1357
+ """Trigger a preset download (uses Splice.com credits, not the sample quota).
1358
+
1359
+ Splice credits ARE used for presets under every plan — this is the
1360
+ "second pocket" of the subscription model. We still honor
1361
+ CREDIT_HARD_FLOOR=5 so the agent can't drain the monthly allotment.
1362
+ """
1363
+ from ..splice_client.client import CREDIT_HARD_FLOOR
1364
+
1365
+ client, err = _require_splice_client(ctx)
1366
+ if err:
1367
+ return err
1368
+ if not uuid:
1369
+ return {"ok": False, "error": "uuid is required"}
1370
+
1371
+ try:
1372
+ can, remaining = await client.can_afford(1, budget=1)
1373
+ except Exception as exc:
1374
+ return {"ok": False, "error": f"Credit check failed: {exc}"}
1375
+ if not can:
1376
+ return {
1377
+ "ok": False,
1378
+ "error": (
1379
+ f"Credit floor hit (remaining={remaining}, "
1380
+ f"floor={CREDIT_HARD_FLOOR}). Preset download refused."
1381
+ ),
1382
+ "credits_remaining": remaining,
1383
+ }
1384
+
1385
+ success = await client.download_preset(uuid)
1386
+ result: dict = {"ok": success, "uuid": uuid}
1387
+ try:
1388
+ info = await client.get_credits()
1389
+ result["credits_remaining"] = int(info.credits)
1390
+ except Exception as exc:
1391
+ logger.debug("post-preset-download credit check failed: %s", exc)
1392
+ return result
1393
+
1394
+
1395
+ # ────────────────────────────────────────────────────────────────────────
1396
+ # Sample packs — pack metadata (rich descriptions, genre, cover art, etc.).
1397
+ # ────────────────────────────────────────────────────────────────────────
1398
+
1399
+
1400
+ @mcp.tool()
1401
+ async def splice_pack_info(ctx: Context, pack_uuid: str) -> dict:
1402
+ """Fetch full metadata for a Splice sample pack by UUID.
1403
+
1404
+ Pack UUIDs come from search results (each sample carries `pack_uuid`).
1405
+ Useful for discovering related samples by pack, or surfacing pack-level
1406
+ genre/provider info that search results omit.
1407
+ """
1408
+ client, err = _require_splice_client(ctx)
1409
+ if err:
1410
+ return err
1411
+ if not pack_uuid:
1412
+ return {"ok": False, "error": "pack_uuid is required"}
1413
+ pack = await client.get_pack_info(pack_uuid)
1414
+ if pack is None:
1415
+ return {"ok": False, "error": "Pack not found or gRPC call failed"}
1416
+ return {"ok": True, "pack": pack.to_dict()}
1417
+
1418
+
1419
+ # ────────────────────────────────────────────────────────────────────────
1420
+ # HTTPS bridge — Describe a Sound / Variations (plugin-exclusive features).
1421
+ # These hit api.splice.com over HTTPS with the session token from gRPC.
1422
+ # Scaffolding ships today; real endpoints wire in once captured.
1423
+ # ────────────────────────────────────────────────────────────────────────
1424
+
1425
+
1426
+ def _build_http_bridge(ctx: Context):
1427
+ """Construct the HTTPS bridge with the current gRPC client attached.
1428
+
1429
+ Returns (bridge, err_dict). On success err_dict is None.
1430
+ """
1431
+ from ..splice_client.http_bridge import SpliceHTTPBridge
1432
+
1433
+ client = None
1434
+ try:
1435
+ client = ctx.lifespan_context.get("splice_client")
1436
+ except AttributeError:
1437
+ pass
1438
+ if client is None or not getattr(client, "connected", False):
1439
+ return None, {
1440
+ "ok": False,
1441
+ "error": "Splice gRPC not connected — session token unreachable",
1442
+ }
1443
+ return SpliceHTTPBridge(grpc_client=client), None
1444
+
1445
+
1446
+ @mcp.tool()
1447
+ async def splice_describe_sound(
1448
+ ctx: Context,
1449
+ description: str,
1450
+ bpm: Optional[int] = None,
1451
+ key: Optional[str] = None,
1452
+ limit: int = 20,
1453
+ ) -> dict:
1454
+ """Natural-language sample search — the Sounds Plugin's "Describe a Sound".
1455
+
1456
+ 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.
1460
+
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.
1465
+
1466
+ description: free-text prompt ("warm analog bass under 80bpm")
1467
+ bpm: optional BPM filter
1468
+ key: optional musical key ("Dm", "F#")
1469
+ limit: max results (default 20)
1470
+ """
1471
+ bridge, err = _build_http_bridge(ctx)
1472
+ if err:
1473
+ return err
1474
+ from ..splice_client.http_bridge import SpliceHTTPError
1475
+ if not description or not description.strip():
1476
+ return {"ok": False, "error": "description is required"}
1477
+ try:
1478
+ result = await bridge.describe_sound(
1479
+ description=description.strip(),
1480
+ bpm=bpm, key=key, limit=int(limit),
1481
+ )
1482
+ except SpliceHTTPError as exc:
1483
+ return exc.to_dict()
1484
+ except Exception as exc:
1485
+ return {"ok": False, "error": f"describe_sound failed: {exc}"}
1486
+ return {"ok": True, "query": description, **(result if isinstance(result, dict) else {"raw": result})}
1487
+
1488
+
1489
+ @mcp.tool()
1490
+ async def splice_generate_variation(
1491
+ ctx: Context,
1492
+ file_hash: str,
1493
+ target_key: Optional[str] = None,
1494
+ target_bpm: Optional[int] = None,
1495
+ count: int = 1,
1496
+ ) -> 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.
1515
+ """
1516
+ bridge, err = _build_http_bridge(ctx)
1517
+ if err:
1518
+ return err
1519
+ 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"}
1524
+ try:
1525
+ 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),
1530
+ )
1531
+ except SpliceHTTPError as exc:
1532
+ return exc.to_dict()
1533
+ except Exception as exc:
1534
+ 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})}
1536
+
1537
+
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".
1545
+
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
+
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.
1554
+
1555
+ audio_path: absolute path to a local audio file (.wav, .mp3, .flac)
1556
+ limit: max results (default 20)
1557
+ """
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}"}
1564
+ try:
1565
+ result = await bridge.search_with_sound(
1566
+ audio_path=audio_path, limit=int(limit),
1567
+ )
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})}