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.
- package/CHANGELOG.md +176 -1
- package/README.md +6 -6
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/device_atlas.json +91219 -7161
- package/mcp_server/atlas/tools.py +30 -2
- package/mcp_server/runtime/live_version.py +4 -2
- package/mcp_server/runtime/remote_commands.py +5 -0
- package/mcp_server/sample_engine/tools.py +692 -60
- package/mcp_server/splice_client/client.py +511 -65
- package/mcp_server/splice_client/http_bridge.py +361 -0
- package/mcp_server/splice_client/models.py +266 -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 +666 -6
- 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 +2 -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 +186 -0
- 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 (
|
|
662
|
-
#
|
|
663
|
-
#
|
|
664
|
-
#
|
|
665
|
-
#
|
|
666
|
-
#
|
|
667
|
-
#
|
|
668
|
-
#
|
|
669
|
-
#
|
|
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
|
|
678
|
-
|
|
679
|
-
Returns:
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
"
|
|
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
|
-
"
|
|
750
|
+
"plan_raw": "",
|
|
751
|
+
"plan_kind": PlanKind.UNKNOWN.value,
|
|
704
752
|
"credits_remaining": 0,
|
|
705
753
|
"credit_floor": CREDIT_HARD_FLOOR,
|
|
706
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
846
|
-
|
|
847
|
-
Use `splice_catalog_hunt` first to find samples
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
976
|
+
sample = await client.get_sample_info(file_hash)
|
|
887
977
|
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
|
-
}
|
|
978
|
+
logger.debug("get_sample_info failed pre-gating: %s", exc)
|
|
898
979
|
|
|
899
|
-
#
|
|
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(
|
|
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-
|
|
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})}
|