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
|
@@ -3,8 +3,25 @@
|
|
|
3
3
|
Splice runs a gRPC server (Go binary) on localhost with TLS.
|
|
4
4
|
Port is dynamic (read from port.conf). Certs are self-signed.
|
|
5
5
|
|
|
6
|
-
This client
|
|
6
|
+
This client wraps the full `proto.App` service surface:
|
|
7
|
+
- Search / download / sample-info
|
|
8
|
+
- Credits + plan classification (see models.PlanKind)
|
|
9
|
+
- Collections (list, samples, add/remove items, create, update, delete)
|
|
10
|
+
- Presets (purchased list, download, info, purchase)
|
|
11
|
+
- Packs (info)
|
|
12
|
+
- Imported samples (user's own directories indexed by Splice)
|
|
13
|
+
- Convert to WAV (for non-WAV sources)
|
|
14
|
+
- Preview URL fetching (zero-credit audition)
|
|
15
|
+
|
|
7
16
|
All methods are async. Graceful degradation when Splice is not running.
|
|
17
|
+
|
|
18
|
+
Plan-aware gating (see `project_splice_subscription_model.md`):
|
|
19
|
+
- On the Ableton Live plan, sample downloads deplete a DAILY counter
|
|
20
|
+
(100/day) rather than credits. `can_download_sample()` checks both
|
|
21
|
+
the daily quota AND the credit floor, choosing the right budget for
|
|
22
|
+
the user's actual plan.
|
|
23
|
+
- Free samples (`Sample.IsPremium == False` or `Price == 0`) bypass
|
|
24
|
+
gating entirely — they're free under any plan.
|
|
8
25
|
"""
|
|
9
26
|
|
|
10
27
|
from __future__ import annotations
|
|
@@ -13,9 +30,20 @@ import asyncio
|
|
|
13
30
|
import glob
|
|
14
31
|
import logging
|
|
15
32
|
import os
|
|
33
|
+
from dataclasses import dataclass
|
|
16
34
|
from typing import Optional
|
|
17
35
|
|
|
18
|
-
from .models import
|
|
36
|
+
from .models import (
|
|
37
|
+
PlanKind,
|
|
38
|
+
SpliceCollection,
|
|
39
|
+
SpliceCredits,
|
|
40
|
+
SplicePack,
|
|
41
|
+
SplicePreset,
|
|
42
|
+
SpliceSample,
|
|
43
|
+
SpliceSearchResult,
|
|
44
|
+
classify_plan,
|
|
45
|
+
)
|
|
46
|
+
from .quota import DailyQuotaTracker, get_tracker
|
|
19
47
|
|
|
20
48
|
logger = logging.getLogger(__name__)
|
|
21
49
|
|
|
@@ -24,7 +52,8 @@ _SPLICE_APP_SUPPORT = os.path.expanduser(
|
|
|
24
52
|
"~/Library/Application Support/com.splice.Splice"
|
|
25
53
|
)
|
|
26
54
|
|
|
27
|
-
# Credit safety floor — never drain below this
|
|
55
|
+
# Credit safety floor — never drain below this on credit-metered plans.
|
|
56
|
+
# Does NOT apply to the Ableton Live plan, which uses a daily counter.
|
|
28
57
|
CREDIT_HARD_FLOOR = 5
|
|
29
58
|
|
|
30
59
|
# Per-call gRPC timeouts. The previous implementation passed no timeout, so
|
|
@@ -36,6 +65,40 @@ INFO_TIMEOUT = 5.0
|
|
|
36
65
|
CREDITS_TIMEOUT = 5.0
|
|
37
66
|
SYNC_TIMEOUT = 30.0
|
|
38
67
|
DOWNLOAD_TRIGGER_TIMEOUT = 5.0
|
|
68
|
+
COLLECTION_TIMEOUT = 10.0
|
|
69
|
+
PRESET_TIMEOUT = 10.0
|
|
70
|
+
CONVERT_TIMEOUT = 30.0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class DownloadDecision:
|
|
75
|
+
"""Result of pre-download gating logic.
|
|
76
|
+
|
|
77
|
+
`allowed` — whether to proceed with the download.
|
|
78
|
+
`reason` — human-readable explanation.
|
|
79
|
+
`plan_kind` — plan classification used for the decision.
|
|
80
|
+
`credits_remaining` / `quota_used` / `quota_remaining` — state snapshot.
|
|
81
|
+
`gating_mode` — "free_sample", "daily_quota", "credit_floor", or "blocked".
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
allowed: bool
|
|
85
|
+
reason: str
|
|
86
|
+
plan_kind: PlanKind
|
|
87
|
+
gating_mode: str
|
|
88
|
+
credits_remaining: int = 0
|
|
89
|
+
quota_used: int = 0
|
|
90
|
+
quota_remaining: int = 0
|
|
91
|
+
|
|
92
|
+
def to_dict(self) -> dict:
|
|
93
|
+
return {
|
|
94
|
+
"allowed": self.allowed,
|
|
95
|
+
"reason": self.reason,
|
|
96
|
+
"plan_kind": self.plan_kind.value,
|
|
97
|
+
"gating_mode": self.gating_mode,
|
|
98
|
+
"credits_remaining": self.credits_remaining,
|
|
99
|
+
"quota_used": self.quota_used,
|
|
100
|
+
"quota_remaining": self.quota_remaining,
|
|
101
|
+
}
|
|
39
102
|
|
|
40
103
|
|
|
41
104
|
def _try_import_grpc():
|
|
@@ -56,16 +119,43 @@ def _try_import_protos():
|
|
|
56
119
|
return None, None
|
|
57
120
|
|
|
58
121
|
|
|
122
|
+
def _read_plan_kind_override() -> Optional[str]:
|
|
123
|
+
"""Read `plan_kind_override` from ~/.livepilot/splice.json, if present.
|
|
124
|
+
|
|
125
|
+
Lets the user pin their Splice plan_kind when gRPC data is ambiguous.
|
|
126
|
+
Example config:
|
|
127
|
+
{"plan_kind_override": "ableton_live"}
|
|
128
|
+
Returns None silently on any I/O or JSON error.
|
|
129
|
+
"""
|
|
130
|
+
import json
|
|
131
|
+
path = os.path.expanduser("~/.livepilot/splice.json")
|
|
132
|
+
if not os.path.isfile(path):
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
136
|
+
data = json.load(f)
|
|
137
|
+
if isinstance(data, dict):
|
|
138
|
+
value = data.get("plan_kind_override")
|
|
139
|
+
if isinstance(value, str) and value.strip():
|
|
140
|
+
return value.strip()
|
|
141
|
+
except (OSError, json.JSONDecodeError):
|
|
142
|
+
pass
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
59
146
|
class SpliceGRPCClient:
|
|
60
147
|
"""Async gRPC client for Splice desktop's App service."""
|
|
61
148
|
|
|
62
|
-
def __init__(self):
|
|
149
|
+
def __init__(self, quota_tracker: Optional[DailyQuotaTracker] = None):
|
|
63
150
|
self.channel = None
|
|
64
151
|
self.stub = None
|
|
65
152
|
self.connected = False
|
|
66
153
|
self._port: Optional[int] = None
|
|
67
154
|
self._grpc = _try_import_grpc()
|
|
68
155
|
self._pb2, self._pb2_grpc = _try_import_protos()
|
|
156
|
+
self._quota = quota_tracker or get_tracker()
|
|
157
|
+
# Cached plan state — refreshed on every explicit get_credits() call.
|
|
158
|
+
self._cached_credits: Optional[SpliceCredits] = None
|
|
69
159
|
|
|
70
160
|
@property
|
|
71
161
|
def available(self) -> bool:
|
|
@@ -132,14 +222,20 @@ class SpliceGRPCClient:
|
|
|
132
222
|
per_page: int = 20,
|
|
133
223
|
page: int = 1,
|
|
134
224
|
purchased_only: bool = False,
|
|
225
|
+
collection_uuid: str = "",
|
|
226
|
+
file_hash: str = "",
|
|
135
227
|
) -> SpliceSearchResult:
|
|
136
|
-
"""Search Splice catalog. Returns ranked results with full metadata.
|
|
228
|
+
"""Search Splice catalog. Returns ranked results with full metadata.
|
|
229
|
+
|
|
230
|
+
`collection_uuid` scopes search to a single user collection
|
|
231
|
+
(e.g. "Likes", "bass") — pure taste signal when present.
|
|
232
|
+
`file_hash` is a direct lookup for a single sample.
|
|
233
|
+
"""
|
|
137
234
|
if not self.connected:
|
|
138
235
|
return SpliceSearchResult()
|
|
139
236
|
|
|
140
237
|
pb2 = self._pb2
|
|
141
238
|
try:
|
|
142
|
-
# Build search request
|
|
143
239
|
purchased = 0 # All
|
|
144
240
|
if purchased_only:
|
|
145
241
|
purchased = 1 # OnlyPurchased
|
|
@@ -157,6 +253,8 @@ class SpliceGRPCClient:
|
|
|
157
253
|
PerPage=per_page,
|
|
158
254
|
Page=page,
|
|
159
255
|
Purchased=purchased,
|
|
256
|
+
CollectionUUID=collection_uuid,
|
|
257
|
+
FileHash=file_hash,
|
|
160
258
|
)
|
|
161
259
|
response = await self.stub.SearchSamples(request, timeout=SEARCH_TIMEOUT)
|
|
162
260
|
return self._parse_search_response(response)
|
|
@@ -168,54 +266,159 @@ class SpliceGRPCClient:
|
|
|
168
266
|
"""Convert protobuf SearchSampleResponse to our models."""
|
|
169
267
|
samples = []
|
|
170
268
|
for s in response.Samples:
|
|
171
|
-
samples.append(
|
|
172
|
-
file_hash=s.FileHash,
|
|
173
|
-
filename=s.Filename,
|
|
174
|
-
local_path=s.LocalPath,
|
|
175
|
-
audio_key=s.AudioKey,
|
|
176
|
-
chord_type=s.ChordType,
|
|
177
|
-
bpm=s.BPM,
|
|
178
|
-
duration_ms=s.Duration,
|
|
179
|
-
genre=s.Genre,
|
|
180
|
-
sample_type=s.SampleType,
|
|
181
|
-
tags=list(s.Tags),
|
|
182
|
-
provider_name=s.ProviderName,
|
|
183
|
-
pack_uuid=s.PackUUID,
|
|
184
|
-
popularity=s.Popularity,
|
|
185
|
-
is_premium=s.IsPremium,
|
|
186
|
-
preview_url=s.PreviewURL,
|
|
187
|
-
waveform_url=s.WaveformURL,
|
|
188
|
-
is_downloaded=bool(s.LocalPath),
|
|
189
|
-
))
|
|
269
|
+
samples.append(self._parse_sample(s))
|
|
190
270
|
return SpliceSearchResult(
|
|
191
271
|
total_hits=response.TotalHits,
|
|
192
272
|
samples=samples,
|
|
193
273
|
matching_tags=dict(response.MatchingTags),
|
|
194
274
|
)
|
|
195
275
|
|
|
276
|
+
def _parse_sample(self, s) -> SpliceSample:
|
|
277
|
+
"""Convert a single protobuf Sample to our model."""
|
|
278
|
+
return SpliceSample(
|
|
279
|
+
file_hash=s.FileHash,
|
|
280
|
+
filename=s.Filename,
|
|
281
|
+
local_path=s.LocalPath,
|
|
282
|
+
audio_key=s.AudioKey,
|
|
283
|
+
chord_type=s.ChordType,
|
|
284
|
+
bpm=s.BPM,
|
|
285
|
+
duration_ms=s.Duration,
|
|
286
|
+
genre=s.Genre,
|
|
287
|
+
sample_type=s.SampleType,
|
|
288
|
+
tags=list(s.Tags),
|
|
289
|
+
provider_name=s.ProviderName,
|
|
290
|
+
pack_uuid=s.PackUUID,
|
|
291
|
+
popularity=s.Popularity,
|
|
292
|
+
is_premium=s.IsPremium,
|
|
293
|
+
price=s.Price if hasattr(s, "Price") else 0,
|
|
294
|
+
preview_url=s.PreviewURL,
|
|
295
|
+
waveform_url=s.WaveformURL,
|
|
296
|
+
is_downloaded=bool(s.LocalPath),
|
|
297
|
+
)
|
|
298
|
+
|
|
196
299
|
# ── Download ────────────────────────────────────────────────────
|
|
197
300
|
|
|
301
|
+
async def decide_download(
|
|
302
|
+
self,
|
|
303
|
+
file_hash: str,
|
|
304
|
+
sample: Optional[SpliceSample] = None,
|
|
305
|
+
) -> DownloadDecision:
|
|
306
|
+
"""Run plan-aware gating logic for a prospective download.
|
|
307
|
+
|
|
308
|
+
The caller passes the `SpliceSample` when known (from a prior
|
|
309
|
+
search); we use it to detect `is_free` and skip all gating.
|
|
310
|
+
When not known we do NOT fetch the sample — that would waste
|
|
311
|
+
a SampleInfo round-trip. Unknown samples default to paid.
|
|
312
|
+
"""
|
|
313
|
+
if not self.connected:
|
|
314
|
+
return DownloadDecision(
|
|
315
|
+
allowed=False,
|
|
316
|
+
reason="Splice desktop app not reachable",
|
|
317
|
+
plan_kind=PlanKind.UNKNOWN,
|
|
318
|
+
gating_mode="blocked",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Fast path: free samples bypass every gate.
|
|
322
|
+
if sample is not None and sample.is_free:
|
|
323
|
+
return DownloadDecision(
|
|
324
|
+
allowed=True,
|
|
325
|
+
reason=(
|
|
326
|
+
"Sample is free (Price=0 or !IsPremium) — no credit or "
|
|
327
|
+
"quota cost under any plan."
|
|
328
|
+
),
|
|
329
|
+
plan_kind=(
|
|
330
|
+
self._cached_credits.plan_kind
|
|
331
|
+
if self._cached_credits
|
|
332
|
+
else PlanKind.UNKNOWN
|
|
333
|
+
),
|
|
334
|
+
gating_mode="free_sample",
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Refresh plan + credit state for this decision.
|
|
338
|
+
credits = await self.get_credits()
|
|
339
|
+
plan = credits.plan_kind
|
|
340
|
+
|
|
341
|
+
if plan == PlanKind.ABLETON_LIVE:
|
|
342
|
+
quota = self._quota.summary()
|
|
343
|
+
if quota["at_limit"]:
|
|
344
|
+
return DownloadDecision(
|
|
345
|
+
allowed=False,
|
|
346
|
+
reason=(
|
|
347
|
+
f"Daily quota hit ({quota['used_today']}/"
|
|
348
|
+
f"{quota['daily_limit']}). Resets at UTC midnight."
|
|
349
|
+
),
|
|
350
|
+
plan_kind=plan,
|
|
351
|
+
gating_mode="daily_quota",
|
|
352
|
+
credits_remaining=credits.credits,
|
|
353
|
+
quota_used=quota["used_today"],
|
|
354
|
+
quota_remaining=quota["remaining_today"],
|
|
355
|
+
)
|
|
356
|
+
return DownloadDecision(
|
|
357
|
+
allowed=True,
|
|
358
|
+
reason=(
|
|
359
|
+
f"Ableton Live plan: {quota['remaining_today']} of "
|
|
360
|
+
f"{quota['daily_limit']} daily samples remain. Download "
|
|
361
|
+
"will NOT deplete your 80 Splice.com credits."
|
|
362
|
+
),
|
|
363
|
+
plan_kind=plan,
|
|
364
|
+
gating_mode="daily_quota",
|
|
365
|
+
credits_remaining=credits.credits,
|
|
366
|
+
quota_used=quota["used_today"],
|
|
367
|
+
quota_remaining=quota["remaining_today"],
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# Credit-metered plans (SOUNDS_PLUS, CREATOR, CREATOR_PLUS, UNKNOWN).
|
|
371
|
+
# Keep the hard floor to avoid draining the monthly pool.
|
|
372
|
+
can, remaining = await self.can_afford(1, budget=1)
|
|
373
|
+
if not can:
|
|
374
|
+
return DownloadDecision(
|
|
375
|
+
allowed=False,
|
|
376
|
+
reason=(
|
|
377
|
+
f"Credit safety floor hit (remaining={remaining}, "
|
|
378
|
+
f"floor={CREDIT_HARD_FLOOR}). Download would drain "
|
|
379
|
+
"your monthly allotment past the safe reserve."
|
|
380
|
+
),
|
|
381
|
+
plan_kind=plan,
|
|
382
|
+
gating_mode="credit_floor",
|
|
383
|
+
credits_remaining=remaining,
|
|
384
|
+
)
|
|
385
|
+
return DownloadDecision(
|
|
386
|
+
allowed=True,
|
|
387
|
+
reason=(
|
|
388
|
+
f"Credit-metered plan ({plan.value}): {remaining} credits "
|
|
389
|
+
"available, safely above floor."
|
|
390
|
+
),
|
|
391
|
+
plan_kind=plan,
|
|
392
|
+
gating_mode="credit_floor",
|
|
393
|
+
credits_remaining=remaining,
|
|
394
|
+
)
|
|
395
|
+
|
|
198
396
|
async def download_sample(
|
|
199
|
-
self,
|
|
397
|
+
self,
|
|
398
|
+
file_hash: str,
|
|
399
|
+
timeout: float = 30.0,
|
|
400
|
+
sample: Optional[SpliceSample] = None,
|
|
200
401
|
) -> Optional[str]:
|
|
201
402
|
"""Download a sample by file_hash. Returns local path when complete.
|
|
202
403
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
404
|
+
Plan-aware gating:
|
|
405
|
+
- Free samples: always allowed, no counter update.
|
|
406
|
+
- Ableton Live plan: increments daily quota, leaves credits alone.
|
|
407
|
+
- Credit-metered plans: enforces CREDIT_HARD_FLOOR.
|
|
408
|
+
|
|
409
|
+
Callers should prefer `decide_download()` first for a structured
|
|
410
|
+
response that surfaces plan/quota state. This method is the
|
|
411
|
+
imperative "go download it" path; the decision is repeated here
|
|
412
|
+
defensively because a future caller might forget to gate.
|
|
208
413
|
"""
|
|
209
414
|
if not self.connected:
|
|
210
415
|
return None
|
|
211
416
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if not can:
|
|
417
|
+
decision = await self.decide_download(file_hash, sample=sample)
|
|
418
|
+
if not decision.allowed:
|
|
215
419
|
logger.warning(
|
|
216
|
-
"Splice download
|
|
217
|
-
|
|
218
|
-
remaining, CREDIT_HARD_FLOOR, file_hash,
|
|
420
|
+
"Splice download refused: %s (plan=%s, mode=%s)",
|
|
421
|
+
decision.reason, decision.plan_kind.value, decision.gating_mode,
|
|
219
422
|
)
|
|
220
423
|
return None
|
|
221
424
|
|
|
@@ -227,19 +430,33 @@ class SpliceGRPCClient:
|
|
|
227
430
|
timeout=DOWNLOAD_TRIGGER_TIMEOUT,
|
|
228
431
|
)
|
|
229
432
|
# Wait for file to appear on disk
|
|
230
|
-
|
|
433
|
+
local_path = await self._wait_for_download(file_hash, timeout)
|
|
231
434
|
except Exception as exc:
|
|
232
435
|
logger.warning(f"Splice download failed for {file_hash}: {exc}")
|
|
233
436
|
return None
|
|
234
437
|
|
|
438
|
+
if local_path is None:
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
# Record against the daily quota IF this was a quota-metered download.
|
|
442
|
+
# Free samples don't count; credit-metered samples are tracked by
|
|
443
|
+
# Splice server-side (our credit count will reflect on next fetch).
|
|
444
|
+
if decision.gating_mode == "daily_quota":
|
|
445
|
+
try:
|
|
446
|
+
self._quota.record_download(
|
|
447
|
+
file_hash=file_hash,
|
|
448
|
+
filename=os.path.basename(local_path),
|
|
449
|
+
)
|
|
450
|
+
except Exception as exc:
|
|
451
|
+
logger.debug("quota record_download failed: %s", exc)
|
|
452
|
+
|
|
453
|
+
return local_path
|
|
454
|
+
|
|
235
455
|
async def _wait_for_download(
|
|
236
456
|
self, file_hash: str, timeout: float,
|
|
237
457
|
) -> Optional[str]:
|
|
238
458
|
"""Poll SampleInfo until LocalPath is populated."""
|
|
239
459
|
pb2 = self._pb2
|
|
240
|
-
# asyncio.get_event_loop() is deprecated when called inside an
|
|
241
|
-
# already-running coroutine on Python 3.10+. Use get_running_loop()
|
|
242
|
-
# which is the documented replacement.
|
|
243
460
|
loop = asyncio.get_running_loop()
|
|
244
461
|
deadline = loop.time() + timeout
|
|
245
462
|
while loop.time() < deadline:
|
|
@@ -269,30 +486,15 @@ class SpliceGRPCClient:
|
|
|
269
486
|
pb2.SampleInfoRequest(FileHash=file_hash),
|
|
270
487
|
timeout=INFO_TIMEOUT,
|
|
271
488
|
)
|
|
272
|
-
|
|
273
|
-
return SpliceSample(
|
|
274
|
-
file_hash=s.FileHash,
|
|
275
|
-
filename=s.Filename,
|
|
276
|
-
local_path=s.LocalPath,
|
|
277
|
-
audio_key=s.AudioKey,
|
|
278
|
-
chord_type=s.ChordType,
|
|
279
|
-
bpm=s.BPM,
|
|
280
|
-
duration_ms=s.Duration,
|
|
281
|
-
genre=s.Genre,
|
|
282
|
-
sample_type=s.SampleType,
|
|
283
|
-
tags=list(s.Tags),
|
|
284
|
-
provider_name=s.ProviderName,
|
|
285
|
-
pack_uuid=s.PackUUID,
|
|
286
|
-
is_downloaded=bool(s.LocalPath),
|
|
287
|
-
)
|
|
489
|
+
return self._parse_sample(response.Sample)
|
|
288
490
|
except Exception as exc:
|
|
289
491
|
logger.warning(f"SampleInfo failed: {exc}")
|
|
290
492
|
return None
|
|
291
493
|
|
|
292
|
-
# ── Credits
|
|
494
|
+
# ── Credits + Plan ──────────────────────────────────────────────
|
|
293
495
|
|
|
294
496
|
async def get_credits(self) -> SpliceCredits:
|
|
295
|
-
"""Get current credit balance and
|
|
497
|
+
"""Get current credit balance, plan, and feature-flag map."""
|
|
296
498
|
if not self.connected:
|
|
297
499
|
return SpliceCredits()
|
|
298
500
|
|
|
@@ -302,19 +504,44 @@ class SpliceGRPCClient:
|
|
|
302
504
|
pb2.ValidateLoginRequest(),
|
|
303
505
|
timeout=CREDITS_TIMEOUT,
|
|
304
506
|
)
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
507
|
+
user = response.User
|
|
508
|
+
features = dict(user.Features) if hasattr(user, "Features") else {}
|
|
509
|
+
sounds_plan = (
|
|
510
|
+
int(user.SoundsPlan) if hasattr(user, "SoundsPlan") else 0
|
|
309
511
|
)
|
|
512
|
+
uuid_str = str(user.UUID) if hasattr(user, "UUID") else ""
|
|
513
|
+
# Read optional plan_kind_override from ~/.livepilot/splice.json.
|
|
514
|
+
# Users who know their Splice plan can pin the classification
|
|
515
|
+
# here when the gRPC data is ambiguous. See models.classify_plan.
|
|
516
|
+
override = _read_plan_kind_override()
|
|
517
|
+
plan_kind = classify_plan(
|
|
518
|
+
sounds_status=user.SoundsStatus,
|
|
519
|
+
sounds_plan=sounds_plan,
|
|
520
|
+
features=features,
|
|
521
|
+
override=override,
|
|
522
|
+
)
|
|
523
|
+
creds = SpliceCredits(
|
|
524
|
+
credits=user.Credits,
|
|
525
|
+
username=user.Username,
|
|
526
|
+
plan=user.SoundsStatus,
|
|
527
|
+
sounds_plan_id=sounds_plan,
|
|
528
|
+
features=features,
|
|
529
|
+
plan_kind=plan_kind,
|
|
530
|
+
user_uuid=uuid_str,
|
|
531
|
+
)
|
|
532
|
+
self._cached_credits = creds
|
|
533
|
+
return creds
|
|
310
534
|
except Exception as exc:
|
|
311
535
|
logger.warning(f"Credit check failed: {exc}")
|
|
312
536
|
return SpliceCredits()
|
|
313
537
|
|
|
314
538
|
async def can_afford(self, credits_needed: int, budget: int) -> tuple[bool, int]:
|
|
315
|
-
"""Check if we can afford credits_needed within budget
|
|
539
|
+
"""Check if we can afford `credits_needed` within `budget` for
|
|
540
|
+
credit-metered plans.
|
|
316
541
|
|
|
317
|
-
Returns (can_afford, credits_remaining).
|
|
542
|
+
Returns (can_afford, credits_remaining). NOTE: does NOT consult the
|
|
543
|
+
daily quota — callers on the Ableton Live plan should use
|
|
544
|
+
`decide_download()` instead of `can_afford()`.
|
|
318
545
|
"""
|
|
319
546
|
info = await self.get_credits()
|
|
320
547
|
remaining = info.credits
|
|
@@ -341,6 +568,297 @@ class SpliceGRPCClient:
|
|
|
341
568
|
except Exception as exc:
|
|
342
569
|
logger.debug("sync_sounds failed: %s", exc)
|
|
343
570
|
return False
|
|
571
|
+
|
|
572
|
+
# ── Collections ─────────────────────────────────────────────────
|
|
573
|
+
|
|
574
|
+
def _parse_collection(self, c) -> SpliceCollection:
|
|
575
|
+
creator_username = ""
|
|
576
|
+
try:
|
|
577
|
+
creator_username = c.Creator.Username
|
|
578
|
+
except AttributeError:
|
|
579
|
+
pass
|
|
580
|
+
access_map = {0: "unspecified", 1: "private", 2: "public"}
|
|
581
|
+
access = access_map.get(
|
|
582
|
+
int(c.Access) if hasattr(c, "Access") else 0, "unspecified",
|
|
583
|
+
)
|
|
584
|
+
return SpliceCollection(
|
|
585
|
+
uuid=c.UUID,
|
|
586
|
+
name=c.Name,
|
|
587
|
+
description=c.Description,
|
|
588
|
+
access=access,
|
|
589
|
+
permalink=c.Permalink,
|
|
590
|
+
cover_url=c.CoverURL,
|
|
591
|
+
sample_count=int(c.SampleCount),
|
|
592
|
+
preset_count=int(c.PresetCount),
|
|
593
|
+
pack_count=int(c.PackCount),
|
|
594
|
+
subscription_count=int(c.SubscriptionCount),
|
|
595
|
+
created_by_current_user=bool(c.CreatedByCurrentUser),
|
|
596
|
+
creator_username=creator_username,
|
|
597
|
+
created_at=c.CreatedAt,
|
|
598
|
+
updated_at=c.UpdatedAt,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
async def list_collections(
|
|
602
|
+
self, page: int = 1, per_page: int = 50,
|
|
603
|
+
) -> tuple[int, list[SpliceCollection]]:
|
|
604
|
+
"""List the user's collections. Returns (total_count, collections)."""
|
|
605
|
+
if not self.connected:
|
|
606
|
+
return 0, []
|
|
607
|
+
pb2 = self._pb2
|
|
608
|
+
try:
|
|
609
|
+
response = await self.stub.CollectionsList(
|
|
610
|
+
pb2.CollectionsListRequest(Page=page, PerPage=per_page),
|
|
611
|
+
timeout=COLLECTION_TIMEOUT,
|
|
612
|
+
)
|
|
613
|
+
total = int(response.TotalCount)
|
|
614
|
+
collections = [self._parse_collection(c) for c in response.Collections]
|
|
615
|
+
return total, collections
|
|
616
|
+
except Exception as exc:
|
|
617
|
+
logger.warning(f"CollectionsList failed: {exc}")
|
|
618
|
+
return 0, []
|
|
619
|
+
|
|
620
|
+
async def collection_samples(
|
|
621
|
+
self, uuid: str, page: int = 1, per_page: int = 50,
|
|
622
|
+
) -> tuple[int, list[SpliceSample]]:
|
|
623
|
+
"""List samples inside a collection. Returns (total_hits, samples)."""
|
|
624
|
+
if not self.connected:
|
|
625
|
+
return 0, []
|
|
626
|
+
pb2 = self._pb2
|
|
627
|
+
try:
|
|
628
|
+
response = await self.stub.CollectionListSamples(
|
|
629
|
+
pb2.CollectionListSamplesRequest(
|
|
630
|
+
UUID=uuid, Page=page, PerPage=per_page,
|
|
631
|
+
),
|
|
632
|
+
timeout=COLLECTION_TIMEOUT,
|
|
633
|
+
)
|
|
634
|
+
total = int(response.TotalHits)
|
|
635
|
+
samples = [self._parse_sample(s) for s in response.Samples]
|
|
636
|
+
return total, samples
|
|
637
|
+
except Exception as exc:
|
|
638
|
+
logger.warning(f"CollectionListSamples failed: {exc}")
|
|
639
|
+
return 0, []
|
|
640
|
+
|
|
641
|
+
async def add_to_collection(self, uuid: str, sample_hashes: list[str]) -> bool:
|
|
642
|
+
"""Add samples to a collection. Returns True on success."""
|
|
643
|
+
if not self.connected or not sample_hashes:
|
|
644
|
+
return False
|
|
645
|
+
pb2 = self._pb2
|
|
646
|
+
try:
|
|
647
|
+
await self.stub.CollectionAddItems(
|
|
648
|
+
pb2.CollectionAddItemsRequest(UUID=uuid, Samples=sample_hashes),
|
|
649
|
+
timeout=COLLECTION_TIMEOUT,
|
|
650
|
+
)
|
|
651
|
+
return True
|
|
652
|
+
except Exception as exc:
|
|
653
|
+
logger.warning(f"CollectionAddItems failed: {exc}")
|
|
654
|
+
return False
|
|
655
|
+
|
|
656
|
+
async def remove_from_collection(
|
|
657
|
+
self, uuid: str, sample_hashes: list[str],
|
|
658
|
+
) -> bool:
|
|
659
|
+
"""Remove samples from a collection."""
|
|
660
|
+
if not self.connected or not sample_hashes:
|
|
661
|
+
return False
|
|
662
|
+
pb2 = self._pb2
|
|
663
|
+
try:
|
|
664
|
+
await self.stub.CollectionDeleteItems(
|
|
665
|
+
pb2.CollectionDeleteItemsRequest(UUID=uuid, Samples=sample_hashes),
|
|
666
|
+
timeout=COLLECTION_TIMEOUT,
|
|
667
|
+
)
|
|
668
|
+
return True
|
|
669
|
+
except Exception as exc:
|
|
670
|
+
logger.warning(f"CollectionDeleteItems failed: {exc}")
|
|
671
|
+
return False
|
|
672
|
+
|
|
673
|
+
async def create_collection(self, name: str) -> Optional[SpliceCollection]:
|
|
674
|
+
"""Create a new user collection. Returns the new Collection or None."""
|
|
675
|
+
if not self.connected:
|
|
676
|
+
return None
|
|
677
|
+
pb2 = self._pb2
|
|
678
|
+
try:
|
|
679
|
+
response = await self.stub.CollectionAdd(
|
|
680
|
+
pb2.CollectionAddRequest(Name=name),
|
|
681
|
+
timeout=COLLECTION_TIMEOUT,
|
|
682
|
+
)
|
|
683
|
+
return self._parse_collection(response.Collection)
|
|
684
|
+
except Exception as exc:
|
|
685
|
+
logger.warning(f"CollectionAdd failed: {exc}")
|
|
686
|
+
return None
|
|
687
|
+
|
|
688
|
+
# ── Packs ───────────────────────────────────────────────────────
|
|
689
|
+
|
|
690
|
+
async def get_pack_info(
|
|
691
|
+
self, pack_uuid: str, max_pages: int = 5,
|
|
692
|
+
) -> tuple[Optional[SplicePack], Optional[str]]:
|
|
693
|
+
"""Fetch metadata for a single sample pack.
|
|
694
|
+
|
|
695
|
+
Splice's gRPC `App` service does NOT expose a per-UUID
|
|
696
|
+
`SamplePackInfo` RPC (only `ListSamplePacks` is published in the
|
|
697
|
+
service definition — the `SamplePackInfoRequest` / `...Response`
|
|
698
|
+
messages exist in the descriptor but no RPC binds them). So this
|
|
699
|
+
implementation paginates `ListSamplePacks` and matches client-side.
|
|
700
|
+
|
|
701
|
+
Only finds packs the user has engaged with (owned / downloaded /
|
|
702
|
+
in their library). Catalog-only packs return None with an
|
|
703
|
+
explanatory error.
|
|
704
|
+
|
|
705
|
+
Returns (pack, error) — `error` is a user-readable string when the
|
|
706
|
+
lookup didn't find a match.
|
|
707
|
+
"""
|
|
708
|
+
if not self.connected:
|
|
709
|
+
return None, "Splice gRPC not connected"
|
|
710
|
+
pb2 = self._pb2
|
|
711
|
+
target = pack_uuid.strip()
|
|
712
|
+
# Splice uses two UUID formats: canonical 36-char and an "extended"
|
|
713
|
+
# form with a longer last group (observed 43 chars, e.g.
|
|
714
|
+
# "1170db75-0ce1-5280-bb61-887a0dd7f26bf5a3951"). Both variants
|
|
715
|
+
# appear in sounds.db and search results. We match BOTH when the
|
|
716
|
+
# caller submits one form — the other form might be the one the
|
|
717
|
+
# server returns for the same pack. Observed 2026-04-22.
|
|
718
|
+
canonical = target[:36] if len(target) > 36 else target
|
|
719
|
+
targets = {target, canonical}
|
|
720
|
+
next_token = 0
|
|
721
|
+
try:
|
|
722
|
+
for _page in range(max(1, int(max_pages))):
|
|
723
|
+
response = await self.stub.ListSamplePacks(
|
|
724
|
+
pb2.ListSamplePacksRequest(NextToken=next_token),
|
|
725
|
+
timeout=INFO_TIMEOUT,
|
|
726
|
+
)
|
|
727
|
+
for p in response.SamplePacks:
|
|
728
|
+
p_uuid = p.UUID
|
|
729
|
+
p_canonical = p_uuid[:36] if len(p_uuid) > 36 else p_uuid
|
|
730
|
+
if p_uuid in targets or p_canonical in targets:
|
|
731
|
+
return SplicePack(
|
|
732
|
+
uuid=p_uuid,
|
|
733
|
+
name=p.Name,
|
|
734
|
+
cover_url=p.CoverURL,
|
|
735
|
+
genre=p.Genre,
|
|
736
|
+
permalink=p.Permalink,
|
|
737
|
+
provider_name=p.ProviderName,
|
|
738
|
+
), None
|
|
739
|
+
# If no next-page token, we've exhausted the list.
|
|
740
|
+
new_token = int(response.NextToken)
|
|
741
|
+
if new_token == 0 or new_token == next_token:
|
|
742
|
+
break
|
|
743
|
+
next_token = new_token
|
|
744
|
+
except Exception as exc:
|
|
745
|
+
msg = f"ListSamplePacks gRPC call failed: {type(exc).__name__}: {exc}"
|
|
746
|
+
logger.warning(msg)
|
|
747
|
+
return None, msg
|
|
748
|
+
return None, (
|
|
749
|
+
f"Pack '{target}' not found in the user's library. "
|
|
750
|
+
"Splice's gRPC only lists packs the user has engaged with "
|
|
751
|
+
"(owned/downloaded/in library). Catalog-only packs can't be "
|
|
752
|
+
"looked up via this RPC. Use the Splice website or Desktop app "
|
|
753
|
+
"to browse un-engaged packs."
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
# ── Presets ─────────────────────────────────────────────────────
|
|
757
|
+
|
|
758
|
+
def _parse_preset(self, p) -> SplicePreset:
|
|
759
|
+
return SplicePreset(
|
|
760
|
+
uuid=p.UUID,
|
|
761
|
+
file_hash=p.FileHash,
|
|
762
|
+
filename=p.Filename,
|
|
763
|
+
local_path=p.LocalPath,
|
|
764
|
+
tags=list(p.Tags),
|
|
765
|
+
price=int(p.Price),
|
|
766
|
+
is_default=bool(p.IsDefault),
|
|
767
|
+
plugin_name=p.PluginName,
|
|
768
|
+
plugin_version=p.PluginVersion,
|
|
769
|
+
provider_name=p.ProviderName,
|
|
770
|
+
pack_uuid=p.Pack.UUID if hasattr(p, "Pack") else "",
|
|
771
|
+
preview_url=p.PreviewURL,
|
|
772
|
+
purchased_at=int(p.PurchasedAt) if hasattr(p, "PurchasedAt") else 0,
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
async def list_purchased_presets(
|
|
776
|
+
self,
|
|
777
|
+
page: int = 1,
|
|
778
|
+
per_page: int = 50,
|
|
779
|
+
sort: str = "",
|
|
780
|
+
sort_order: str = "",
|
|
781
|
+
) -> tuple[int, list[SplicePreset]]:
|
|
782
|
+
"""List presets the user has purchased/owns."""
|
|
783
|
+
if not self.connected:
|
|
784
|
+
return 0, []
|
|
785
|
+
pb2 = self._pb2
|
|
786
|
+
try:
|
|
787
|
+
response = await self.stub.PresetsListPurchased(
|
|
788
|
+
pb2.PresetsListPurchasedRequest(
|
|
789
|
+
Page=page, PerPage=per_page,
|
|
790
|
+
SortFn=sort, SortOrder=sort_order,
|
|
791
|
+
),
|
|
792
|
+
timeout=PRESET_TIMEOUT,
|
|
793
|
+
)
|
|
794
|
+
total = int(response.TotalHits)
|
|
795
|
+
presets = [self._parse_preset(p) for p in response.Presets]
|
|
796
|
+
return total, presets
|
|
797
|
+
except Exception as exc:
|
|
798
|
+
logger.warning(f"PresetsListPurchased failed: {exc}")
|
|
799
|
+
return 0, []
|
|
800
|
+
|
|
801
|
+
async def get_preset_info(
|
|
802
|
+
self, uuid: str = "", file_hash: str = "", plugin_name: str = "",
|
|
803
|
+
) -> Optional[dict]:
|
|
804
|
+
"""Fetch metadata for a single preset."""
|
|
805
|
+
if not self.connected:
|
|
806
|
+
return None
|
|
807
|
+
pb2 = self._pb2
|
|
808
|
+
try:
|
|
809
|
+
response = await self.stub.PresetInfo(
|
|
810
|
+
pb2.PresetInfoRequest(
|
|
811
|
+
UUID=uuid, FileHash=file_hash, PluginName=plugin_name,
|
|
812
|
+
),
|
|
813
|
+
timeout=PRESET_TIMEOUT,
|
|
814
|
+
)
|
|
815
|
+
return {
|
|
816
|
+
"uuid": response.Preset.UUID,
|
|
817
|
+
"file_hash": response.Preset.FileHash,
|
|
818
|
+
"local_path": response.Preset.LocalPath,
|
|
819
|
+
}
|
|
820
|
+
except Exception as exc:
|
|
821
|
+
logger.warning(f"PresetInfo failed: {exc}")
|
|
822
|
+
return None
|
|
823
|
+
|
|
824
|
+
async def download_preset(self, uuid: str) -> bool:
|
|
825
|
+
"""Trigger a preset download (uses credits)."""
|
|
826
|
+
if not self.connected:
|
|
827
|
+
return False
|
|
828
|
+
pb2 = self._pb2
|
|
829
|
+
try:
|
|
830
|
+
await self.stub.PresetDownload(
|
|
831
|
+
pb2.PresetDownloadRequest(UUID=uuid),
|
|
832
|
+
timeout=PRESET_TIMEOUT,
|
|
833
|
+
)
|
|
834
|
+
return True
|
|
835
|
+
except Exception as exc:
|
|
836
|
+
logger.warning(f"PresetDownload failed: {exc}")
|
|
837
|
+
return False
|
|
838
|
+
|
|
839
|
+
# ── Convert to WAV ──────────────────────────────────────────────
|
|
840
|
+
|
|
841
|
+
async def convert_to_wav(self, path: str) -> Optional[dict]:
|
|
842
|
+
"""Convert an audio file to PCM WAV via Splice's converter."""
|
|
843
|
+
if not self.connected:
|
|
844
|
+
return None
|
|
845
|
+
pb2 = self._pb2
|
|
846
|
+
try:
|
|
847
|
+
response = await self.stub.ConvertToWav(
|
|
848
|
+
pb2.ConvertToWavRequest(Path=path),
|
|
849
|
+
timeout=CONVERT_TIMEOUT,
|
|
850
|
+
)
|
|
851
|
+
wav = response.WavFile
|
|
852
|
+
return {
|
|
853
|
+
"path": wav.Path,
|
|
854
|
+
"channels": int(wav.Channels),
|
|
855
|
+
"sample_rate": int(wav.SampleRate),
|
|
856
|
+
"bit_depth": int(wav.BitDepth),
|
|
857
|
+
}
|
|
858
|
+
except Exception as exc:
|
|
859
|
+
logger.warning(f"ConvertToWav failed: {exc}")
|
|
860
|
+
return None
|
|
861
|
+
|
|
344
862
|
# ── Connection Helpers ──────────────────────────────────────────
|
|
345
863
|
|
|
346
864
|
def _read_port(self) -> Optional[int]:
|