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
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""HTTPS bridge for Splice plugin-exclusive features.
|
|
2
|
+
|
|
3
|
+
The Splice Sounds Plugin (beta) ships two capabilities that are NOT on the
|
|
4
|
+
local gRPC service:
|
|
5
|
+
- **Describe a Sound** — natural-language search ("dark ambient pad
|
|
6
|
+
with shimmer")
|
|
7
|
+
- **Variations** — generate unique re-keyed / re-tempo'd versions of
|
|
8
|
+
any sample
|
|
9
|
+
|
|
10
|
+
Both call `api.splice.com` over HTTPS, authenticated with the session
|
|
11
|
+
token we can read from the local gRPC `GetSession` RPC.
|
|
12
|
+
|
|
13
|
+
This module is *scaffolding* — it builds the auth flow, endpoint URLs,
|
|
14
|
+
response parsing, and retry/timeout plumbing so that capturing the real
|
|
15
|
+
endpoint shapes (via mitmproxy against the running plugin) is a matter
|
|
16
|
+
of updating the URL templates rather than rebuilding infrastructure.
|
|
17
|
+
|
|
18
|
+
## How to go from scaffolding to working tool
|
|
19
|
+
|
|
20
|
+
1. Run mitmproxy in transparent mode against the Splice Sounds Plugin
|
|
21
|
+
while it makes a Describe a Sound or Variations request.
|
|
22
|
+
2. Capture the real endpoint URL, request body shape, and response body.
|
|
23
|
+
3. Drop the values into `SpliceHTTPConfig` defaults or via env vars:
|
|
24
|
+
- `SPLICE_API_BASE_URL` (default: https://api.splice.com)
|
|
25
|
+
- `SPLICE_DESCRIBE_ENDPOINT` (default: /v1/describe)
|
|
26
|
+
- `SPLICE_VARIATION_ENDPOINT` (default: /v1/variations/{file_hash})
|
|
27
|
+
4. Run `splice_describe_sound("dark pad")` — done.
|
|
28
|
+
|
|
29
|
+
Until step 4 is complete, the MCP tools return a clear, actionable error
|
|
30
|
+
rather than pretending to work. Zero cheats.
|
|
31
|
+
|
|
32
|
+
## Why token-based instead of embedding the plugin
|
|
33
|
+
|
|
34
|
+
The plugin's authentication flow uses Splice's OAuth session tokens.
|
|
35
|
+
These rotate periodically — hardcoding them wouldn't work. Reading from
|
|
36
|
+
`GetSession` RPC means we always use the current session, tied to the
|
|
37
|
+
user's currently-logged-in Splice desktop app.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import asyncio
|
|
43
|
+
import json
|
|
44
|
+
import logging
|
|
45
|
+
import os
|
|
46
|
+
import ssl
|
|
47
|
+
import urllib.error
|
|
48
|
+
import urllib.request
|
|
49
|
+
from dataclasses import dataclass, field
|
|
50
|
+
from typing import Any, Optional
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Configuration ─────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class SpliceHTTPConfig:
|
|
60
|
+
"""Endpoint configuration for the HTTPS bridge.
|
|
61
|
+
|
|
62
|
+
All fields have env-var overrides so a dev can swap them for testing
|
|
63
|
+
without code changes. Defaults are best-guesses based on Splice's
|
|
64
|
+
public URL conventions — they WILL need updating when we capture real
|
|
65
|
+
traffic. That's expected.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
base_url: str = "https://api.splice.com"
|
|
69
|
+
describe_endpoint: str = "/v1/describe"
|
|
70
|
+
variation_endpoint: str = "/v1/variations/{file_hash}"
|
|
71
|
+
search_with_sound_endpoint: str = "/v1/search-with-sound"
|
|
72
|
+
timeout_sec: float = 30.0
|
|
73
|
+
max_retries: int = 2
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_env(cls) -> "SpliceHTTPConfig":
|
|
77
|
+
"""Load config from env vars, falling back to defaults."""
|
|
78
|
+
return cls(
|
|
79
|
+
base_url=os.environ.get("SPLICE_API_BASE_URL", cls.base_url),
|
|
80
|
+
describe_endpoint=os.environ.get(
|
|
81
|
+
"SPLICE_DESCRIBE_ENDPOINT", cls.describe_endpoint,
|
|
82
|
+
),
|
|
83
|
+
variation_endpoint=os.environ.get(
|
|
84
|
+
"SPLICE_VARIATION_ENDPOINT", cls.variation_endpoint,
|
|
85
|
+
),
|
|
86
|
+
search_with_sound_endpoint=os.environ.get(
|
|
87
|
+
"SPLICE_SEARCH_WITH_SOUND_ENDPOINT",
|
|
88
|
+
cls.search_with_sound_endpoint,
|
|
89
|
+
),
|
|
90
|
+
timeout_sec=float(os.environ.get("SPLICE_HTTP_TIMEOUT", cls.timeout_sec)),
|
|
91
|
+
max_retries=int(os.environ.get("SPLICE_HTTP_RETRIES", cls.max_retries)),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def is_user_configured(self) -> bool:
|
|
96
|
+
"""True when at least one endpoint URL has been overridden by env var.
|
|
97
|
+
|
|
98
|
+
Defaults are unverified guesses; callers check this before making
|
|
99
|
+
requests so we don't silently hit non-existent endpoints.
|
|
100
|
+
"""
|
|
101
|
+
return (
|
|
102
|
+
"SPLICE_API_BASE_URL" in os.environ
|
|
103
|
+
or "SPLICE_DESCRIBE_ENDPOINT" in os.environ
|
|
104
|
+
or "SPLICE_VARIATION_ENDPOINT" in os.environ
|
|
105
|
+
or "SPLICE_SEARCH_WITH_SOUND_ENDPOINT" in os.environ
|
|
106
|
+
or os.environ.get("SPLICE_ALLOW_UNVERIFIED_ENDPOINTS") == "1"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── Auth token fetch ─────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def fetch_session_token(grpc_client) -> Optional[str]:
|
|
114
|
+
"""Fetch the current Splice session token from the local gRPC.
|
|
115
|
+
|
|
116
|
+
The `GetSession` RPC returns an `Auth` object with a `Token` field —
|
|
117
|
+
this is the bearer we attach to `api.splice.com` requests. The token
|
|
118
|
+
rotates periodically so we always fetch fresh rather than caching.
|
|
119
|
+
"""
|
|
120
|
+
if not grpc_client or not getattr(grpc_client, "connected", False):
|
|
121
|
+
return None
|
|
122
|
+
pb2 = getattr(grpc_client, "_pb2", None)
|
|
123
|
+
if pb2 is None:
|
|
124
|
+
return None
|
|
125
|
+
try:
|
|
126
|
+
response = await grpc_client.stub.GetSession(
|
|
127
|
+
pb2.GetSessionRequest(), timeout=5.0,
|
|
128
|
+
)
|
|
129
|
+
return str(response.Auth.Token) if response.Auth else None
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
logger.warning("GetSession RPC failed: %s", exc)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── HTTP client ───────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class SpliceHTTPError(Exception):
|
|
140
|
+
"""Structured error for HTTPS-bridge calls."""
|
|
141
|
+
|
|
142
|
+
code: str
|
|
143
|
+
message: str
|
|
144
|
+
endpoint: str = ""
|
|
145
|
+
status_code: int = 0
|
|
146
|
+
|
|
147
|
+
def __str__(self) -> str:
|
|
148
|
+
return f"[{self.code}] {self.message} ({self.endpoint})"
|
|
149
|
+
|
|
150
|
+
def to_dict(self) -> dict:
|
|
151
|
+
return {
|
|
152
|
+
"ok": False,
|
|
153
|
+
"error": self.message,
|
|
154
|
+
"code": self.code,
|
|
155
|
+
"endpoint": self.endpoint,
|
|
156
|
+
"status_code": self.status_code,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SpliceHTTPBridge:
|
|
161
|
+
"""Low-level HTTPS client for Splice cloud APIs.
|
|
162
|
+
|
|
163
|
+
Attaches the bearer token, retries on 5xx, applies a total timeout.
|
|
164
|
+
Thread-safe — each request builds its own opener. Synchronous network
|
|
165
|
+
calls run in an executor from the async wrappers.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
def __init__(
|
|
169
|
+
self,
|
|
170
|
+
config: Optional[SpliceHTTPConfig] = None,
|
|
171
|
+
grpc_client=None,
|
|
172
|
+
):
|
|
173
|
+
self.config = config or SpliceHTTPConfig.from_env()
|
|
174
|
+
self.grpc_client = grpc_client
|
|
175
|
+
|
|
176
|
+
async def _request(
|
|
177
|
+
self,
|
|
178
|
+
method: str,
|
|
179
|
+
path: str,
|
|
180
|
+
body: Optional[dict] = None,
|
|
181
|
+
query: Optional[dict] = None,
|
|
182
|
+
) -> Any:
|
|
183
|
+
token = await fetch_session_token(self.grpc_client)
|
|
184
|
+
if token is None:
|
|
185
|
+
raise SpliceHTTPError(
|
|
186
|
+
code="NO_AUTH",
|
|
187
|
+
message=(
|
|
188
|
+
"Could not fetch Splice session token via GetSession RPC. "
|
|
189
|
+
"Is the Splice desktop app running and logged in?"
|
|
190
|
+
),
|
|
191
|
+
endpoint=path,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
url = self.config.base_url.rstrip("/") + path
|
|
195
|
+
if query:
|
|
196
|
+
import urllib.parse
|
|
197
|
+
qs = urllib.parse.urlencode(query)
|
|
198
|
+
url = f"{url}?{qs}"
|
|
199
|
+
|
|
200
|
+
data_bytes = None
|
|
201
|
+
headers = {
|
|
202
|
+
"Authorization": f"Bearer {token}",
|
|
203
|
+
"Accept": "application/json",
|
|
204
|
+
"User-Agent": "LivePilot/1.15 (+splice-http-bridge)",
|
|
205
|
+
}
|
|
206
|
+
if body is not None:
|
|
207
|
+
data_bytes = json.dumps(body).encode("utf-8")
|
|
208
|
+
headers["Content-Type"] = "application/json"
|
|
209
|
+
|
|
210
|
+
loop = asyncio.get_running_loop()
|
|
211
|
+
last_err = None
|
|
212
|
+
for attempt in range(1 + max(0, self.config.max_retries)):
|
|
213
|
+
try:
|
|
214
|
+
return await loop.run_in_executor(
|
|
215
|
+
None,
|
|
216
|
+
self._perform_sync_request,
|
|
217
|
+
url, method, data_bytes, headers,
|
|
218
|
+
)
|
|
219
|
+
except SpliceHTTPError as exc:
|
|
220
|
+
last_err = exc
|
|
221
|
+
# Retry only on 5xx / network. 4xx is terminal.
|
|
222
|
+
if exc.status_code and exc.status_code < 500:
|
|
223
|
+
raise
|
|
224
|
+
await asyncio.sleep(min(2 ** attempt, 5))
|
|
225
|
+
assert last_err is not None
|
|
226
|
+
raise last_err
|
|
227
|
+
|
|
228
|
+
def _perform_sync_request(self, url, method, data_bytes, headers):
|
|
229
|
+
try:
|
|
230
|
+
req = urllib.request.Request(
|
|
231
|
+
url, data=data_bytes, headers=headers, method=method,
|
|
232
|
+
)
|
|
233
|
+
context = ssl.create_default_context()
|
|
234
|
+
with urllib.request.urlopen(
|
|
235
|
+
req, timeout=self.config.timeout_sec, context=context,
|
|
236
|
+
) as resp:
|
|
237
|
+
raw = resp.read()
|
|
238
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
239
|
+
if "application/json" in content_type:
|
|
240
|
+
return json.loads(raw.decode("utf-8"))
|
|
241
|
+
return {"raw": raw.decode("utf-8", errors="replace")}
|
|
242
|
+
except urllib.error.HTTPError as exc:
|
|
243
|
+
raise SpliceHTTPError(
|
|
244
|
+
code="HTTP_ERROR",
|
|
245
|
+
message=f"HTTP {exc.code}: {exc.reason}",
|
|
246
|
+
endpoint=url,
|
|
247
|
+
status_code=exc.code,
|
|
248
|
+
)
|
|
249
|
+
except urllib.error.URLError as exc:
|
|
250
|
+
raise SpliceHTTPError(
|
|
251
|
+
code="NETWORK_ERROR",
|
|
252
|
+
message=f"Network error: {exc.reason}",
|
|
253
|
+
endpoint=url,
|
|
254
|
+
status_code=0,
|
|
255
|
+
)
|
|
256
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
257
|
+
raise SpliceHTTPError(
|
|
258
|
+
code="DECODE_ERROR",
|
|
259
|
+
message=f"Response decode failed: {exc}",
|
|
260
|
+
endpoint=url,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# ── Tool-facing helpers ──────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
async def describe_sound(
|
|
266
|
+
self,
|
|
267
|
+
description: str,
|
|
268
|
+
bpm: Optional[int] = None,
|
|
269
|
+
key: Optional[str] = None,
|
|
270
|
+
limit: int = 20,
|
|
271
|
+
) -> dict:
|
|
272
|
+
"""Natural-language sample search.
|
|
273
|
+
|
|
274
|
+
Returns a dict with keys: `samples` (list of sample metadata),
|
|
275
|
+
`total_hits`, plus whatever Splice echoes back. Shape is best-effort
|
|
276
|
+
until we capture real traffic — see module docstring.
|
|
277
|
+
"""
|
|
278
|
+
if not self.config.is_user_configured:
|
|
279
|
+
raise SpliceHTTPError(
|
|
280
|
+
code="ENDPOINT_NOT_CONFIGURED",
|
|
281
|
+
message=(
|
|
282
|
+
"Describe a Sound endpoint is unverified. Set "
|
|
283
|
+
"SPLICE_DESCRIBE_ENDPOINT (or SPLICE_ALLOW_UNVERIFIED_"
|
|
284
|
+
"ENDPOINTS=1) once you've captured the real URL via "
|
|
285
|
+
"mitmproxy against the Sounds Plugin."
|
|
286
|
+
),
|
|
287
|
+
endpoint=self.config.describe_endpoint,
|
|
288
|
+
)
|
|
289
|
+
body = {
|
|
290
|
+
"description": description,
|
|
291
|
+
"limit": int(limit),
|
|
292
|
+
}
|
|
293
|
+
if bpm is not None:
|
|
294
|
+
body["bpm"] = int(bpm)
|
|
295
|
+
if key:
|
|
296
|
+
body["key"] = key
|
|
297
|
+
return await self._request("POST", self.config.describe_endpoint, body=body)
|
|
298
|
+
|
|
299
|
+
async def generate_variation(
|
|
300
|
+
self,
|
|
301
|
+
file_hash: str,
|
|
302
|
+
target_key: Optional[str] = None,
|
|
303
|
+
target_bpm: Optional[int] = None,
|
|
304
|
+
count: int = 1,
|
|
305
|
+
) -> dict:
|
|
306
|
+
"""Generate AI variations of a sample.
|
|
307
|
+
|
|
308
|
+
Returns a dict with keys: `variations` (list), `credits_spent`.
|
|
309
|
+
Shape is best-effort until captured — see module docstring.
|
|
310
|
+
"""
|
|
311
|
+
if not self.config.is_user_configured:
|
|
312
|
+
raise SpliceHTTPError(
|
|
313
|
+
code="ENDPOINT_NOT_CONFIGURED",
|
|
314
|
+
message=(
|
|
315
|
+
"Variations endpoint is unverified. Set "
|
|
316
|
+
"SPLICE_VARIATION_ENDPOINT (or SPLICE_ALLOW_UNVERIFIED_"
|
|
317
|
+
"ENDPOINTS=1) once you've captured the real URL via "
|
|
318
|
+
"mitmproxy against the Sounds Plugin."
|
|
319
|
+
),
|
|
320
|
+
endpoint=self.config.variation_endpoint,
|
|
321
|
+
)
|
|
322
|
+
path = self.config.variation_endpoint.format(file_hash=file_hash)
|
|
323
|
+
body: dict = {"count": max(1, int(count))}
|
|
324
|
+
if target_key:
|
|
325
|
+
body["target_key"] = target_key
|
|
326
|
+
if target_bpm is not None:
|
|
327
|
+
body["target_bpm"] = int(target_bpm)
|
|
328
|
+
return await self._request("POST", path, body=body)
|
|
329
|
+
|
|
330
|
+
async def search_with_sound(
|
|
331
|
+
self,
|
|
332
|
+
audio_path: str,
|
|
333
|
+
limit: int = 20,
|
|
334
|
+
) -> dict:
|
|
335
|
+
"""Sample-reference search — find catalog samples similar to a file.
|
|
336
|
+
|
|
337
|
+
Encodes the file as a multipart POST. Wiring waits on a real
|
|
338
|
+
endpoint capture; the upload shape is the most uncertain part
|
|
339
|
+
of the bridge.
|
|
340
|
+
"""
|
|
341
|
+
if not self.config.is_user_configured:
|
|
342
|
+
raise SpliceHTTPError(
|
|
343
|
+
code="ENDPOINT_NOT_CONFIGURED",
|
|
344
|
+
message=(
|
|
345
|
+
"Search with Sound endpoint is unverified. Set "
|
|
346
|
+
"SPLICE_SEARCH_WITH_SOUND_ENDPOINT (or SPLICE_ALLOW_"
|
|
347
|
+
"UNVERIFIED_ENDPOINTS=1) once you've captured the real "
|
|
348
|
+
"URL via mitmproxy against the Sounds Plugin."
|
|
349
|
+
),
|
|
350
|
+
endpoint=self.config.search_with_sound_endpoint,
|
|
351
|
+
)
|
|
352
|
+
# Multipart upload — reserved for the real-capture wiring.
|
|
353
|
+
raise SpliceHTTPError(
|
|
354
|
+
code="NOT_YET_IMPLEMENTED",
|
|
355
|
+
message=(
|
|
356
|
+
"search_with_sound multipart upload wiring is pending real-"
|
|
357
|
+
"endpoint capture. File a follow-up when the Describe a "
|
|
358
|
+
"Sound endpoint has been mapped — similar shape is likely."
|
|
359
|
+
),
|
|
360
|
+
endpoint=self.config.search_with_sound_endpoint,
|
|
361
|
+
)
|
|
@@ -1,11 +1,137 @@
|
|
|
1
|
-
"""Splice client data models — Python representations of Splice gRPC messages.
|
|
1
|
+
"""Splice client data models — Python representations of Splice gRPC messages.
|
|
2
|
+
|
|
3
|
+
Models here mirror the proto messages under `protos/app_pb2.py`.
|
|
4
|
+
See `project_splice_subscription_model.md` for the two-pocket model
|
|
5
|
+
(daily samples vs Splice.com credits) that PlanKind classifies.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
from __future__ import annotations
|
|
4
9
|
|
|
5
10
|
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
6
12
|
from typing import Optional
|
|
7
13
|
|
|
8
14
|
|
|
15
|
+
# ── Plan classification ──────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PlanKind(str, Enum):
|
|
19
|
+
"""Classification of Splice subscription plans.
|
|
20
|
+
|
|
21
|
+
Splice returns `User.SoundsStatus` as a generic string (often just
|
|
22
|
+
"subscribed"), so we classify tier via the `Features` map and numeric
|
|
23
|
+
`SoundsPlan` id. See `project_splice_subscription_model.md`.
|
|
24
|
+
|
|
25
|
+
- ABLETON_LIVE: $12.99/mo, 100 samples/day unmetered via Ableton drag-drop
|
|
26
|
+
+ 100 intro credits for Splice.com content. Sample downloads DO NOT
|
|
27
|
+
cost credits on this plan — they deplete a daily counter.
|
|
28
|
+
- SOUNDS_PLUS: legacy per-credit tiers (100/300/600/1000) + Creator+.
|
|
29
|
+
Samples DO cost credits.
|
|
30
|
+
- CREATOR: $12.99/mo legacy creator plan, 100 credits/mo.
|
|
31
|
+
- FREE: anonymous or unconverted trial.
|
|
32
|
+
- UNKNOWN: plan metadata absent — treat like SOUNDS_PLUS (safest).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
ABLETON_LIVE = "ableton_live"
|
|
36
|
+
SOUNDS_PLUS = "sounds_plus"
|
|
37
|
+
CREATOR = "creator"
|
|
38
|
+
CREATOR_PLUS = "creator_plus"
|
|
39
|
+
FREE = "free"
|
|
40
|
+
UNKNOWN = "unknown"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_subscribed(self) -> bool:
|
|
44
|
+
return self != PlanKind.FREE and self != PlanKind.UNKNOWN
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def has_daily_sample_quota(self) -> bool:
|
|
48
|
+
"""True iff sample downloads deplete a daily counter, not credits."""
|
|
49
|
+
return self == PlanKind.ABLETON_LIVE
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Feature-flag keys we look for in `User.Features`. Splice sets these in
|
|
53
|
+
# the ValidateLogin response. Names are best-effort — Splice may rename
|
|
54
|
+
# them; the classifier tolerates missing keys.
|
|
55
|
+
_FEATURE_ABLETON_UNMETERED = "ableton_unmetered"
|
|
56
|
+
_FEATURE_ABLETON_LIVE_PLAN = "ableton_live_plan"
|
|
57
|
+
_FEATURE_UNMETERED = "unmetered_downloads"
|
|
58
|
+
_FEATURE_CREATOR_PLUS = "creator_plus"
|
|
59
|
+
|
|
60
|
+
# Numeric plan IDs we've observed. Splice uses `User.SoundsPlan` as a
|
|
61
|
+
# proprietary enum. These are inferred from the API responses and the
|
|
62
|
+
# public plan catalog.
|
|
63
|
+
_PLAN_ID_ABLETON_LIVE = {12, 13} # possible IDs for the Ableton plan
|
|
64
|
+
_PLAN_ID_CREATOR_PLUS = {11}
|
|
65
|
+
_PLAN_ID_CREATOR = {1, 2, 3}
|
|
66
|
+
_PLAN_ID_FREE = {0}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def classify_plan(
|
|
70
|
+
sounds_status: str,
|
|
71
|
+
sounds_plan: int,
|
|
72
|
+
features: Optional[dict[str, bool]] = None,
|
|
73
|
+
) -> PlanKind:
|
|
74
|
+
"""Classify the user's Splice plan from the ValidateLogin response.
|
|
75
|
+
|
|
76
|
+
Priority order (most authoritative first):
|
|
77
|
+
1. Feature flags — if `ableton_unmetered` etc. is set, trust it.
|
|
78
|
+
2. Non-zero numeric plan IDs we recognize.
|
|
79
|
+
3. Free-form status string heuristics — catches "subscribed",
|
|
80
|
+
"ableton live plan", "creator plus", etc.
|
|
81
|
+
4. Numeric 0 → FREE only when the status string doesn't
|
|
82
|
+
contradict. (plan_id=0 alone with status="subscribed" is NOT
|
|
83
|
+
free — it's just a plan we don't have a numeric ID for yet.)
|
|
84
|
+
5. Fallback: UNKNOWN so callers keep the safe credit-floor default.
|
|
85
|
+
"""
|
|
86
|
+
features = features or {}
|
|
87
|
+
|
|
88
|
+
# Step 1: feature flags are authoritative
|
|
89
|
+
if features.get(_FEATURE_ABLETON_UNMETERED) or features.get(_FEATURE_ABLETON_LIVE_PLAN):
|
|
90
|
+
return PlanKind.ABLETON_LIVE
|
|
91
|
+
if features.get(_FEATURE_UNMETERED):
|
|
92
|
+
return PlanKind.ABLETON_LIVE
|
|
93
|
+
if features.get(_FEATURE_CREATOR_PLUS):
|
|
94
|
+
return PlanKind.CREATOR_PLUS
|
|
95
|
+
|
|
96
|
+
# Step 2: recognized non-zero plan IDs
|
|
97
|
+
if sounds_plan in _PLAN_ID_ABLETON_LIVE:
|
|
98
|
+
return PlanKind.ABLETON_LIVE
|
|
99
|
+
if sounds_plan in _PLAN_ID_CREATOR_PLUS:
|
|
100
|
+
return PlanKind.CREATOR_PLUS
|
|
101
|
+
if sounds_plan in _PLAN_ID_CREATOR:
|
|
102
|
+
return PlanKind.CREATOR
|
|
103
|
+
|
|
104
|
+
# Step 3: string heuristics — BEFORE the plan_id=0 FREE check, because
|
|
105
|
+
# "subscribed" + plan_id=0 means "subscribed plan we don't recognize
|
|
106
|
+
# numerically", NOT free.
|
|
107
|
+
status_lower = (sounds_status or "").lower().strip()
|
|
108
|
+
if "ableton" in status_lower:
|
|
109
|
+
return PlanKind.ABLETON_LIVE
|
|
110
|
+
if "creator" in status_lower and "plus" in status_lower:
|
|
111
|
+
return PlanKind.CREATOR_PLUS
|
|
112
|
+
if "creator" in status_lower:
|
|
113
|
+
return PlanKind.CREATOR
|
|
114
|
+
if status_lower in ("subscribed", "paid", "active", "sounds_plus", "sounds+"):
|
|
115
|
+
# Generic "subscribed" is ambiguous — SOUNDS_PLUS is the safe
|
|
116
|
+
# default because it keeps the credit floor on. The MCP tool
|
|
117
|
+
# documents this.
|
|
118
|
+
return PlanKind.SOUNDS_PLUS
|
|
119
|
+
if status_lower in ("free", "trial", "unconverted"):
|
|
120
|
+
return PlanKind.FREE
|
|
121
|
+
|
|
122
|
+
# Step 4: numeric FREE path — only reached when status was silent
|
|
123
|
+
if sounds_plan in _PLAN_ID_FREE:
|
|
124
|
+
return PlanKind.FREE
|
|
125
|
+
|
|
126
|
+
# Step 5: fallback
|
|
127
|
+
if not status_lower:
|
|
128
|
+
return PlanKind.FREE
|
|
129
|
+
return PlanKind.UNKNOWN
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Sample & search ──────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
|
|
9
135
|
@dataclass
|
|
10
136
|
class SpliceSample:
|
|
11
137
|
"""A sample from the Splice catalog or local library."""
|
|
@@ -24,6 +150,7 @@ class SpliceSample:
|
|
|
24
150
|
pack_uuid: str = ""
|
|
25
151
|
popularity: int = 0
|
|
26
152
|
is_premium: bool = False
|
|
153
|
+
price: int = 0 # 0 ⇒ free regardless of plan
|
|
27
154
|
preview_url: str = ""
|
|
28
155
|
waveform_url: str = ""
|
|
29
156
|
is_downloaded: bool = False
|
|
@@ -42,6 +169,15 @@ class SpliceSample:
|
|
|
42
169
|
def duration_seconds(self) -> float:
|
|
43
170
|
return self.duration_ms / 1000.0 if self.duration_ms else 0.0
|
|
44
171
|
|
|
172
|
+
@property
|
|
173
|
+
def is_free(self) -> bool:
|
|
174
|
+
"""True iff this sample costs no credits under any plan.
|
|
175
|
+
|
|
176
|
+
Splice marks samples as free via `IsPremium == False` or `Price == 0`.
|
|
177
|
+
This is orthogonal to plan: even a free-tier user can license these.
|
|
178
|
+
"""
|
|
179
|
+
return (not self.is_premium) or self.price == 0
|
|
180
|
+
|
|
45
181
|
def to_dict(self) -> dict:
|
|
46
182
|
return {
|
|
47
183
|
"file_hash": self.file_hash,
|
|
@@ -60,6 +196,9 @@ class SpliceSample:
|
|
|
60
196
|
"popularity": self.popularity,
|
|
61
197
|
"is_downloaded": self.is_downloaded,
|
|
62
198
|
"is_premium": self.is_premium,
|
|
199
|
+
"price": self.price,
|
|
200
|
+
"is_free": self.is_free,
|
|
201
|
+
"preview_url": self.preview_url,
|
|
63
202
|
}
|
|
64
203
|
|
|
65
204
|
|
|
@@ -80,17 +219,142 @@ class SpliceSearchResult:
|
|
|
80
219
|
}
|
|
81
220
|
|
|
82
221
|
|
|
222
|
+
# ── Credits & plan ───────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
|
|
83
225
|
@dataclass
|
|
84
226
|
class SpliceCredits:
|
|
85
|
-
"""User credit status.
|
|
227
|
+
"""User credit status plus plan classification.
|
|
228
|
+
|
|
229
|
+
`plan` is the raw Splice `SoundsStatus` string (e.g. "subscribed").
|
|
230
|
+
`plan_kind` is our classification — use this for gating decisions.
|
|
231
|
+
`features` carries the full `Features` map so callers can check
|
|
232
|
+
granular flags not yet modelled by PlanKind.
|
|
233
|
+
"""
|
|
86
234
|
|
|
87
235
|
credits: int = 0
|
|
88
236
|
username: str = ""
|
|
89
237
|
plan: str = ""
|
|
238
|
+
sounds_plan_id: int = 0
|
|
239
|
+
features: dict[str, bool] = field(default_factory=dict)
|
|
240
|
+
plan_kind: PlanKind = PlanKind.UNKNOWN
|
|
241
|
+
user_uuid: str = ""
|
|
90
242
|
|
|
91
243
|
def to_dict(self) -> dict:
|
|
92
244
|
return {
|
|
93
245
|
"credits": self.credits,
|
|
94
246
|
"username": self.username,
|
|
95
247
|
"plan": self.plan,
|
|
248
|
+
"sounds_plan_id": self.sounds_plan_id,
|
|
249
|
+
"plan_kind": self.plan_kind.value,
|
|
250
|
+
"features": dict(self.features),
|
|
251
|
+
"user_uuid": self.user_uuid,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ── Collections (Splice-side personal organization) ──────────────────────
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass
|
|
259
|
+
class SpliceCollection:
|
|
260
|
+
"""A user-curated Collection (Likes, custom folders, Daily Picks bookmark)."""
|
|
261
|
+
|
|
262
|
+
uuid: str = ""
|
|
263
|
+
name: str = ""
|
|
264
|
+
description: str = ""
|
|
265
|
+
access: str = "" # "public", "private"
|
|
266
|
+
permalink: str = ""
|
|
267
|
+
cover_url: str = ""
|
|
268
|
+
sample_count: int = 0
|
|
269
|
+
preset_count: int = 0
|
|
270
|
+
pack_count: int = 0
|
|
271
|
+
subscription_count: int = 0
|
|
272
|
+
created_by_current_user: bool = False
|
|
273
|
+
creator_username: str = ""
|
|
274
|
+
created_at: str = ""
|
|
275
|
+
updated_at: str = ""
|
|
276
|
+
|
|
277
|
+
def to_dict(self) -> dict:
|
|
278
|
+
return {
|
|
279
|
+
"uuid": self.uuid,
|
|
280
|
+
"name": self.name,
|
|
281
|
+
"description": self.description,
|
|
282
|
+
"access": self.access,
|
|
283
|
+
"permalink": self.permalink,
|
|
284
|
+
"sample_count": self.sample_count,
|
|
285
|
+
"preset_count": self.preset_count,
|
|
286
|
+
"pack_count": self.pack_count,
|
|
287
|
+
"cover_url": self.cover_url,
|
|
288
|
+
"owned_by_me": self.created_by_current_user,
|
|
289
|
+
"creator": self.creator_username,
|
|
290
|
+
"created_at": self.created_at,
|
|
291
|
+
"updated_at": self.updated_at,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ── Packs ────────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass
|
|
299
|
+
class SplicePack:
|
|
300
|
+
"""A sample pack (Splice `SamplePack` message)."""
|
|
301
|
+
|
|
302
|
+
uuid: str = ""
|
|
303
|
+
name: str = ""
|
|
304
|
+
cover_url: str = ""
|
|
305
|
+
genre: str = ""
|
|
306
|
+
permalink: str = ""
|
|
307
|
+
provider_name: str = ""
|
|
308
|
+
|
|
309
|
+
def to_dict(self) -> dict:
|
|
310
|
+
return {
|
|
311
|
+
"uuid": self.uuid,
|
|
312
|
+
"name": self.name,
|
|
313
|
+
"genre": self.genre,
|
|
314
|
+
"permalink": self.permalink,
|
|
315
|
+
"provider": self.provider_name,
|
|
316
|
+
"cover_url": self.cover_url,
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ── Presets ──────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@dataclass
|
|
324
|
+
class SplicePreset:
|
|
325
|
+
"""A Splice Instrument or VST/AU preset from the catalog."""
|
|
326
|
+
|
|
327
|
+
uuid: str = ""
|
|
328
|
+
file_hash: str = ""
|
|
329
|
+
filename: str = ""
|
|
330
|
+
local_path: str = ""
|
|
331
|
+
tags: list[str] = field(default_factory=list)
|
|
332
|
+
price: int = 0
|
|
333
|
+
is_default: bool = False
|
|
334
|
+
plugin_name: str = ""
|
|
335
|
+
plugin_version: str = ""
|
|
336
|
+
provider_name: str = ""
|
|
337
|
+
pack_uuid: str = ""
|
|
338
|
+
preview_url: str = ""
|
|
339
|
+
purchased_at: int = 0
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def is_downloaded(self) -> bool:
|
|
343
|
+
return bool(self.local_path)
|
|
344
|
+
|
|
345
|
+
def to_dict(self) -> dict:
|
|
346
|
+
return {
|
|
347
|
+
"uuid": self.uuid,
|
|
348
|
+
"file_hash": self.file_hash,
|
|
349
|
+
"filename": self.filename,
|
|
350
|
+
"local_path": self.local_path,
|
|
351
|
+
"tags": self.tags,
|
|
352
|
+
"price": self.price,
|
|
353
|
+
"plugin_name": self.plugin_name,
|
|
354
|
+
"plugin_version": self.plugin_version,
|
|
355
|
+
"provider": self.provider_name,
|
|
356
|
+
"pack_uuid": self.pack_uuid,
|
|
357
|
+
"is_default": self.is_default,
|
|
358
|
+
"is_downloaded": self.is_downloaded,
|
|
359
|
+
"preview_url": self.preview_url,
|
|
96
360
|
}
|