livepilot 1.15.0-beta → 1.16.1

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 (70) hide show
  1. package/CHANGELOG.md +206 -3
  2. package/README.md +11 -11
  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/device_atlas.json +91219 -7161
  7. package/mcp_server/atlas/enrichments/audio_effects/pitch_hack.yaml +61 -0
  8. package/mcp_server/atlas/enrichments/audio_effects/pitchloop89.yaml +111 -0
  9. package/mcp_server/atlas/enrichments/audio_effects/re_enveloper.yaml +51 -0
  10. package/mcp_server/atlas/enrichments/audio_effects/snipper.yaml +36 -0
  11. package/mcp_server/atlas/enrichments/audio_effects/spectral_blur.yaml +64 -0
  12. package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +37 -0
  13. package/mcp_server/atlas/enrichments/instruments/granulator_iii.yaml +124 -0
  14. package/mcp_server/atlas/enrichments/instruments/harmonic_drone_generator.yaml +83 -0
  15. package/mcp_server/atlas/enrichments/instruments/impulse.yaml +47 -0
  16. package/mcp_server/atlas/enrichments/instruments/sting_iftah.yaml +44 -0
  17. package/mcp_server/atlas/enrichments/midi_effects/expressive_chords.yaml +38 -0
  18. package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +32 -0
  19. package/mcp_server/atlas/enrichments/midi_effects/microtuner.yaml +83 -0
  20. package/mcp_server/atlas/enrichments/midi_effects/patterns_iftah.yaml +38 -0
  21. package/mcp_server/atlas/enrichments/midi_effects/phase_pattern.yaml +51 -0
  22. package/mcp_server/atlas/enrichments/midi_effects/polyrhythm.yaml +46 -0
  23. package/mcp_server/atlas/enrichments/midi_effects/retrigger.yaml +40 -0
  24. package/mcp_server/atlas/enrichments/midi_effects/slice_shuffler.yaml +39 -0
  25. package/mcp_server/atlas/enrichments/midi_effects/sq_sequencer.yaml +39 -0
  26. package/mcp_server/atlas/enrichments/midi_effects/stages.yaml +38 -0
  27. package/mcp_server/atlas/enrichments/utility/arrangement_looper.yaml +31 -0
  28. package/mcp_server/atlas/enrichments/utility/cv_clock_in.yaml +25 -0
  29. package/mcp_server/atlas/enrichments/utility/cv_clock_out.yaml +25 -0
  30. package/mcp_server/atlas/enrichments/utility/cv_envelope_follower.yaml +38 -0
  31. package/mcp_server/atlas/enrichments/utility/cv_in.yaml +26 -0
  32. package/mcp_server/atlas/enrichments/utility/cv_instrument.yaml +34 -0
  33. package/mcp_server/atlas/enrichments/utility/cv_lfo.yaml +38 -0
  34. package/mcp_server/atlas/enrichments/utility/cv_shaper.yaml +35 -0
  35. package/mcp_server/atlas/enrichments/utility/cv_triggers.yaml +26 -0
  36. package/mcp_server/atlas/enrichments/utility/cv_utility.yaml +37 -0
  37. package/mcp_server/atlas/enrichments/utility/performer.yaml +36 -0
  38. package/mcp_server/atlas/enrichments/utility/prearranger.yaml +36 -0
  39. package/mcp_server/atlas/enrichments/utility/rotating_rhythm_generator.yaml +35 -0
  40. package/mcp_server/atlas/enrichments/utility/surround_panner.yaml +40 -0
  41. package/mcp_server/atlas/enrichments/utility/variations.yaml +40 -0
  42. package/mcp_server/atlas/enrichments/utility/vector_map.yaml +36 -0
  43. package/mcp_server/atlas/tools.py +30 -2
  44. package/mcp_server/runtime/remote_commands.py +3 -0
  45. package/mcp_server/sample_engine/tools.py +738 -60
  46. package/mcp_server/server.py +18 -6
  47. package/mcp_server/splice_client/client.py +583 -65
  48. package/mcp_server/splice_client/http_bridge.py +434 -0
  49. package/mcp_server/splice_client/models.py +278 -2
  50. package/mcp_server/splice_client/quota.py +229 -0
  51. package/mcp_server/tools/_analyzer_engine/__init__.py +4 -0
  52. package/mcp_server/tools/_analyzer_engine/sample.py +73 -0
  53. package/mcp_server/tools/analyzer.py +730 -29
  54. package/mcp_server/tools/browser.py +164 -13
  55. package/mcp_server/tools/devices.py +56 -11
  56. package/mcp_server/tools/mixing.py +64 -15
  57. package/mcp_server/tools/scales.py +18 -6
  58. package/mcp_server/tools/tracks.py +92 -4
  59. package/package.json +2 -2
  60. package/remote_script/LivePilot/__init__.py +1 -1
  61. package/remote_script/LivePilot/_clip_helpers.py +86 -0
  62. package/remote_script/LivePilot/_drum_helpers.py +40 -0
  63. package/remote_script/LivePilot/_scale_helpers.py +87 -0
  64. package/remote_script/LivePilot/arrangement.py +44 -15
  65. package/remote_script/LivePilot/clips.py +182 -2
  66. package/remote_script/LivePilot/devices.py +82 -2
  67. package/remote_script/LivePilot/notes.py +17 -2
  68. package/remote_script/LivePilot/scales.py +31 -16
  69. package/remote_script/LivePilot/simpler_sample.py +105 -17
  70. package/server.json +3 -3
@@ -203,11 +203,15 @@ 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,
208
+ collection_uuid: str = "",
206
209
  ) -> dict:
207
210
  """Search for samples across Splice library, Ableton browser, and local filesystem.
208
211
 
209
212
  Searches all enabled sources in parallel and ranks results.
210
- Splice results include rich metadata (key, BPM, genre, tags, pack info).
213
+ Splice results include rich metadata (key, BPM, genre, tags, pack info,
214
+ is_premium, price, is_free, preview_url).
211
215
 
212
216
  When the Splice desktop app is running AND grpcio is installed, this
213
217
  searches Splice's ONLINE catalog (19,690+ hits for a generic query)
@@ -216,12 +220,25 @@ async def search_samples(
216
220
  already-downloaded samples.
217
221
 
218
222
  query: search text like "dark vocal", "breakbeat", "foley metal"
223
+ q: alias for `query` (accepts either name for ergonomics)
219
224
  material_type: filter by type (vocal, drum_loop, texture, etc.)
220
225
  key: prefer samples in this key (e.g., "Cm", "F#")
221
226
  bpm_range: "min-max" BPM range (e.g., "120-130")
222
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.
223
231
  max_results: maximum results to return (default 10)
232
+ free_only: if True, only return samples that cost nothing to license
233
+ (IsPremium=False or Price=0). Under the Ableton Live plan these
234
+ don't deplete the daily quota; under credit-metered plans they
235
+ bypass the credit floor.
224
236
  """
237
+ # Accept `q` as an alias for `query` — BUG-FIX #4 from 2026-04-22 bug doc.
238
+ if not query and q:
239
+ query = q
240
+ if not query:
241
+ return {"error": "query is required (or use `q` alias)"}
225
242
  results: list[dict] = []
226
243
 
227
244
  # Parse BPM range
