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
@@ -0,0 +1,434 @@
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
+ _DEFAULT_CONFIG_PATH = os.path.expanduser("~/.livepilot/splice.json")
59
+
60
+
61
+ @dataclass
62
+ class SpliceHTTPConfig:
63
+ """Endpoint configuration for the HTTPS bridge.
64
+
65
+ Three sources, checked in order of precedence:
66
+ 1. Env vars (highest — useful for one-off tests / CI)
67
+ 2. JSON config file at `~/.livepilot/splice.json` (persistent user config)
68
+ 3. Built-in defaults (unverified guesses — WILL need updating when
69
+ we capture real traffic)
70
+
71
+ JSON config shape:
72
+ {
73
+ "base_url": "https://api.splice.com",
74
+ "describe_endpoint": "/v1/...",
75
+ "variation_endpoint": "/v1/variations/{file_hash}",
76
+ "search_with_sound_endpoint": "/v1/...",
77
+ "timeout_sec": 30.0,
78
+ "max_retries": 2,
79
+ "allow_unverified_endpoints": false
80
+ }
81
+
82
+ Any subset of keys is allowed; omitted keys fall through to defaults.
83
+ """
84
+
85
+ base_url: str = "https://api.splice.com"
86
+ describe_endpoint: str = "/v1/describe"
87
+ variation_endpoint: str = "/v1/variations/{file_hash}"
88
+ search_with_sound_endpoint: str = "/v1/search-with-sound"
89
+ timeout_sec: float = 30.0
90
+ max_retries: int = 2
91
+ # Whether any of the above values came from user config (file or env)
92
+ # rather than the built-in defaults. Used by `is_user_configured`.
93
+ _user_configured: bool = False
94
+
95
+ @classmethod
96
+ def from_env(cls, config_path: Optional[str] = None) -> "SpliceHTTPConfig":
97
+ """Load config: defaults → JSON file → env vars.
98
+
99
+ `config_path` override is test-only. Production always uses
100
+ ~/.livepilot/splice.json (or skips the file silently if absent).
101
+ """
102
+ instance = cls()
103
+ loaded_from_file = False
104
+
105
+ # Layer 1: JSON file (persistent user config)
106
+ path = config_path or _DEFAULT_CONFIG_PATH
107
+ if os.path.isfile(path):
108
+ try:
109
+ with open(path, "r", encoding="utf-8") as f:
110
+ data = json.load(f)
111
+ if isinstance(data, dict):
112
+ for key in (
113
+ "base_url", "describe_endpoint", "variation_endpoint",
114
+ "search_with_sound_endpoint",
115
+ ):
116
+ if key in data and isinstance(data[key], str):
117
+ setattr(instance, key, data[key])
118
+ loaded_from_file = True
119
+ for key in ("timeout_sec",):
120
+ if key in data:
121
+ try:
122
+ setattr(instance, key, float(data[key]))
123
+ loaded_from_file = True
124
+ except (TypeError, ValueError):
125
+ logger.warning(
126
+ "splice.json: %s must be a number", key,
127
+ )
128
+ for key in ("max_retries",):
129
+ if key in data:
130
+ try:
131
+ setattr(instance, key, int(data[key]))
132
+ loaded_from_file = True
133
+ except (TypeError, ValueError):
134
+ logger.warning(
135
+ "splice.json: %s must be an integer", key,
136
+ )
137
+ if data.get("allow_unverified_endpoints"):
138
+ loaded_from_file = True
139
+ except (OSError, json.JSONDecodeError) as exc:
140
+ logger.warning(
141
+ "Could not load %s: %s — falling back to defaults/env",
142
+ path, exc,
143
+ )
144
+
145
+ # Layer 2: env vars (override file/defaults)
146
+ env_keys = (
147
+ ("SPLICE_API_BASE_URL", "base_url", str),
148
+ ("SPLICE_DESCRIBE_ENDPOINT", "describe_endpoint", str),
149
+ ("SPLICE_VARIATION_ENDPOINT", "variation_endpoint", str),
150
+ ("SPLICE_SEARCH_WITH_SOUND_ENDPOINT", "search_with_sound_endpoint", str),
151
+ ("SPLICE_HTTP_TIMEOUT", "timeout_sec", float),
152
+ ("SPLICE_HTTP_RETRIES", "max_retries", int),
153
+ )
154
+ env_configured = False
155
+ for env_name, attr, cast in env_keys:
156
+ if env_name in os.environ:
157
+ try:
158
+ setattr(instance, attr, cast(os.environ[env_name]))
159
+ env_configured = True
160
+ except (TypeError, ValueError) as exc:
161
+ logger.warning(
162
+ "Env %s has invalid value: %s", env_name, exc,
163
+ )
164
+
165
+ instance._user_configured = (
166
+ loaded_from_file
167
+ or env_configured
168
+ or os.environ.get("SPLICE_ALLOW_UNVERIFIED_ENDPOINTS") == "1"
169
+ )
170
+ return instance
171
+
172
+ @property
173
+ def is_user_configured(self) -> bool:
174
+ """True when at least one endpoint URL has been overridden by the
175
+ user (JSON config file or env var).
176
+
177
+ Defaults are unverified guesses; callers check this before making
178
+ requests so we don't silently hit non-existent endpoints.
179
+ """
180
+ return self._user_configured
181
+
182
+
183
+ # ── Auth token fetch ─────────────────────────────────────────────────
184
+
185
+
186
+ async def fetch_session_token(grpc_client) -> Optional[str]:
187
+ """Fetch the current Splice session token from the local gRPC.
188
+
189
+ The `GetSession` RPC returns an `Auth` object with a `Token` field —
190
+ this is the bearer we attach to `api.splice.com` requests. The token
191
+ rotates periodically so we always fetch fresh rather than caching.
192
+ """
193
+ if not grpc_client or not getattr(grpc_client, "connected", False):
194
+ return None
195
+ pb2 = getattr(grpc_client, "_pb2", None)
196
+ if pb2 is None:
197
+ return None
198
+ try:
199
+ response = await grpc_client.stub.GetSession(
200
+ pb2.GetSessionRequest(), timeout=5.0,
201
+ )
202
+ return str(response.Auth.Token) if response.Auth else None
203
+ except Exception as exc:
204
+ logger.warning("GetSession RPC failed: %s", exc)
205
+ return None
206
+
207
+
208
+ # ── HTTP client ───────────────────────────────────────────────────────
209
+
210
+
211
+ @dataclass
212
+ class SpliceHTTPError(Exception):
213
+ """Structured error for HTTPS-bridge calls."""
214
+
215
+ code: str
216
+ message: str
217
+ endpoint: str = ""
218
+ status_code: int = 0
219
+
220
+ def __str__(self) -> str:
221
+ return f"[{self.code}] {self.message} ({self.endpoint})"
222
+
223
+ def to_dict(self) -> dict:
224
+ return {
225
+ "ok": False,
226
+ "error": self.message,
227
+ "code": self.code,
228
+ "endpoint": self.endpoint,
229
+ "status_code": self.status_code,
230
+ }
231
+
232
+
233
+ class SpliceHTTPBridge:
234
+ """Low-level HTTPS client for Splice cloud APIs.
235
+
236
+ Attaches the bearer token, retries on 5xx, applies a total timeout.
237
+ Thread-safe — each request builds its own opener. Synchronous network
238
+ calls run in an executor from the async wrappers.
239
+ """
240
+
241
+ def __init__(
242
+ self,
243
+ config: Optional[SpliceHTTPConfig] = None,
244
+ grpc_client=None,
245
+ ):
246
+ self.config = config or SpliceHTTPConfig.from_env()
247
+ self.grpc_client = grpc_client
248
+
249
+ async def _request(
250
+ self,
251
+ method: str,
252
+ path: str,
253
+ body: Optional[dict] = None,
254
+ query: Optional[dict] = None,
255
+ ) -> Any:
256
+ token = await fetch_session_token(self.grpc_client)
257
+ if token is None:
258
+ raise SpliceHTTPError(
259
+ code="NO_AUTH",
260
+ message=(
261
+ "Could not fetch Splice session token via GetSession RPC. "
262
+ "Is the Splice desktop app running and logged in?"
263
+ ),
264
+ endpoint=path,
265
+ )
266
+
267
+ url = self.config.base_url.rstrip("/") + path
268
+ if query:
269
+ import urllib.parse
270
+ qs = urllib.parse.urlencode(query)
271
+ url = f"{url}?{qs}"
272
+
273
+ data_bytes = None
274
+ headers = {
275
+ "Authorization": f"Bearer {token}",
276
+ "Accept": "application/json",
277
+ "User-Agent": "LivePilot/1.15 (+splice-http-bridge)",
278
+ }
279
+ if body is not None:
280
+ data_bytes = json.dumps(body).encode("utf-8")
281
+ headers["Content-Type"] = "application/json"
282
+
283
+ loop = asyncio.get_running_loop()
284
+ last_err = None
285
+ for attempt in range(1 + max(0, self.config.max_retries)):
286
+ try:
287
+ return await loop.run_in_executor(
288
+ None,
289
+ self._perform_sync_request,
290
+ url, method, data_bytes, headers,
291
+ )
292
+ except SpliceHTTPError as exc:
293
+ last_err = exc
294
+ # Retry only on 5xx / network. 4xx is terminal.
295
+ if exc.status_code and exc.status_code < 500:
296
+ raise
297
+ await asyncio.sleep(min(2 ** attempt, 5))
298
+ assert last_err is not None
299
+ raise last_err
300
+
301
+ def _perform_sync_request(self, url, method, data_bytes, headers):
302
+ try:
303
+ req = urllib.request.Request(
304
+ url, data=data_bytes, headers=headers, method=method,
305
+ )
306
+ context = ssl.create_default_context()
307
+ with urllib.request.urlopen(
308
+ req, timeout=self.config.timeout_sec, context=context,
309
+ ) as resp:
310
+ raw = resp.read()
311
+ content_type = resp.headers.get("Content-Type", "")
312
+ if "application/json" in content_type:
313
+ return json.loads(raw.decode("utf-8"))
314
+ return {"raw": raw.decode("utf-8", errors="replace")}
315
+ except urllib.error.HTTPError as exc:
316
+ raise SpliceHTTPError(
317
+ code="HTTP_ERROR",
318
+ message=f"HTTP {exc.code}: {exc.reason}",
319
+ endpoint=url,
320
+ status_code=exc.code,
321
+ )
322
+ except urllib.error.URLError as exc:
323
+ raise SpliceHTTPError(
324
+ code="NETWORK_ERROR",
325
+ message=f"Network error: {exc.reason}",
326
+ endpoint=url,
327
+ status_code=0,
328
+ )
329
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
330
+ raise SpliceHTTPError(
331
+ code="DECODE_ERROR",
332
+ message=f"Response decode failed: {exc}",
333
+ endpoint=url,
334
+ )
335
+
336
+ # ── Tool-facing helpers ──────────────────────────────────────────
337
+
338
+ async def describe_sound(
339
+ self,
340
+ description: str,
341
+ bpm: Optional[int] = None,
342
+ key: Optional[str] = None,
343
+ limit: int = 20,
344
+ ) -> dict:
345
+ """Natural-language sample search.
346
+
347
+ Returns a dict with keys: `samples` (list of sample metadata),
348
+ `total_hits`, plus whatever Splice echoes back. Shape is best-effort
349
+ until we capture real traffic — see module docstring.
350
+ """
351
+ if not self.config.is_user_configured:
352
+ raise SpliceHTTPError(
353
+ code="ENDPOINT_NOT_CONFIGURED",
354
+ message=(
355
+ "Describe a Sound endpoint is unverified. Set "
356
+ "SPLICE_DESCRIBE_ENDPOINT (or SPLICE_ALLOW_UNVERIFIED_"
357
+ "ENDPOINTS=1) once you've captured the real URL via "
358
+ "mitmproxy against the Sounds Plugin."
359
+ ),
360
+ endpoint=self.config.describe_endpoint,
361
+ )
362
+ body = {
363
+ "description": description,
364
+ "limit": int(limit),
365
+ }
366
+ if bpm is not None:
367
+ body["bpm"] = int(bpm)
368
+ if key:
369
+ body["key"] = key
370
+ return await self._request("POST", self.config.describe_endpoint, body=body)
371
+
372
+ async def generate_variation(
373
+ self,
374
+ file_hash: str,
375
+ target_key: Optional[str] = None,
376
+ target_bpm: Optional[int] = None,
377
+ count: int = 1,
378
+ ) -> dict:
379
+ """Generate AI variations of a sample.
380
+
381
+ Returns a dict with keys: `variations` (list), `credits_spent`.
382
+ Shape is best-effort until captured — see module docstring.
383
+ """
384
+ if not self.config.is_user_configured:
385
+ raise SpliceHTTPError(
386
+ code="ENDPOINT_NOT_CONFIGURED",
387
+ message=(
388
+ "Variations endpoint is unverified. Set "
389
+ "SPLICE_VARIATION_ENDPOINT (or SPLICE_ALLOW_UNVERIFIED_"
390
+ "ENDPOINTS=1) once you've captured the real URL via "
391
+ "mitmproxy against the Sounds Plugin."
392
+ ),
393
+ endpoint=self.config.variation_endpoint,
394
+ )
395
+ path = self.config.variation_endpoint.format(file_hash=file_hash)
396
+ body: dict = {"count": max(1, int(count))}
397
+ if target_key:
398
+ body["target_key"] = target_key
399
+ if target_bpm is not None:
400
+ body["target_bpm"] = int(target_bpm)
401
+ return await self._request("POST", path, body=body)
402
+
403
+ async def search_with_sound(
404
+ self,
405
+ audio_path: str,
406
+ limit: int = 20,
407
+ ) -> dict:
408
+ """Sample-reference search — find catalog samples similar to a file.
409
+
410
+ Encodes the file as a multipart POST. Wiring waits on a real
411
+ endpoint capture; the upload shape is the most uncertain part
412
+ of the bridge.
413
+ """
414
+ if not self.config.is_user_configured:
415
+ raise SpliceHTTPError(
416
+ code="ENDPOINT_NOT_CONFIGURED",
417
+ message=(
418
+ "Search with Sound endpoint is unverified. Set "
419
+ "SPLICE_SEARCH_WITH_SOUND_ENDPOINT (or SPLICE_ALLOW_"
420
+ "UNVERIFIED_ENDPOINTS=1) once you've captured the real "
421
+ "URL via mitmproxy against the Sounds Plugin."
422
+ ),
423
+ endpoint=self.config.search_with_sound_endpoint,
424
+ )
425
+ # Multipart upload — reserved for the real-capture wiring.
426
+ raise SpliceHTTPError(
427
+ code="NOT_YET_IMPLEMENTED",
428
+ message=(
429
+ "search_with_sound multipart upload wiring is pending real-"
430
+ "endpoint capture. File a follow-up when the Describe a "
431
+ "Sound endpoint has been mapped — similar shape is likely."
432
+ ),
433
+ endpoint=self.config.search_with_sound_endpoint,
434
+ )