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.
- package/CHANGELOG.md +206 -3
- package/README.md +11 -11
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/device_atlas.json +91219 -7161
- package/mcp_server/atlas/enrichments/audio_effects/pitch_hack.yaml +61 -0
- package/mcp_server/atlas/enrichments/audio_effects/pitchloop89.yaml +111 -0
- package/mcp_server/atlas/enrichments/audio_effects/re_enveloper.yaml +51 -0
- package/mcp_server/atlas/enrichments/audio_effects/snipper.yaml +36 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_blur.yaml +64 -0
- package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +37 -0
- package/mcp_server/atlas/enrichments/instruments/granulator_iii.yaml +124 -0
- package/mcp_server/atlas/enrichments/instruments/harmonic_drone_generator.yaml +83 -0
- package/mcp_server/atlas/enrichments/instruments/impulse.yaml +47 -0
- package/mcp_server/atlas/enrichments/instruments/sting_iftah.yaml +44 -0
- package/mcp_server/atlas/enrichments/midi_effects/expressive_chords.yaml +38 -0
- package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +32 -0
- package/mcp_server/atlas/enrichments/midi_effects/microtuner.yaml +83 -0
- package/mcp_server/atlas/enrichments/midi_effects/patterns_iftah.yaml +38 -0
- package/mcp_server/atlas/enrichments/midi_effects/phase_pattern.yaml +51 -0
- package/mcp_server/atlas/enrichments/midi_effects/polyrhythm.yaml +46 -0
- package/mcp_server/atlas/enrichments/midi_effects/retrigger.yaml +40 -0
- package/mcp_server/atlas/enrichments/midi_effects/slice_shuffler.yaml +39 -0
- package/mcp_server/atlas/enrichments/midi_effects/sq_sequencer.yaml +39 -0
- package/mcp_server/atlas/enrichments/midi_effects/stages.yaml +38 -0
- package/mcp_server/atlas/enrichments/utility/arrangement_looper.yaml +31 -0
- package/mcp_server/atlas/enrichments/utility/cv_clock_in.yaml +25 -0
- package/mcp_server/atlas/enrichments/utility/cv_clock_out.yaml +25 -0
- package/mcp_server/atlas/enrichments/utility/cv_envelope_follower.yaml +38 -0
- package/mcp_server/atlas/enrichments/utility/cv_in.yaml +26 -0
- package/mcp_server/atlas/enrichments/utility/cv_instrument.yaml +34 -0
- package/mcp_server/atlas/enrichments/utility/cv_lfo.yaml +38 -0
- package/mcp_server/atlas/enrichments/utility/cv_shaper.yaml +35 -0
- package/mcp_server/atlas/enrichments/utility/cv_triggers.yaml +26 -0
- package/mcp_server/atlas/enrichments/utility/cv_utility.yaml +37 -0
- package/mcp_server/atlas/enrichments/utility/performer.yaml +36 -0
- package/mcp_server/atlas/enrichments/utility/prearranger.yaml +36 -0
- package/mcp_server/atlas/enrichments/utility/rotating_rhythm_generator.yaml +35 -0
- package/mcp_server/atlas/enrichments/utility/surround_panner.yaml +40 -0
- package/mcp_server/atlas/enrichments/utility/variations.yaml +40 -0
- package/mcp_server/atlas/enrichments/utility/vector_map.yaml +36 -0
- package/mcp_server/atlas/tools.py +30 -2
- package/mcp_server/runtime/remote_commands.py +3 -0
- package/mcp_server/sample_engine/tools.py +738 -60
- package/mcp_server/server.py +18 -6
- package/mcp_server/splice_client/client.py +583 -65
- package/mcp_server/splice_client/http_bridge.py +434 -0
- package/mcp_server/splice_client/models.py +278 -2
- package/mcp_server/splice_client/quota.py +229 -0
- package/mcp_server/tools/_analyzer_engine/__init__.py +4 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +73 -0
- package/mcp_server/tools/analyzer.py +730 -29
- package/mcp_server/tools/browser.py +164 -13
- package/mcp_server/tools/devices.py +56 -11
- package/mcp_server/tools/mixing.py +64 -15
- package/mcp_server/tools/scales.py +18 -6
- package/mcp_server/tools/tracks.py +92 -4
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/_clip_helpers.py +86 -0
- package/remote_script/LivePilot/_drum_helpers.py +40 -0
- package/remote_script/LivePilot/_scale_helpers.py +87 -0
- package/remote_script/LivePilot/arrangement.py +44 -15
- package/remote_script/LivePilot/clips.py +182 -2
- package/remote_script/LivePilot/devices.py +82 -2
- package/remote_script/LivePilot/notes.py +17 -2
- package/remote_script/LivePilot/scales.py +31 -16
- package/remote_script/LivePilot/simpler_sample.py +105 -17
- 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 (
|
|
662
|
-
#
|
|
663
|
-
#
|
|
664
|
-
#
|
|
665
|
-
#
|
|
666
|
-
#
|
|
667
|
-
#
|
|
668
|
-
#
|
|
669
|
-
#
|
|
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
|
|
678
|
-
|
|
679
|
-
Returns:
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
"
|
|
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
|
-
"
|
|
760
|
+
"plan_raw": "",
|
|
761
|
+
"plan_kind": PlanKind.UNKNOWN.value,
|
|
704
762
|
"credits_remaining": 0,
|
|
705
763
|
"credit_floor": CREDIT_HARD_FLOOR,
|
|
706
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
846
|
-
|
|
847
|
-
Use `splice_catalog_hunt` first to find samples
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
990
|
+
sample = await client.get_sample_info(file_hash)
|
|
887
991
|
except Exception as exc:
|
|
888
|
-
|
|
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
|
-
#
|
|
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(
|
|
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-
|
|
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})}
|