@@ -234,6 +251,11 @@ async def search_samples(
234
251
  except ValueError:
235
252
  pass
236
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
+
237
259
  # Splice search — prefer gRPC online catalog when available, fall back
238
260
  # to local SQLite index. See docs/2026-04-14-bugs-discovered.md — P0-2.
239
261
  if source in (None, "splice"):
@@ -254,8 +276,11 @@ async def search_samples(
254
276
  per_page=max_results,
255
277
  page=1,
256
278
  purchased_only=False,
279
+ collection_uuid=collection_uuid,
257
280
  )
258
281
  for s in grpc_result.samples[:max_results]:
282
+ if free_only and not s.is_free:
283
+ continue
259
284
  results.append({
260
285
  "source": "splice",
261
286
  "name": s.filename,
@@ -267,6 +292,8 @@ async def search_samples(
267
292
  "splice_catalog": True,
268
293
  "downloaded": bool(s.local_path),
269
294
  "file_hash": s.file_hash,
295
+ "preview_url": s.preview_url,
296
+ "is_free": s.is_free,
270
297
  "metadata": {
271
298
  "key": s.audio_key,
272
299
  "bpm": s.bpm,
@@ -278,6 +305,8 @@ async def search_samples(
278
305
  "pack_uuid": s.pack_uuid,
279
306
  "duration": s.duration_ms / 1000.0 if s.duration_ms else 0.0,
280
307
  "is_premium": s.is_premium,
308
+ "price": s.price,
309
+ "is_free": s.is_free,
281
310
  "chord_type": s.chord_type,
282
311
  },
283
312
  })
@@ -658,37 +687,63 @@ def plan_slice_workflow(
658
687
  # Support/com.splice.Splice/)
659
688
  # - grpcio and protobuf installed (added to requirements.txt in v1.10.5)
660
689
  #
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.
690
+ # Credit model (corrected 2026-04-22 — see project_splice_subscription_model.md):
691
+ # Splice has a TWO-POCKET model that our earlier code conflated:
692
+ #
693
+ # 1. Daily sample quota — 100/day unmetered on the Splice x Ableton Live plan
694
+ # ($12.99/mo). Sample downloads deplete this counter, NOT credits.
695
+ # Resets at UTC midnight. We track locally in ~/.livepilot/splice_quota.json
696
+ # (see splice_client/quota.py) and warn at 90/100.
697
+ #
698
+ # 2. Splice.com credits used for presets, MIDI, Splice Instrument content.
699
+ # ALL plans have some credits (100 intro on Ableton Live, or monthly
700
+ # allotment on Creator/Sounds+). CREDIT_HARD_FLOOR = 5 keeps a safety
701
+ # reserve so agents can't drain you to zero.
702
+ #
703
+ # Free samples (Sample.IsPremium=False or Price=0) bypass BOTH gates — they're
704
+ # free under any plan.
705
+ #
706
+ # `SpliceGRPCClient.decide_download()` runs the full gating logic and returns
707
+ # a DownloadDecision with plan_kind and gating_mode. Use that, not the raw
708
+ # credit check, for any new download path.
670
709
 
671
710
 
672
711
  _SPLICE_USER_LIB_DEST = "~/Music/Ableton/User Library/Samples/Splice"
712
+ _SPLICE_PREVIEW_CACHE = "~/Library/Caches/LivePilot/splice_previews"
673
713
 
674
714
 
675
715
  @mcp.tool()
676
716
  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
- }
717
+ """Get the user's current Splice plan, credits, and daily sample quota.
718
+
719
+ Returns both pockets of the Splice subscription model:
720
+ - `credits_remaining`: Splice.com credits for presets/MIDI/Instrument
721
+ - `daily_quota`: sample-download counter (Ableton Live plan only)
722
+
723
+ Example (Ableton Live plan):
724
+ {
725
+ "connected": true,
726
+ "username": "user-1367453956",
727
+ "plan_raw": "subscribed",
728
+ "plan_kind": "ableton_live",
729
+ "sounds_plan_id": 12,
730
+ "features": {"ableton_unmetered": true, ...},
731
+ "credits_remaining": 80,
732
+ "credit_floor": 5,
733
+ "daily_quota": {
734
+ "used_today": 3, "remaining_today": 97, "daily_limit": 100,
735
+ "near_limit": false, "at_limit": false,
736
+ },
737
+ "can_download_sample": true,
738
+ "download_gating": "daily_quota", # or "credit_floor"
739
+ }
687
740
 
688
741
  Returns connected=False (with zero credits) when the Splice desktop app
689
742
  isn't running or grpcio isn't installed.
690
743
  """
691
744
  from ..splice_client.client import CREDIT_HARD_FLOOR
745
+ from ..splice_client.models import PlanKind
746
+ from ..splice_client.quota import get_tracker
692
747
 
693
748
  client = None
694
749
  try:
@@ -696,14 +751,19 @@ async def get_splice_credits(ctx: Context) -> dict:
696
751
  except AttributeError:
697
752
  pass
698
753
 
754
+ quota_summary = get_tracker().summary()
755
+
699
756
  if client is None or not getattr(client, "connected", False):
700
757
  return {
701
758
  "connected": False,
702
759
  "username": "",
703
- "plan": "",
760
+ "plan_raw": "",
761
+ "plan_kind": PlanKind.UNKNOWN.value,
704
762
  "credits_remaining": 0,
705
763
  "credit_floor": CREDIT_HARD_FLOOR,
706
- "can_download": False,
764
+ "daily_quota": quota_summary,
765
+ "can_download_sample": False,
766
+ "download_gating": "blocked",
707
767
  "hint": (
708
768
  "Splice gRPC not connected. Ensure Splice desktop app is "
709
769
  "running and grpcio+protobuf are installed in the LivePilot "
@@ -718,16 +778,43 @@ async def get_splice_credits(ctx: Context) -> dict:
718
778
  "connected": False,
719
779
  "error": f"get_credits failed: {exc}",
720
780
  "credit_floor": CREDIT_HARD_FLOOR,
781
+ "daily_quota": quota_summary,
721
782
  }
722
783
 
723
784
  remaining = int(info.credits)
785
+ plan = info.plan_kind
786
+
787
+ # Compute `can_download_sample` using the same logic decide_download uses.
788
+ if plan == PlanKind.ABLETON_LIVE:
789
+ gating = "daily_quota"
790
+ can_download = not quota_summary["at_limit"]
791
+ else:
792
+ gating = "credit_floor"
793
+ can_download = remaining > CREDIT_HARD_FLOOR
794
+
795
+ from ..splice_client.client import _read_plan_kind_override
796
+ plan_override_active = _read_plan_kind_override()
797
+
724
798
  return {
725
799
  "connected": True,
726
800
  "username": info.username,
727
- "plan": info.plan,
801
+ "plan_raw": info.plan,
802
+ "plan_kind": plan.value,
803
+ "plan_kind_override": plan_override_active,
804
+ "sounds_plan_id": info.sounds_plan_id,
805
+ "features": info.features,
806
+ "user_uuid": info.user_uuid,
728
807
  "credits_remaining": remaining,
729
808
  "credit_floor": CREDIT_HARD_FLOOR,
730
- "can_download": remaining > CREDIT_HARD_FLOOR,
809
+ "daily_quota": quota_summary,
810
+ "can_download_sample": can_download,
811
+ "download_gating": gating,
812
+ "note": (
813
+ "This plan gets 100 samples/day unmetered via drag-drop; "
814
+ "the 80 credits are for presets/MIDI only."
815
+ if plan == PlanKind.ABLETON_LIVE
816
+ else None
817
+ ),
731
818
  }
732
819
 
733
820
 
@@ -742,6 +829,8 @@ async def splice_catalog_hunt(
742
829
  genre: str = "",
743
830
  per_page: int = 10,
744
831
  page: int = 1,
832
+ free_only: bool = False,
833
+ collection_uuid: str = "",
745
834
  ) -> dict:
746
835
  """Search Splice's ONLINE catalog via gRPC.
747
836
 
@@ -798,6 +887,7 @@ async def splice_catalog_hunt(
798
887
  per_page=max(1, min(per_page, 50)),
799
888
  page=max(1, int(page)),
800
889
  purchased_only=False,
890
+ collection_uuid=collection_uuid,
801
891
  )
802
892
  except Exception as exc:
803
893
  return {
@@ -808,6 +898,8 @@ async def splice_catalog_hunt(
808
898
 
809
899
  samples_out = []
810
900
  for s in result.samples:
901
+ if free_only and not s.is_free:
902
+ continue
811
903
  samples_out.append({
812
904
  "file_hash": s.file_hash,
813
905
  "filename": s.filename,
@@ -821,6 +913,8 @@ async def splice_catalog_hunt(
821
913
  "pack": s.provider_name,
822
914
  "pack_uuid": s.pack_uuid,
823
915
  "is_premium": bool(s.is_premium),
916
+ "price": int(s.price),
917
+ "is_free": s.is_free,
824
918
  "is_downloaded": bool(s.local_path),
825
919
  "local_path": s.local_path or None,
826
920
  "preview_url": s.preview_url,
@@ -841,31 +935,38 @@ async def splice_download_sample(
841
935
  ctx: Context,
842
936
  file_hash: str,
843
937
  copy_to_user_library: bool = True,
938
+ force: bool = False,
844
939
  ) -> 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.
940
+ """Download a Splice sample by file_hash plan-aware gating.
941
+
942
+ Use `splice_catalog_hunt` or `search_samples` first to find samples
943
+ and their `file_hash`. The gating logic runs BEFORE any network call:
944
+
945
+ - Ableton Live plan: uses your 100/day unmetered quota (not credits).
946
+ Tracked locally in ~/.livepilot/splice_quota.json so repeated runs
947
+ warn at 90/100 and refuse at 100 (resets at UTC midnight).
948
+ - Credit-metered plans (Sounds+/Creator): enforces CREDIT_HARD_FLOOR=5
949
+ so the agent can't drain your monthly allotment.
950
+ - Free samples (IsPremium=False or Price=0): bypass both gates.
951
+
952
+ Arguments:
953
+ file_hash: the sample identifier from search results
954
+ copy_to_user_library: if True (default), also copies to
955
+ ~/Music/Ableton/User Library/Samples/Splice/ so Ableton's browser
956
+ can reach it via `load_browser_item` with a `query:UserLibrary#...`
957
+ URI.
958
+ force: bypass local quota checks (still honors server-side limits).
959
+ Use for deterministic tests — NOT for production flows.
856
960
 
857
961
  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,
962
+ "ok": bool,
963
+ "local_path": str, # Splice's own download path
964
+ "user_library_path": str, # if copy_to_user_library=True
965
+ "browser_uri": str, # ready for load_browser_item
966
+ "decision": {...}, # plan-aware gating summary
967
+ "credits_remaining": int,
968
+ "daily_quota": {...}, # post-download quota snapshot
863
969
  }
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
970
  """
870
971
  import shutil
871
972
 
@@ -881,24 +982,35 @@ async def splice_download_sample(
881
982
  "error": "Splice gRPC not connected",
882
983
  }
883
984
 
884
- # Credit safety check
985
+ # Try to fetch the sample metadata so we can detect free samples and
986
+ # bypass gating when Price=0. This is one extra round-trip but saves
987
+ # credits/quota for catalog items marked free.
988
+ sample = None
885
989
  try:
886
- can, remaining = await client.can_afford(1, budget=10)
990
+ sample = await client.get_sample_info(file_hash)
887
991
  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
- }
992
+ logger.debug("get_sample_info failed pre-gating: %s", exc)
898
993
 
899
- # Trigger download
994
+ # Run the full gating logic before touching the network.
995
+ if not force:
996
+ try:
997
+ decision = await client.decide_download(file_hash, sample=sample)
998
+ except Exception as exc:
999
+ return {"ok": False, "error": f"Gating check failed: {exc}"}
1000
+ if not decision.allowed:
1001
+ return {
1002
+ "ok": False,
1003
+ "error": decision.reason,
1004
+ "decision": decision.to_dict(),
1005
+ }
1006
+ else:
1007
+ decision = None
1008
+
1009
+ # Trigger download (client.download_sample also re-runs the gate defensively)
900
1010
  try:
901
- local_path = await client.download_sample(file_hash, timeout=30.0)
1011
+ local_path = await client.download_sample(
1012
+ file_hash, timeout=30.0, sample=sample,
1013
+ )
902
1014
  except Exception as exc:
903
1015
  return {"ok": False, "error": f"Download failed: {exc}"}
904
1016
 
@@ -912,6 +1024,7 @@ async def splice_download_sample(
912
1024
  "ok": True,
913
1025
  "local_path": local_path,
914
1026
  "filename": os.path.basename(local_path),
1027
+ "decision": decision.to_dict() if decision else None,
915
1028
  }
916
1029
 
917
1030
  # Copy into User Library so Ableton's browser indexes it
@@ -930,7 +1043,12 @@ async def splice_download_sample(
930
1043
  except Exception as exc:
931
1044
  response["copy_warning"] = f"Failed to copy to User Library: {exc}"
932
1045
 
933
- # Post-credit count
1046
+ # Post-download state
1047
+ try:
1048
+ from ..splice_client.quota import get_tracker
1049
+ response["daily_quota"] = get_tracker().summary()
1050
+ except Exception as exc:
1051
+ logger.debug("post-download quota snapshot failed: %s", exc)
934
1052
  try:
935
1053
  info = await client.get_credits()
936
1054
  response["credits_remaining"] = int(info.credits)
@@ -938,3 +1056,563 @@ async def splice_download_sample(
938
1056
  logger.warning("post-download credit check failed: %s", exc)
939
1057
 
940
1058
  return response
1059
+
1060
+
1061
+ # ────────────────────────────────────────────────────────────────────────
1062
+ # Zero-cost preview — fetches Sample.PreviewURL which is always free.
1063
+ # ────────────────────────────────────────────────────────────────────────
1064
+
1065
+
1066
+ @mcp.tool()
1067
+ async def splice_preview_sample(
1068
+ ctx: Context,
1069
+ file_hash: str,
1070
+ cache: bool = True,
1071
+ ) -> dict:
1072
+ """Fetch a Splice sample's preview audio — ZERO credits, ZERO quota cost.
1073
+
1074
+ Every catalog sample has a `PreviewURL` (low-bitrate MP3) that Splice
1075
+ streams freely. Use this to audition before calling
1076
+ `splice_download_sample`. Perfect for:
1077
+ - Quickly hearing 10 candidates before committing to one download
1078
+ - Staying under the daily sample quota on the Ableton Live plan
1079
+ - Letting agents judge fit without spending anything
1080
+
1081
+ Arguments:
1082
+ file_hash: the sample identifier from search results
1083
+ cache: if True (default), write the preview to
1084
+ ~/Library/Caches/LivePilot/splice_previews/ for Ableton to load
1085
+
1086
+ Returns: {
1087
+ "ok": bool,
1088
+ "preview_url": str,
1089
+ "local_preview_path": str, # if cache=True and download succeeded
1090
+ "filename": str,
1091
+ "duration_sec": float,
1092
+ "cost": "free", # always, for every plan
1093
+ }
1094
+ """
1095
+ import hashlib
1096
+ import urllib.request
1097
+ import urllib.error
1098
+
1099
+ client = None
1100
+ try:
1101
+ client = ctx.lifespan_context.get("splice_client")
1102
+ except AttributeError:
1103
+ pass
1104
+
1105
+ if client is None or not getattr(client, "connected", False):
1106
+ return {"ok": False, "error": "Splice gRPC not connected"}
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.
1113
+ sample = None
1114
+ try:
1115
+ sample = await client.get_sample_info(file_hash)
1116
+ except Exception as 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}"}
1126
+
1127
+ if sample is None or not sample.preview_url:
1128
+ return {
1129
+ "ok": False,
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
+ ),
1136
+ "file_hash": file_hash,
1137
+ }
1138
+
1139
+ response: dict = {
1140
+ "ok": True,
1141
+ "preview_url": sample.preview_url,
1142
+ "filename": sample.filename,
1143
+ "duration_sec": round(sample.duration_seconds, 2),
1144
+ "cost": "free",
1145
+ "file_hash": file_hash,
1146
+ "is_free_sample": sample.is_free,
1147
+ "key": sample.key_display,
1148
+ "bpm": sample.bpm,
1149
+ "tags": sample.tags,
1150
+ }
1151
+
1152
+ if cache:
1153
+ cache_dir = os.path.expanduser(_SPLICE_PREVIEW_CACHE)
1154
+ try:
1155
+ os.makedirs(cache_dir, exist_ok=True)
1156
+ # Short deterministic filename based on file_hash
1157
+ digest = hashlib.md5(file_hash.encode()).hexdigest()[:12]
1158
+ ext = os.path.splitext(sample.preview_url.split("?")[0])[1] or ".mp3"
1159
+ dest = os.path.join(cache_dir, f"preview_{digest}{ext}")
1160
+ if not os.path.isfile(dest):
1161
+ def _download():
1162
+ req = urllib.request.Request(
1163
+ sample.preview_url,
1164
+ headers={"User-Agent": "LivePilot/1.0"},
1165
+ )
1166
+ with urllib.request.urlopen(req, timeout=15) as r:
1167
+ data = r.read()
1168
+ with open(dest, "wb") as f:
1169
+ f.write(data)
1170
+ return dest
1171
+ # Run sync urllib in a thread to avoid blocking the event loop
1172
+ import asyncio as _aio
1173
+ await _aio.get_running_loop().run_in_executor(None, _download)
1174
+ response["local_preview_path"] = dest
1175
+ except (urllib.error.URLError, OSError, ValueError) as exc:
1176
+ response["cache_warning"] = f"Preview cache write failed: {exc}"
1177
+
1178
+ return response
1179
+
1180
+
1181
+ # ────────────────────────────────────────────────────────────────────────
1182
+ # Collections — user's personal sample organization (Likes, bass, keys, …).
1183
+ # These call the gRPC Collection* RPCs already wrapped in AppStub.
1184
+ # ────────────────────────────────────────────────────────────────────────
1185
+
1186
+
1187
+ def _require_splice_client(ctx: Context) -> tuple[object, Optional[dict]]:
1188
+ """Fetch the Splice client from context, or return an error dict."""
1189
+ client = None
1190
+ try:
1191
+ client = ctx.lifespan_context.get("splice_client")
1192
+ except AttributeError:
1193
+ pass
1194
+ if client is None or not getattr(client, "connected", False):
1195
+ return None, {"ok": False, "error": "Splice gRPC not connected"}
1196
+ return client, None
1197
+
1198
+
1199
+ @mcp.tool()
1200
+ async def splice_list_collections(
1201
+ ctx: Context, page: int = 1, per_page: int = 50,
1202
+ ) -> dict:
1203
+ """List the user's Splice Collections (Likes, custom folders, Daily Picks…).
1204
+
1205
+ Collections are user-curated sample/preset/pack bookmarks. They are
1206
+ the strongest available taste signal: each one represents the user's
1207
+ deliberate grouping. Use `splice_search_in_collection` to scope a
1208
+ search to one collection's samples — better than keyword-only search.
1209
+
1210
+ Returns: {
1211
+ "ok": true,
1212
+ "total_count": int,
1213
+ "collections": [
1214
+ {"uuid": "...", "name": "Likes", "sample_count": 47, ...},
1215
+ ],
1216
+ }
1217
+ """
1218
+ client, err = _require_splice_client(ctx)
1219
+ if err:
1220
+ return err
1221
+ total, collections = await client.list_collections(
1222
+ page=max(1, int(page)), per_page=max(1, min(int(per_page), 100)),
1223
+ )
1224
+ return {
1225
+ "ok": True,
1226
+ "total_count": total,
1227
+ "returned": len(collections),
1228
+ "page": page,
1229
+ "collections": [c.to_dict() for c in collections],
1230
+ }
1231
+
1232
+
1233
+ @mcp.tool()
1234
+ async def splice_search_in_collection(
1235
+ ctx: Context,
1236
+ collection_uuid: str,
1237
+ page: int = 1,
1238
+ per_page: int = 50,
1239
+ ) -> dict:
1240
+ """List samples inside a Splice Collection by UUID.
1241
+
1242
+ Get the UUID from `splice_list_collections`. The returned samples
1243
+ carry full metadata (key, BPM, is_free, preview_url) identical to
1244
+ `splice_catalog_hunt` — you can feed them straight into
1245
+ `splice_preview_sample` or `splice_download_sample`.
1246
+ """
1247
+ client, err = _require_splice_client(ctx)
1248
+ if err:
1249
+ return err
1250
+ total, samples = await client.collection_samples(
1251
+ uuid=collection_uuid,
1252
+ page=max(1, int(page)),
1253
+ per_page=max(1, min(int(per_page), 100)),
1254
+ )
1255
+ return {
1256
+ "ok": True,
1257
+ "collection_uuid": collection_uuid,
1258
+ "total_hits": total,
1259
+ "returned": len(samples),
1260
+ "samples": [s.to_dict() for s in samples],
1261
+ }
1262
+
1263
+
1264
+ @mcp.tool()
1265
+ async def splice_add_to_collection(
1266
+ ctx: Context, collection_uuid: str, file_hashes: list[str],
1267
+ ) -> dict:
1268
+ """Add one or more samples to a user Collection.
1269
+
1270
+ Persists server-side — the change appears in the Splice desktop app
1271
+ and web UI immediately. Use this to let LivePilot "save for later"
1272
+ items it finds during composition work.
1273
+ """
1274
+ client, err = _require_splice_client(ctx)
1275
+ if err:
1276
+ return err
1277
+ if not file_hashes:
1278
+ return {"ok": False, "error": "file_hashes must be a non-empty list"}
1279
+ success = await client.add_to_collection(collection_uuid, list(file_hashes))
1280
+ return {
1281
+ "ok": success,
1282
+ "collection_uuid": collection_uuid,
1283
+ "added_count": len(file_hashes) if success else 0,
1284
+ }
1285
+
1286
+
1287
+ @mcp.tool()
1288
+ async def splice_remove_from_collection(
1289
+ ctx: Context, collection_uuid: str, file_hashes: list[str],
1290
+ ) -> dict:
1291
+ """Remove one or more samples from a user Collection (server-side)."""
1292
+ client, err = _require_splice_client(ctx)
1293
+ if err:
1294
+ return err
1295
+ if not file_hashes:
1296
+ return {"ok": False, "error": "file_hashes must be a non-empty list"}
1297
+ success = await client.remove_from_collection(
1298
+ collection_uuid, list(file_hashes),
1299
+ )
1300
+ return {
1301
+ "ok": success,
1302
+ "collection_uuid": collection_uuid,
1303
+ "removed_count": len(file_hashes) if success else 0,
1304
+ }
1305
+
1306
+
1307
+ @mcp.tool()
1308
+ async def splice_create_collection(ctx: Context, name: str) -> dict:
1309
+ """Create a new user Collection. Returns the new UUID on success."""
1310
+ client, err = _require_splice_client(ctx)
1311
+ if err:
1312
+ return err
1313
+ name = (name or "").strip()
1314
+ if not name:
1315
+ return {"ok": False, "error": "Collection name cannot be empty"}
1316
+ collection = await client.create_collection(name)
1317
+ if collection is None:
1318
+ return {"ok": False, "error": "Collection create returned no result"}
1319
+ return {"ok": True, "collection": collection.to_dict()}
1320
+
1321
+
1322
+ # ────────────────────────────────────────────────────────────────────────
1323
+ # Presets — Splice Instrument / VST presets the user has purchased.
1324
+ # ────────────────────────────────────────────────────────────────────────
1325
+
1326
+
1327
+ @mcp.tool()
1328
+ async def splice_list_presets(
1329
+ ctx: Context,
1330
+ page: int = 1,
1331
+ per_page: int = 50,
1332
+ sort: str = "",
1333
+ sort_order: str = "",
1334
+ ) -> dict:
1335
+ """List presets the user has purchased from Splice.
1336
+
1337
+ Covers Splice Instrument and Rent-to-Own plugin presets. Each entry
1338
+ includes `plugin_name` so the agent can route loading to the right
1339
+ plugin — e.g., a Serum preset vs. a Splice Instrument preset.
1340
+
1341
+ Returns: {
1342
+ "ok": true,
1343
+ "total_hits": int,
1344
+ "presets": [
1345
+ {"uuid": "...", "filename": "Deep House Pluck.fxp",
1346
+ "plugin_name": "Serum", "local_path": "...", ...},
1347
+ ],
1348
+ }
1349
+ """
1350
+ client, err = _require_splice_client(ctx)
1351
+ if err:
1352
+ return err
1353
+ total, presets = await client.list_purchased_presets(
1354
+ page=max(1, int(page)),
1355
+ per_page=max(1, min(int(per_page), 100)),
1356
+ sort=sort, sort_order=sort_order,
1357
+ )
1358
+ return {
1359
+ "ok": True,
1360
+ "total_hits": total,
1361
+ "returned": len(presets),
1362
+ "presets": [p.to_dict() for p in presets],
1363
+ }
1364
+
1365
+
1366
+ @mcp.tool()
1367
+ async def splice_preset_info(
1368
+ ctx: Context,
1369
+ uuid: str = "",
1370
+ file_hash: str = "",
1371
+ plugin_name: str = "",
1372
+ ) -> dict:
1373
+ """Fetch metadata for a single preset (uuid, file_hash, or plugin_name)."""
1374
+ client, err = _require_splice_client(ctx)
1375
+ if err:
1376
+ return err
1377
+ if not (uuid or file_hash or plugin_name):
1378
+ return {"ok": False, "error": "Provide at least one of uuid, file_hash, plugin_name"}
1379
+ info = await client.get_preset_info(
1380
+ uuid=uuid, file_hash=file_hash, plugin_name=plugin_name,
1381
+ )
1382
+ if info is None:
1383
+ return {"ok": False, "error": "Preset not found"}
1384
+ return {"ok": True, **info}
1385
+
1386
+
1387
+ @mcp.tool()
1388
+ async def splice_download_preset(ctx: Context, uuid: str) -> dict:
1389
+ """Trigger a preset download (uses Splice.com credits, not the sample quota).
1390
+
1391
+ Splice credits ARE used for presets under every plan — this is the
1392
+ "second pocket" of the subscription model. We still honor
1393
+ CREDIT_HARD_FLOOR=5 so the agent can't drain the monthly allotment.
1394
+ """
1395
+ from ..splice_client.client import CREDIT_HARD_FLOOR
1396
+
1397
+ client, err = _require_splice_client(ctx)
1398
+ if err:
1399
+ return err
1400
+ if not uuid:
1401
+ return {"ok": False, "error": "uuid is required"}
1402
+
1403
+ try:
1404
+ can, remaining = await client.can_afford(1, budget=1)
1405
+ except Exception as exc:
1406
+ return {"ok": False, "error": f"Credit check failed: {exc}"}
1407
+ if not can:
1408
+ return {
1409
+ "ok": False,
1410
+ "error": (
1411
+ f"Credit floor hit (remaining={remaining}, "
1412
+ f"floor={CREDIT_HARD_FLOOR}). Preset download refused."
1413
+ ),
1414
+ "credits_remaining": remaining,
1415
+ }
1416
+
1417
+ success = await client.download_preset(uuid)
1418
+ result: dict = {"ok": success, "uuid": uuid}
1419
+ try:
1420
+ info = await client.get_credits()
1421
+ result["credits_remaining"] = int(info.credits)
1422
+ except Exception as exc:
1423
+ logger.debug("post-preset-download credit check failed: %s", exc)
1424
+ return result
1425
+
1426
+
1427
+ # ────────────────────────────────────────────────────────────────────────
1428
+ # Sample packs — pack metadata (rich descriptions, genre, cover art, etc.).
1429
+ # ────────────────────────────────────────────────────────────────────────
1430
+
1431
+
1432
+ @mcp.tool()
1433
+ async def splice_pack_info(ctx: Context, pack_uuid: str) -> dict:
1434
+ """Fetch full metadata for a Splice sample pack by UUID.
1435
+
1436
+ Pack UUIDs come from search results (each sample carries `pack_uuid`).
1437
+ Useful for discovering related samples by pack, or surfacing pack-level
1438
+ genre/provider info that search results omit.
1439
+ """
1440
+ client, err = _require_splice_client(ctx)
1441
+ if err:
1442
+ return err
1443
+ if not pack_uuid:
1444
+ return {"ok": False, "error": "pack_uuid is required"}
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)
1455
+ if pack is None:
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
+ }
1462
+ return {"ok": True, "pack": pack.to_dict()}
1463
+
1464
+
1465
+ # ────────────────────────────────────────────────────────────────────────
1466
+ # HTTPS bridge — Describe a Sound / Variations (plugin-exclusive features).
1467
+ # These hit api.splice.com over HTTPS with the session token from gRPC.
1468
+ # Scaffolding ships today; real endpoints wire in once captured.
1469
+ # ────────────────────────────────────────────────────────────────────────
1470
+
1471
+
1472
+ def _build_http_bridge(ctx: Context):
1473
+ """Construct the HTTPS bridge with the current gRPC client attached.
1474
+
1475
+ Returns (bridge, err_dict). On success err_dict is None.
1476
+ """
1477
+ from ..splice_client.http_bridge import SpliceHTTPBridge
1478
+
1479
+ client = None
1480
+ try:
1481
+ client = ctx.lifespan_context.get("splice_client")
1482
+ except AttributeError:
1483
+ pass
1484
+ if client is None or not getattr(client, "connected", False):
1485
+ return None, {
1486
+ "ok": False,
1487
+ "error": "Splice gRPC not connected — session token unreachable",
1488
+ }
1489
+ return SpliceHTTPBridge(grpc_client=client), None
1490
+
1491
+
1492
+ @mcp.tool()
1493
+ async def splice_describe_sound(
1494
+ ctx: Context,
1495
+ description: str,
1496
+ bpm: Optional[int] = None,
1497
+ key: Optional[str] = None,
1498
+ limit: int = 20,
1499
+ ) -> dict:
1500
+ """Natural-language sample search — the Sounds Plugin's "Describe a Sound".
1501
+
1502
+ Splice's AI matches free-form descriptions like "dark ambient pad with
1503
+ shimmer" or "tight 90s house hi-hat" to catalog samples. This is NOT
1504
+ on the local gRPC — the bridge proxies to api.splice.com using your
1505
+ session token.
1506
+
1507
+ **Status: scaffolding complete, endpoint pending real-traffic capture.**
1508
+ Until `SPLICE_DESCRIBE_ENDPOINT` env var is set (or
1509
+ `SPLICE_ALLOW_UNVERIFIED_ENDPOINTS=1`), this tool returns a structured
1510
+ ENDPOINT_NOT_CONFIGURED error with actionable setup steps.
1511
+
1512
+ description: free-text prompt ("warm analog bass under 80bpm")
1513
+ bpm: optional BPM filter
1514
+ key: optional musical key ("Dm", "F#")
1515
+ limit: max results (default 20)
1516
+ """
1517
+ bridge, err = _build_http_bridge(ctx)
1518
+ if err:
1519
+ return err
1520
+ from ..splice_client.http_bridge import SpliceHTTPError
1521
+ if not description or not description.strip():
1522
+ return {"ok": False, "error": "description is required"}
1523
+ try:
1524
+ result = await bridge.describe_sound(
1525
+ description=description.strip(),
1526
+ bpm=bpm, key=key, limit=int(limit),
1527
+ )
1528
+ except SpliceHTTPError as exc:
1529
+ return exc.to_dict()
1530
+ except Exception as exc:
1531
+ return {"ok": False, "error": f"describe_sound failed: {exc}"}
1532
+ return {"ok": True, "query": description, **(result if isinstance(result, dict) else {"raw": result})}
1533
+
1534
+
1535
+ @mcp.tool()
1536
+ async def splice_generate_variation(
1537
+ ctx: Context,
1538
+ file_hash: str,
1539
+ target_key: Optional[str] = None,
1540
+ target_bpm: Optional[int] = None,
1541
+ count: int = 1,
1542
+ ) -> dict:
1543
+ """Generate AI variations of a Splice sample — the Sounds Plugin's "Variations".
1544
+
1545
+ Splice's AI produces unique re-keyed / re-tempo'd versions of any
1546
+ sample. Costs additional credits per variation (on top of the base
1547
+ license). NOT on the local gRPC — bridged via api.splice.com.
1548
+
1549
+ **Status: scaffolding complete, endpoint pending real-traffic capture.**
1550
+ Until `SPLICE_VARIATION_ENDPOINT` env var is set (or
1551
+ `SPLICE_ALLOW_UNVERIFIED_ENDPOINTS=1`), this tool returns a structured
1552
+ ENDPOINT_NOT_CONFIGURED error with actionable setup steps.
1553
+
1554
+ file_hash: sample identifier (from search results)
1555
+ target_key: desired key (e.g. "Am")
1556
+ target_bpm: desired tempo
1557
+ count: number of variations to generate (1-5)
1558
+
1559
+ WARNING: this WILL spend credits when the endpoint is live.
1560
+ Consider previewing the source sample with splice_preview_sample first.
1561
+ """
1562
+ bridge, err = _build_http_bridge(ctx)
1563
+ if err:
1564
+ return err
1565
+ from ..splice_client.http_bridge import SpliceHTTPError
1566
+ if not file_hash or not file_hash.strip():
1567
+ return {"ok": False, "error": "file_hash is required"}
1568
+ if count < 1 or count > 5:
1569
+ return {"ok": False, "error": "count must be 1-5"}
1570
+ try:
1571
+ result = await bridge.generate_variation(
1572
+ file_hash=file_hash.strip(),
1573
+ target_key=target_key,
1574
+ target_bpm=target_bpm,
1575
+ count=int(count),
1576
+ )
1577
+ except SpliceHTTPError as exc:
1578
+ return exc.to_dict()
1579
+ except Exception as exc:
1580
+ return {"ok": False, "error": f"generate_variation failed: {exc}"}
1581
+ return {"ok": True, "file_hash": file_hash, **(result if isinstance(result, dict) else {"raw": result})}
1582
+
1583
+
1584
+ @mcp.tool()
1585
+ async def splice_search_with_sound(
1586
+ ctx: Context,
1587
+ audio_path: str,
1588
+ limit: int = 20,
1589
+ ) -> dict:
1590
+ """Reference-audio search — the Sounds Plugin's "Search with Sound".
1591
+
1592
+ Uploads a local audio file to Splice's AI and returns catalog samples
1593
+ with similar character. Complements `splice_describe_sound` (text)
1594
+ and `search_samples` (keyword).
1595
+
1596
+ **Status: scaffolding complete, wiring pending real-traffic capture
1597
+ (multipart upload shape is the most uncertain part of the bridge).**
1598
+ Until `SPLICE_SEARCH_WITH_SOUND_ENDPOINT` is set, returns a structured
1599
+ NOT_YET_IMPLEMENTED error.
1600
+
1601
+ audio_path: absolute path to a local audio file (.wav, .mp3, .flac)
1602
+ limit: max results (default 20)
1603
+ """
1604
+ bridge, err = _build_http_bridge(ctx)
1605
+ if err:
1606
+ return err
1607
+ from ..splice_client.http_bridge import SpliceHTTPError
1608
+ if not audio_path or not os.path.isfile(audio_path):
1609
+ return {"ok": False, "error": f"audio_path not found: {audio_path}"}
1610
+ try:
1611
+ result = await bridge.search_with_sound(
1612
+ audio_path=audio_path, limit=int(limit),
1613
+ )
1614
+ except SpliceHTTPError as exc:
1615
+ return exc.to_dict()
1616
+ except Exception as exc:
1617
+ return {"ok": False, "error": f"search_with_sound failed: {exc}"}
1618
+ return {"ok": True, "audio_path": audio_path, **(result if isinstance(result, dict) else {"raw": result})}