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
|
@@ -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():
|
|
@@ -59,13 +122,16 @@ def _try_import_protos():
|
|
|
59
122
|
class SpliceGRPCClient:
|
|
60
123
|
"""Async gRPC client for Splice desktop's App service."""
|
|
61
124
|
|
|
62
|
-
def __init__(self):
|
|
125
|
+
def __init__(self, quota_tracker: Optional[DailyQuotaTracker] = None):
|
|
63
126
|
self.channel = None
|
|
64
127
|
self.stub = None
|
|
65
128
|
self.connected = False
|
|
66
129
|
self._port: Optional[int] = None
|
|
67
130
|
self._grpc = _try_import_grpc()
|
|
68
131
|
self._pb2, self._pb2_grpc = _try_import_protos()
|
|
132
|
+
self._quota = quota_tracker or get_tracker()
|
|
133
|
+
# Cached plan state — refreshed on every explicit get_credits() call.
|
|
134
|
+
self._cached_credits: Optional[SpliceCredits] = None
|
|
69
135
|
|
|
70
136
|
@property
|
|
71
137
|
def available(self) -> bool:
|
|
@@ -132,14 +198,20 @@ class SpliceGRPCClient:
|
|
|
132
198
|
per_page: int = 20,
|
|
133
199
|
page: int = 1,
|
|
134
200
|
purchased_only: bool = False,
|
|
201
|
+
collection_uuid: str = "",
|
|
202
|
+
file_hash: str = "",
|
|
135
203
|
) -> SpliceSearchResult:
|
|
136
|
-
"""Search Splice catalog. Returns ranked results with full metadata.
|
|
204
|
+
"""Search Splice catalog. Returns ranked results with full metadata.
|
|
205
|
+
|
|
206
|
+
`collection_uuid` scopes search to a single user collection
|
|
207
|
+
(e.g. "Likes", "bass") — pure taste signal when present.
|
|
208
|
+
`file_hash` is a direct lookup for a single sample.
|
|
209
|
+
"""
|
|
137
210
|
if not self.connected:
|
|
138
211
|
return SpliceSearchResult()
|
|
139
212
|
|
|
140
213
|
pb2 = self._pb2
|
|
141
214
|
try:
|
|
142
|
-
# Build search request
|
|
143
215
|
purchased = 0 # All
|
|
144
216
|
if purchased_only:
|
|
145
217
|
purchased = 1 # OnlyPurchased
|
|
@@ -157,6 +229,8 @@ class SpliceGRPCClient:
|
|
|
157
229
|
PerPage=per_page,
|
|
158
230
|
Page=page,
|
|
159
231
|
Purchased=purchased,
|
|
232
|
+
CollectionUUID=collection_uuid,
|
|
233
|
+
FileHash=file_hash,
|
|
160
234
|
)
|
|
161
235
|
response = await self.stub.SearchSamples(request, timeout=SEARCH_TIMEOUT)
|
|
162
236
|
return self._parse_search_response(response)
|
|
@@ -168,54 +242,159 @@ class SpliceGRPCClient:
|
|
|
168
242
|
"""Convert protobuf SearchSampleResponse to our models."""
|
|
169
243
|
samples = []
|
|
170
244
|
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
|
-
))
|
|
245
|
+
samples.append(self._parse_sample(s))
|
|
190
246
|
return SpliceSearchResult(
|
|
191
247
|
total_hits=response.TotalHits,
|
|
192
248
|
samples=samples,
|
|
193
249
|
matching_tags=dict(response.MatchingTags),
|
|
194
250
|
)
|
|
195
251
|
|
|
252
|
+
def _parse_sample(self, s) -> SpliceSample:
|
|
253
|
+
"""Convert a single protobuf Sample to our model."""
|
|
254
|
+
return SpliceSample(
|
|
255
|
+
file_hash=s.FileHash,
|
|
256
|
+
filename=s.Filename,
|
|
257
|
+
local_path=s.LocalPath,
|
|
258
|
+
audio_key=s.AudioKey,
|
|
259
|
+
chord_type=s.ChordType,
|
|
260
|
+
bpm=s.BPM,
|
|
261
|
+
duration_ms=s.Duration,
|
|
262
|
+
genre=s.Genre,
|
|
263
|
+
sample_type=s.SampleType,
|
|
264
|
+
tags=list(s.Tags),
|
|
265
|
+
provider_name=s.ProviderName,
|
|
266
|
+
pack_uuid=s.PackUUID,
|
|
267
|
+
popularity=s.Popularity,
|
|
268
|
+
is_premium=s.IsPremium,
|
|
269
|
+
price=s.Price if hasattr(s, "Price") else 0,
|
|
270
|
+
preview_url=s.PreviewURL,
|
|
271
|
+
waveform_url=s.WaveformURL,
|
|
272
|
+
is_downloaded=bool(s.LocalPath),
|
|
273
|
+
)
|
|
274
|
+
|
|
196
275
|
# ── Download ────────────────────────────────────────────────────
|
|
197
276
|
|
|
277
|
+
async def decide_download(
|
|
278
|
+
self,
|
|
279
|
+
file_hash: str,
|
|
280
|
+
sample: Optional[SpliceSample] = None,
|
|
281
|
+
) -> DownloadDecision:
|
|
282
|
+
"""Run plan-aware gating logic for a prospective download.
|
|
283
|
+
|
|
284
|
+
The caller passes the `SpliceSample` when known (from a prior
|
|
285
|
+
search); we use it to detect `is_free` and skip all gating.
|
|
286
|
+
When not known we do NOT fetch the sample — that would waste
|
|
287
|
+
a SampleInfo round-trip. Unknown samples default to paid.
|
|
288
|
+
"""
|
|
289
|
+
if not self.connected:
|
|
290
|
+
return DownloadDecision(
|
|
291
|
+
allowed=False,
|
|
292
|
+
reason="Splice desktop app not reachable",
|
|
293
|
+
plan_kind=PlanKind.UNKNOWN,
|
|
294
|
+
gating_mode="blocked",
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Fast path: free samples bypass every gate.
|
|
298
|
+
if sample is not None and sample.is_free:
|
|
299
|
+
return DownloadDecision(
|
|
300
|
+
allowed=True,
|
|
301
|
+
reason=(
|
|
302
|
+
"Sample is free (Price=0 or !IsPremium) — no credit or "
|
|
303
|
+
"quota cost under any plan."
|
|
304
|
+
),
|
|
305
|
+
plan_kind=(
|
|
306
|
+
self._cached_credits.plan_kind
|
|
307
|
+
if self._cached_credits
|
|
308
|
+
else PlanKind.UNKNOWN
|
|
309
|
+
),
|
|
310
|
+
gating_mode="free_sample",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Refresh plan + credit state for this decision.
|
|
314
|
+
credits = await self.get_credits()
|
|
315
|
+
plan = credits.plan_kind
|
|
316
|
+
|
|
317
|
+
if plan == PlanKind.ABLETON_LIVE:
|
|
318
|
+
quota = self._quota.summary()
|
|
319
|
+
if quota["at_limit"]:
|
|
320
|
+
return DownloadDecision(
|
|
321
|
+
allowed=False,
|
|
322
|
+
reason=(
|
|
323
|
+
f"Daily quota hit ({quota['used_today']}/"
|
|
324
|
+
f"{quota['daily_limit']}). Resets at UTC midnight."
|
|
325
|
+
),
|
|
326
|
+
plan_kind=plan,
|
|
327
|
+
gating_mode="daily_quota",
|
|
328
|
+
credits_remaining=credits.credits,
|
|
329
|
+
quota_used=quota["used_today"],
|
|
330
|
+
quota_remaining=quota["remaining_today"],
|
|
331
|
+
)
|
|
332
|
+
return DownloadDecision(
|
|
333
|
+
allowed=True,
|
|
334
|
+
reason=(
|
|
335
|
+
f"Ableton Live plan: {quota['remaining_today']} of "
|
|
336
|
+
f"{quota['daily_limit']} daily samples remain. Download "
|
|
337
|
+
"will NOT deplete your 80 Splice.com credits."
|
|
338
|
+
),
|
|
339
|
+
plan_kind=plan,
|
|
340
|
+
gating_mode="daily_quota",
|
|
341
|
+
credits_remaining=credits.credits,
|
|
342
|
+
quota_used=quota["used_today"],
|
|
343
|
+
quota_remaining=quota["remaining_today"],
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Credit-metered plans (SOUNDS_PLUS, CREATOR, CREATOR_PLUS, UNKNOWN).
|
|
347
|
+
# Keep the hard floor to avoid draining the monthly pool.
|
|
348
|
+
can, remaining = await self.can_afford(1, budget=1)
|
|
349
|
+
if not can:
|
|
350
|
+
return DownloadDecision(
|
|
351
|
+
allowed=False,
|
|
352
|
+
reason=(
|
|
353
|
+
f"Credit safety floor hit (remaining={remaining}, "
|
|
354
|
+
f"floor={CREDIT_HARD_FLOOR}). Download would drain "
|
|
355
|
+
"your monthly allotment past the safe reserve."
|
|
356
|
+
),
|
|
357
|
+
plan_kind=plan,
|
|
358
|
+
gating_mode="credit_floor",
|
|
359
|
+
credits_remaining=remaining,
|
|
360
|
+
)
|
|
361
|
+
return DownloadDecision(
|
|
362
|
+
allowed=True,
|
|
363
|
+
reason=(
|
|
364
|
+
f"Credit-metered plan ({plan.value}): {remaining} credits "
|
|
365
|
+
"available, safely above floor."
|
|
366
|
+
),
|
|
367
|
+
plan_kind=plan,
|
|
368
|
+
gating_mode="credit_floor",
|
|
369
|
+
credits_remaining=remaining,
|
|
370
|
+
)
|
|
371
|
+
|
|
198
372
|
async def download_sample(
|
|
199
|
-
self,
|
|
373
|
+
self,
|
|
374
|
+
file_hash: str,
|
|
375
|
+
timeout: float = 30.0,
|
|
376
|
+
sample: Optional[SpliceSample] = None,
|
|
200
377
|
) -> Optional[str]:
|
|
201
378
|
"""Download a sample by file_hash. Returns local path when complete.
|
|
202
379
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
380
|
+
Plan-aware gating:
|
|
381
|
+
- Free samples: always allowed, no counter update.
|
|
382
|
+
- Ableton Live plan: increments daily quota, leaves credits alone.
|
|
383
|
+
- Credit-metered plans: enforces CREDIT_HARD_FLOOR.
|
|
384
|
+
|
|
385
|
+
Callers should prefer `decide_download()` first for a structured
|
|
386
|
+
response that surfaces plan/quota state. This method is the
|
|
387
|
+
imperative "go download it" path; the decision is repeated here
|
|
388
|
+
defensively because a future caller might forget to gate.
|
|
208
389
|
"""
|
|
209
390
|
if not self.connected:
|
|
210
391
|
return None
|
|
211
392
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if not can:
|
|
393
|
+
decision = await self.decide_download(file_hash, sample=sample)
|
|
394
|
+
if not decision.allowed:
|
|
215
395
|
logger.warning(
|
|
216
|
-
"Splice download
|
|
217
|
-
|
|
218
|
-
remaining, CREDIT_HARD_FLOOR, file_hash,
|
|
396
|
+
"Splice download refused: %s (plan=%s, mode=%s)",
|
|
397
|
+
decision.reason, decision.plan_kind.value, decision.gating_mode,
|
|
219
398
|
)
|
|
220
399
|
return None
|
|
221
400
|
|
|
@@ -227,19 +406,33 @@ class SpliceGRPCClient:
|
|
|
227
406
|
timeout=DOWNLOAD_TRIGGER_TIMEOUT,
|
|
228
407
|
)
|
|
229
408
|
# Wait for file to appear on disk
|
|
230
|
-
|
|
409
|
+
local_path = await self._wait_for_download(file_hash, timeout)
|
|
231
410
|
except Exception as exc:
|
|
232
411
|
logger.warning(f"Splice download failed for {file_hash}: {exc}")
|
|
233
412
|
return None
|
|
234
413
|
|
|
414
|
+
if local_path is None:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
# Record against the daily quota IF this was a quota-metered download.
|
|
418
|
+
# Free samples don't count; credit-metered samples are tracked by
|
|
419
|
+
# Splice server-side (our credit count will reflect on next fetch).
|
|
420
|
+
if decision.gating_mode == "daily_quota":
|
|
421
|
+
try:
|
|
422
|
+
self._quota.record_download(
|
|
423
|
+
file_hash=file_hash,
|
|
424
|
+
filename=os.path.basename(local_path),
|
|
425
|
+
)
|
|
426
|
+
except Exception as exc:
|
|
427
|
+
logger.debug("quota record_download failed: %s", exc)
|
|
428
|
+
|
|
429
|
+
return local_path
|
|
430
|
+
|
|
235
431
|
async def _wait_for_download(
|
|
236
432
|
self, file_hash: str, timeout: float,
|
|
237
433
|
) -> Optional[str]:
|
|
238
434
|
"""Poll SampleInfo until LocalPath is populated."""
|
|
239
435
|
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
436
|
loop = asyncio.get_running_loop()
|
|
244
437
|
deadline = loop.time() + timeout
|
|
245
438
|
while loop.time() < deadline:
|
|
@@ -269,30 +462,15 @@ class SpliceGRPCClient:
|
|
|
269
462
|
pb2.SampleInfoRequest(FileHash=file_hash),
|
|
270
463
|
timeout=INFO_TIMEOUT,
|
|
271
464
|
)
|
|
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
|
-
)
|
|
465
|
+
return self._parse_sample(response.Sample)
|
|
288
466
|
except Exception as exc:
|
|
289
467
|
logger.warning(f"SampleInfo failed: {exc}")
|
|
290
468
|
return None
|
|
291
469
|
|
|
292
|
-
# ── Credits
|
|
470
|
+
# ── Credits + Plan ──────────────────────────────────────────────
|
|
293
471
|
|
|
294
472
|
async def get_credits(self) -> SpliceCredits:
|
|
295
|
-
"""Get current credit balance and
|
|
473
|
+
"""Get current credit balance, plan, and feature-flag map."""
|
|
296
474
|
if not self.connected:
|
|
297
475
|
return SpliceCredits()
|
|
298
476
|
|
|
@@ -302,19 +480,39 @@ class SpliceGRPCClient:
|
|
|
302
480
|
pb2.ValidateLoginRequest(),
|
|
303
481
|
timeout=CREDITS_TIMEOUT,
|
|
304
482
|
)
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
483
|
+
user = response.User
|
|
484
|
+
features = dict(user.Features) if hasattr(user, "Features") else {}
|
|
485
|
+
sounds_plan = (
|
|
486
|
+
int(user.SoundsPlan) if hasattr(user, "SoundsPlan") else 0
|
|
487
|
+
)
|
|
488
|
+
uuid_str = str(user.UUID) if hasattr(user, "UUID") else ""
|
|
489
|
+
plan_kind = classify_plan(
|
|
490
|
+
sounds_status=user.SoundsStatus,
|
|
491
|
+
sounds_plan=sounds_plan,
|
|
492
|
+
features=features,
|
|
493
|
+
)
|
|
494
|
+
creds = SpliceCredits(
|
|
495
|
+
credits=user.Credits,
|
|
496
|
+
username=user.Username,
|
|
497
|
+
plan=user.SoundsStatus,
|
|
498
|
+
sounds_plan_id=sounds_plan,
|
|
499
|
+
features=features,
|
|
500
|
+
plan_kind=plan_kind,
|
|
501
|
+
user_uuid=uuid_str,
|
|
309
502
|
)
|
|
503
|
+
self._cached_credits = creds
|
|
504
|
+
return creds
|
|
310
505
|
except Exception as exc:
|
|
311
506
|
logger.warning(f"Credit check failed: {exc}")
|
|
312
507
|
return SpliceCredits()
|
|
313
508
|
|
|
314
509
|
async def can_afford(self, credits_needed: int, budget: int) -> tuple[bool, int]:
|
|
315
|
-
"""Check if we can afford credits_needed within budget
|
|
510
|
+
"""Check if we can afford `credits_needed` within `budget` for
|
|
511
|
+
credit-metered plans.
|
|
316
512
|
|
|
317
|
-
Returns (can_afford, credits_remaining).
|
|
513
|
+
Returns (can_afford, credits_remaining). NOTE: does NOT consult the
|
|
514
|
+
daily quota — callers on the Ableton Live plan should use
|
|
515
|
+
`decide_download()` instead of `can_afford()`.
|
|
318
516
|
"""
|
|
319
517
|
info = await self.get_credits()
|
|
320
518
|
remaining = info.credits
|
|
@@ -341,6 +539,254 @@ class SpliceGRPCClient:
|
|
|
341
539
|
except Exception as exc:
|
|
342
540
|
logger.debug("sync_sounds failed: %s", exc)
|
|
343
541
|
return False
|
|
542
|
+
|
|
543
|
+
# ── Collections ─────────────────────────────────────────────────
|
|
544
|
+
|
|
545
|
+
def _parse_collection(self, c) -> SpliceCollection:
|
|
546
|
+
creator_username = ""
|
|
547
|
+
try:
|
|
548
|
+
creator_username = c.Creator.Username
|
|
549
|
+
except AttributeError:
|
|
550
|
+
pass
|
|
551
|
+
access_map = {0: "unspecified", 1: "private", 2: "public"}
|
|
552
|
+
access = access_map.get(
|
|
553
|
+
int(c.Access) if hasattr(c, "Access") else 0, "unspecified",
|
|
554
|
+
)
|
|
555
|
+
return SpliceCollection(
|
|
556
|
+
uuid=c.UUID,
|
|
557
|
+
name=c.Name,
|
|
558
|
+
description=c.Description,
|
|
559
|
+
access=access,
|
|
560
|
+
permalink=c.Permalink,
|
|
561
|
+
cover_url=c.CoverURL,
|
|
562
|
+
sample_count=int(c.SampleCount),
|
|
563
|
+
preset_count=int(c.PresetCount),
|
|
564
|
+
pack_count=int(c.PackCount),
|
|
565
|
+
subscription_count=int(c.SubscriptionCount),
|
|
566
|
+
created_by_current_user=bool(c.CreatedByCurrentUser),
|
|
567
|
+
creator_username=creator_username,
|
|
568
|
+
created_at=c.CreatedAt,
|
|
569
|
+
updated_at=c.UpdatedAt,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
async def list_collections(
|
|
573
|
+
self, page: int = 1, per_page: int = 50,
|
|
574
|
+
) -> tuple[int, list[SpliceCollection]]:
|
|
575
|
+
"""List the user's collections. Returns (total_count, collections)."""
|
|
576
|
+
if not self.connected:
|
|
577
|
+
return 0, []
|
|
578
|
+
pb2 = self._pb2
|
|
579
|
+
try:
|
|
580
|
+
response = await self.stub.CollectionsList(
|
|
581
|
+
pb2.CollectionsListRequest(Page=page, PerPage=per_page),
|
|
582
|
+
timeout=COLLECTION_TIMEOUT,
|
|
583
|
+
)
|
|
584
|
+
total = int(response.TotalCount)
|
|
585
|
+
collections = [self._parse_collection(c) for c in response.Collections]
|
|
586
|
+
return total, collections
|
|
587
|
+
except Exception as exc:
|
|
588
|
+
logger.warning(f"CollectionsList failed: {exc}")
|
|
589
|
+
return 0, []
|
|
590
|
+
|
|
591
|
+
async def collection_samples(
|
|
592
|
+
self, uuid: str, page: int = 1, per_page: int = 50,
|
|
593
|
+
) -> tuple[int, list[SpliceSample]]:
|
|
594
|
+
"""List samples inside a collection. Returns (total_hits, samples)."""
|
|
595
|
+
if not self.connected:
|
|
596
|
+
return 0, []
|
|
597
|
+
pb2 = self._pb2
|
|
598
|
+
try:
|
|
599
|
+
response = await self.stub.CollectionListSamples(
|
|
600
|
+
pb2.CollectionListSamplesRequest(
|
|
601
|
+
UUID=uuid, Page=page, PerPage=per_page,
|
|
602
|
+
),
|
|
603
|
+
timeout=COLLECTION_TIMEOUT,
|
|
604
|
+
)
|
|
605
|
+
total = int(response.TotalHits)
|
|
606
|
+
samples = [self._parse_sample(s) for s in response.Samples]
|
|
607
|
+
return total, samples
|
|
608
|
+
except Exception as exc:
|
|
609
|
+
logger.warning(f"CollectionListSamples failed: {exc}")
|
|
610
|
+
return 0, []
|
|
611
|
+
|
|
612
|
+
async def add_to_collection(self, uuid: str, sample_hashes: list[str]) -> bool:
|
|
613
|
+
"""Add samples to a collection. Returns True on success."""
|
|
614
|
+
if not self.connected or not sample_hashes:
|
|
615
|
+
return False
|
|
616
|
+
pb2 = self._pb2
|
|
617
|
+
try:
|
|
618
|
+
await self.stub.CollectionAddItems(
|
|
619
|
+
pb2.CollectionAddItemsRequest(UUID=uuid, Samples=sample_hashes),
|
|
620
|
+
timeout=COLLECTION_TIMEOUT,
|
|
621
|
+
)
|
|
622
|
+
return True
|
|
623
|
+
except Exception as exc:
|
|
624
|
+
logger.warning(f"CollectionAddItems failed: {exc}")
|
|
625
|
+
return False
|
|
626
|
+
|
|
627
|
+
async def remove_from_collection(
|
|
628
|
+
self, uuid: str, sample_hashes: list[str],
|
|
629
|
+
) -> bool:
|
|
630
|
+
"""Remove samples from a collection."""
|
|
631
|
+
if not self.connected or not sample_hashes:
|
|
632
|
+
return False
|
|
633
|
+
pb2 = self._pb2
|
|
634
|
+
try:
|
|
635
|
+
await self.stub.CollectionDeleteItems(
|
|
636
|
+
pb2.CollectionDeleteItemsRequest(UUID=uuid, Samples=sample_hashes),
|
|
637
|
+
timeout=COLLECTION_TIMEOUT,
|
|
638
|
+
)
|
|
639
|
+
return True
|
|
640
|
+
except Exception as exc:
|
|
641
|
+
logger.warning(f"CollectionDeleteItems failed: {exc}")
|
|
642
|
+
return False
|
|
643
|
+
|
|
644
|
+
async def create_collection(self, name: str) -> Optional[SpliceCollection]:
|
|
645
|
+
"""Create a new user collection. Returns the new Collection or None."""
|
|
646
|
+
if not self.connected:
|
|
647
|
+
return None
|
|
648
|
+
pb2 = self._pb2
|
|
649
|
+
try:
|
|
650
|
+
response = await self.stub.CollectionAdd(
|
|
651
|
+
pb2.CollectionAddRequest(Name=name),
|
|
652
|
+
timeout=COLLECTION_TIMEOUT,
|
|
653
|
+
)
|
|
654
|
+
return self._parse_collection(response.Collection)
|
|
655
|
+
except Exception as exc:
|
|
656
|
+
logger.warning(f"CollectionAdd failed: {exc}")
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
# ── Packs ───────────────────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
async def get_pack_info(self, pack_uuid: str) -> Optional[SplicePack]:
|
|
662
|
+
"""Fetch metadata for a single sample pack."""
|
|
663
|
+
if not self.connected:
|
|
664
|
+
return None
|
|
665
|
+
pb2 = self._pb2
|
|
666
|
+
try:
|
|
667
|
+
response = await self.stub.SamplePackInfo(
|
|
668
|
+
pb2.SamplePackInfoRequest(UUID=pack_uuid),
|
|
669
|
+
timeout=INFO_TIMEOUT,
|
|
670
|
+
)
|
|
671
|
+
p = response.Pack
|
|
672
|
+
return SplicePack(
|
|
673
|
+
uuid=p.UUID,
|
|
674
|
+
name=p.Name,
|
|
675
|
+
cover_url=p.CoverURL,
|
|
676
|
+
genre=p.Genre,
|
|
677
|
+
permalink=p.Permalink,
|
|
678
|
+
provider_name=p.ProviderName,
|
|
679
|
+
)
|
|
680
|
+
except Exception as exc:
|
|
681
|
+
logger.warning(f"SamplePackInfo failed: {exc}")
|
|
682
|
+
return None
|
|
683
|
+
|
|
684
|
+
# ── Presets ─────────────────────────────────────────────────────
|
|
685
|
+
|
|
686
|
+
def _parse_preset(self, p) -> SplicePreset:
|
|
687
|
+
return SplicePreset(
|
|
688
|
+
uuid=p.UUID,
|
|
689
|
+
file_hash=p.FileHash,
|
|
690
|
+
filename=p.Filename,
|
|
691
|
+
local_path=p.LocalPath,
|
|
692
|
+
tags=list(p.Tags),
|
|
693
|
+
price=int(p.Price),
|
|
694
|
+
is_default=bool(p.IsDefault),
|
|
695
|
+
plugin_name=p.PluginName,
|
|
696
|
+
plugin_version=p.PluginVersion,
|
|
697
|
+
provider_name=p.ProviderName,
|
|
698
|
+
pack_uuid=p.Pack.UUID if hasattr(p, "Pack") else "",
|
|
699
|
+
preview_url=p.PreviewURL,
|
|
700
|
+
purchased_at=int(p.PurchasedAt) if hasattr(p, "PurchasedAt") else 0,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
async def list_purchased_presets(
|
|
704
|
+
self,
|
|
705
|
+
page: int = 1,
|
|
706
|
+
per_page: int = 50,
|
|
707
|
+
sort: str = "",
|
|
708
|
+
sort_order: str = "",
|
|
709
|
+
) -> tuple[int, list[SplicePreset]]:
|
|
710
|
+
"""List presets the user has purchased/owns."""
|
|
711
|
+
if not self.connected:
|
|
712
|
+
return 0, []
|
|
713
|
+
pb2 = self._pb2
|
|
714
|
+
try:
|
|
715
|
+
response = await self.stub.PresetsListPurchased(
|
|
716
|
+
pb2.PresetsListPurchasedRequest(
|
|
717
|
+
Page=page, PerPage=per_page,
|
|
718
|
+
SortFn=sort, SortOrder=sort_order,
|
|
719
|
+
),
|
|
720
|
+
timeout=PRESET_TIMEOUT,
|
|
721
|
+
)
|
|
722
|
+
total = int(response.TotalHits)
|
|
723
|
+
presets = [self._parse_preset(p) for p in response.Presets]
|
|
724
|
+
return total, presets
|
|
725
|
+
except Exception as exc:
|
|
726
|
+
logger.warning(f"PresetsListPurchased failed: {exc}")
|
|
727
|
+
return 0, []
|
|
728
|
+
|
|
729
|
+
async def get_preset_info(
|
|
730
|
+
self, uuid: str = "", file_hash: str = "", plugin_name: str = "",
|
|
731
|
+
) -> Optional[dict]:
|
|
732
|
+
"""Fetch metadata for a single preset."""
|
|
733
|
+
if not self.connected:
|
|
734
|
+
return None
|
|
735
|
+
pb2 = self._pb2
|
|
736
|
+
try:
|
|
737
|
+
response = await self.stub.PresetInfo(
|
|
738
|
+
pb2.PresetInfoRequest(
|
|
739
|
+
UUID=uuid, FileHash=file_hash, PluginName=plugin_name,
|
|
740
|
+
),
|
|
741
|
+
timeout=PRESET_TIMEOUT,
|
|
742
|
+
)
|
|
743
|
+
return {
|
|
744
|
+
"uuid": response.Preset.UUID,
|
|
745
|
+
"file_hash": response.Preset.FileHash,
|
|
746
|
+
"local_path": response.Preset.LocalPath,
|
|
747
|
+
}
|
|
748
|
+
except Exception as exc:
|
|
749
|
+
logger.warning(f"PresetInfo failed: {exc}")
|
|
750
|
+
return None
|
|
751
|
+
|
|
752
|
+
async def download_preset(self, uuid: str) -> bool:
|
|
753
|
+
"""Trigger a preset download (uses credits)."""
|
|
754
|
+
if not self.connected:
|
|
755
|
+
return False
|
|
756
|
+
pb2 = self._pb2
|
|
757
|
+
try:
|
|
758
|
+
await self.stub.PresetDownload(
|
|
759
|
+
pb2.PresetDownloadRequest(UUID=uuid),
|
|
760
|
+
timeout=PRESET_TIMEOUT,
|
|
761
|
+
)
|
|
762
|
+
return True
|
|
763
|
+
except Exception as exc:
|
|
764
|
+
logger.warning(f"PresetDownload failed: {exc}")
|
|
765
|
+
return False
|
|
766
|
+
|
|
767
|
+
# ── Convert to WAV ──────────────────────────────────────────────
|
|
768
|
+
|
|
769
|
+
async def convert_to_wav(self, path: str) -> Optional[dict]:
|
|
770
|
+
"""Convert an audio file to PCM WAV via Splice's converter."""
|
|
771
|
+
if not self.connected:
|
|
772
|
+
return None
|
|
773
|
+
pb2 = self._pb2
|
|
774
|
+
try:
|
|
775
|
+
response = await self.stub.ConvertToWav(
|
|
776
|
+
pb2.ConvertToWavRequest(Path=path),
|
|
777
|
+
timeout=CONVERT_TIMEOUT,
|
|
778
|
+
)
|
|
779
|
+
wav = response.WavFile
|
|
780
|
+
return {
|
|
781
|
+
"path": wav.Path,
|
|
782
|
+
"channels": int(wav.Channels),
|
|
783
|
+
"sample_rate": int(wav.SampleRate),
|
|
784
|
+
"bit_depth": int(wav.BitDepth),
|
|
785
|
+
}
|
|
786
|
+
except Exception as exc:
|
|
787
|
+
logger.warning(f"ConvertToWav failed: {exc}")
|
|
788
|
+
return None
|
|
789
|
+
|
|
344
790
|
# ── Connection Helpers ──────────────────────────────────────────
|
|
345
791
|
|
|
346
792
|
def _read_port(self) -> Optional[int]:
|