livepilot 1.15.0-beta → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +206 -3
  2. package/README.md +11 -11
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/atlas/device_atlas.json +91219 -7161
  7. package/mcp_server/atlas/enrichments/audio_effects/pitch_hack.yaml +61 -0
  8. package/mcp_server/atlas/enrichments/audio_effects/pitchloop89.yaml +111 -0
  9. package/mcp_server/atlas/enrichments/audio_effects/re_enveloper.yaml +51 -0
  10. package/mcp_server/atlas/enrichments/audio_effects/snipper.yaml +36 -0
  11. package/mcp_server/atlas/enrichments/audio_effects/spectral_blur.yaml +64 -0
  12. package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +37 -0
  13. package/mcp_server/atlas/enrichments/instruments/granulator_iii.yaml +124 -0
  14. package/mcp_server/atlas/enrichments/instruments/harmonic_drone_generator.yaml +83 -0
  15. package/mcp_server/atlas/enrichments/instruments/impulse.yaml +47 -0
  16. package/mcp_server/atlas/enrichments/instruments/sting_iftah.yaml +44 -0
  17. package/mcp_server/atlas/enrichments/midi_effects/expressive_chords.yaml +38 -0
  18. package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +32 -0
  19. package/mcp_server/atlas/enrichments/midi_effects/microtuner.yaml +83 -0
  20. package/mcp_server/atlas/enrichments/midi_effects/patterns_iftah.yaml +38 -0
  21. package/mcp_server/atlas/enrichments/midi_effects/phase_pattern.yaml +51 -0
  22. package/mcp_server/atlas/enrichments/midi_effects/polyrhythm.yaml +46 -0
  23. package/mcp_server/atlas/enrichments/midi_effects/retrigger.yaml +40 -0
  24. package/mcp_server/atlas/enrichments/midi_effects/slice_shuffler.yaml +39 -0
  25. package/mcp_server/atlas/enrichments/midi_effects/sq_sequencer.yaml +39 -0
  26. package/mcp_server/atlas/enrichments/midi_effects/stages.yaml +38 -0
  27. package/mcp_server/atlas/enrichments/utility/arrangement_looper.yaml +31 -0
  28. package/mcp_server/atlas/enrichments/utility/cv_clock_in.yaml +25 -0
  29. package/mcp_server/atlas/enrichments/utility/cv_clock_out.yaml +25 -0
  30. package/mcp_server/atlas/enrichments/utility/cv_envelope_follower.yaml +38 -0
  31. package/mcp_server/atlas/enrichments/utility/cv_in.yaml +26 -0
  32. package/mcp_server/atlas/enrichments/utility/cv_instrument.yaml +34 -0
  33. package/mcp_server/atlas/enrichments/utility/cv_lfo.yaml +38 -0
  34. package/mcp_server/atlas/enrichments/utility/cv_shaper.yaml +35 -0
  35. package/mcp_server/atlas/enrichments/utility/cv_triggers.yaml +26 -0
  36. package/mcp_server/atlas/enrichments/utility/cv_utility.yaml +37 -0
  37. package/mcp_server/atlas/enrichments/utility/performer.yaml +36 -0
  38. package/mcp_server/atlas/enrichments/utility/prearranger.yaml +36 -0
  39. package/mcp_server/atlas/enrichments/utility/rotating_rhythm_generator.yaml +35 -0
  40. package/mcp_server/atlas/enrichments/utility/surround_panner.yaml +40 -0
  41. package/mcp_server/atlas/enrichments/utility/variations.yaml +40 -0
  42. package/mcp_server/atlas/enrichments/utility/vector_map.yaml +36 -0
  43. package/mcp_server/atlas/tools.py +30 -2
  44. package/mcp_server/runtime/remote_commands.py +3 -0
  45. package/mcp_server/sample_engine/tools.py +738 -60
  46. package/mcp_server/server.py +18 -6
  47. package/mcp_server/splice_client/client.py +583 -65
  48. package/mcp_server/splice_client/http_bridge.py +434 -0
  49. package/mcp_server/splice_client/models.py +278 -2
  50. package/mcp_server/splice_client/quota.py +229 -0
  51. package/mcp_server/tools/_analyzer_engine/__init__.py +4 -0
  52. package/mcp_server/tools/_analyzer_engine/sample.py +73 -0
  53. package/mcp_server/tools/analyzer.py +730 -29
  54. package/mcp_server/tools/browser.py +164 -13
  55. package/mcp_server/tools/devices.py +56 -11
  56. package/mcp_server/tools/mixing.py +64 -15
  57. package/mcp_server/tools/scales.py +18 -6
  58. package/mcp_server/tools/tracks.py +92 -4
  59. package/package.json +2 -2
  60. package/remote_script/LivePilot/__init__.py +1 -1
  61. package/remote_script/LivePilot/_clip_helpers.py +86 -0
  62. package/remote_script/LivePilot/_drum_helpers.py +40 -0
  63. package/remote_script/LivePilot/_scale_helpers.py +87 -0
  64. package/remote_script/LivePilot/arrangement.py +44 -15
  65. package/remote_script/LivePilot/clips.py +182 -2
  66. package/remote_script/LivePilot/devices.py +82 -2
  67. package/remote_script/LivePilot/notes.py +17 -2
  68. package/remote_script/LivePilot/scales.py +31 -16
  69. package/remote_script/LivePilot/simpler_sample.py +105 -17
  70. package/server.json +3 -3
@@ -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 provides: search, download, sample info, credit check.
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 SpliceCredits, SpliceSample, SpliceSearchResult
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(SpliceSample(
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, file_hash: str, timeout: float = 30.0,
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
- Costs 1 credit. Enforces CREDIT_HARD_FLOOR defensively — refuses the
204
- download (returns None) if completing it would leave the user at or
205
- below the floor, regardless of what the caller requested. Callers
206
- should still gate on `can_afford` upstream for UX, but this guard
207
- closes the hole if a future caller forgets.
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
- # Defensive floor guard — do not rely on callers alone.
213
- can, remaining = await self.can_afford(1, budget=1)
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 blocked by credit floor guard "
217
- "(remaining=%s, floor=%s, file_hash=%s)",
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
- return await self._wait_for_download(file_hash, timeout)
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
- s = response.Sample
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 user info."""
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
- return SpliceCredits(
306
- credits=response.User.Credits,
307
- username=response.User.Username,
308
- plan=response.User.SoundsStatus,
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]: