livepilot 1.16.0 → 1.17.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +344 -5
  2. package/README.md +16 -15
  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/__init__.py +85 -0
  7. package/mcp_server/atlas/device_atlas.json +3183 -382
  8. package/mcp_server/atlas/device_techniques_index.json +1510 -0
  9. package/mcp_server/atlas/enrichments/__init__.py +1 -0
  10. package/mcp_server/atlas/enrichments/audio_effects/amp.yaml +112 -0
  11. package/mcp_server/atlas/enrichments/audio_effects/audio_effect_rack.yaml +77 -0
  12. package/mcp_server/atlas/enrichments/audio_effects/cabinet.yaml +81 -0
  13. package/mcp_server/atlas/enrichments/audio_effects/corpus.yaml +128 -0
  14. package/mcp_server/atlas/enrichments/audio_effects/envelope_follower.yaml +99 -0
  15. package/mcp_server/atlas/enrichments/audio_effects/external_audio_effect.yaml +64 -0
  16. package/mcp_server/atlas/enrichments/audio_effects/looper.yaml +85 -0
  17. package/mcp_server/atlas/enrichments/audio_effects/pitch_hack.yaml +61 -0
  18. package/mcp_server/atlas/enrichments/audio_effects/pitchloop89.yaml +111 -0
  19. package/mcp_server/atlas/enrichments/audio_effects/re_enveloper.yaml +51 -0
  20. package/mcp_server/atlas/enrichments/audio_effects/resonators.yaml +121 -0
  21. package/mcp_server/atlas/enrichments/audio_effects/snipper.yaml +53 -0
  22. package/mcp_server/atlas/enrichments/audio_effects/spectral_blur.yaml +64 -0
  23. package/mcp_server/atlas/enrichments/audio_effects/spectrum.yaml +61 -0
  24. package/mcp_server/atlas/enrichments/audio_effects/tuner.yaml +43 -0
  25. package/mcp_server/atlas/enrichments/audio_effects/utility.yaml +118 -0
  26. package/mcp_server/atlas/enrichments/audio_effects/vocoder.yaml +94 -0
  27. package/mcp_server/atlas/enrichments/instruments/analog.yaml +11 -0
  28. package/mcp_server/atlas/enrichments/instruments/bass.yaml +11 -0
  29. package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +75 -0
  30. package/mcp_server/atlas/enrichments/instruments/collision.yaml +11 -0
  31. package/mcp_server/atlas/enrichments/instruments/drift.yaml +11 -0
  32. package/mcp_server/atlas/enrichments/instruments/drum_rack.yaml +142 -0
  33. package/mcp_server/atlas/enrichments/instruments/electric.yaml +11 -0
  34. package/mcp_server/atlas/enrichments/instruments/emit.yaml +11 -0
  35. package/mcp_server/atlas/enrichments/instruments/granulator_iii.yaml +124 -0
  36. package/mcp_server/atlas/enrichments/instruments/harmonic_drone_generator.yaml +83 -0
  37. package/mcp_server/atlas/enrichments/instruments/impulse.yaml +47 -0
  38. package/mcp_server/atlas/enrichments/instruments/meld.yaml +11 -0
  39. package/mcp_server/atlas/enrichments/instruments/operator.yaml +11 -0
  40. package/mcp_server/atlas/enrichments/instruments/poli.yaml +11 -0
  41. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +12 -0
  42. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +15 -0
  43. package/mcp_server/atlas/enrichments/instruments/sting_iftah.yaml +44 -0
  44. package/mcp_server/atlas/enrichments/instruments/tension.yaml +11 -0
  45. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +11 -0
  46. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +11 -0
  47. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +11 -0
  48. package/mcp_server/atlas/enrichments/midi_effects/expressive_chords.yaml +38 -0
  49. package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +49 -0
  50. package/mcp_server/atlas/enrichments/midi_effects/microtuner.yaml +83 -0
  51. package/mcp_server/atlas/enrichments/midi_effects/patterns_iftah.yaml +38 -0
  52. package/mcp_server/atlas/enrichments/midi_effects/phase_pattern.yaml +51 -0
  53. package/mcp_server/atlas/enrichments/midi_effects/polyrhythm.yaml +46 -0
  54. package/mcp_server/atlas/enrichments/midi_effects/retrigger.yaml +40 -0
  55. package/mcp_server/atlas/enrichments/midi_effects/slice_shuffler.yaml +39 -0
  56. package/mcp_server/atlas/enrichments/midi_effects/sq_sequencer.yaml +39 -0
  57. package/mcp_server/atlas/enrichments/midi_effects/stages.yaml +38 -0
  58. package/mcp_server/atlas/enrichments/utility/arrangement_looper.yaml +31 -0
  59. package/mcp_server/atlas/enrichments/utility/cv_clock_in.yaml +25 -0
  60. package/mcp_server/atlas/enrichments/utility/cv_clock_out.yaml +25 -0
  61. package/mcp_server/atlas/enrichments/utility/cv_envelope_follower.yaml +38 -0
  62. package/mcp_server/atlas/enrichments/utility/cv_in.yaml +26 -0
  63. package/mcp_server/atlas/enrichments/utility/cv_instrument.yaml +34 -0
  64. package/mcp_server/atlas/enrichments/utility/cv_lfo.yaml +38 -0
  65. package/mcp_server/atlas/enrichments/utility/cv_shaper.yaml +35 -0
  66. package/mcp_server/atlas/enrichments/utility/cv_triggers.yaml +26 -0
  67. package/mcp_server/atlas/enrichments/utility/cv_utility.yaml +37 -0
  68. package/mcp_server/atlas/enrichments/utility/performer.yaml +51 -0
  69. package/mcp_server/atlas/enrichments/utility/prearranger.yaml +36 -0
  70. package/mcp_server/atlas/enrichments/utility/rotating_rhythm_generator.yaml +35 -0
  71. package/mcp_server/atlas/enrichments/utility/surround_panner.yaml +40 -0
  72. package/mcp_server/atlas/enrichments/utility/variations.yaml +40 -0
  73. package/mcp_server/atlas/enrichments/utility/vector_map.yaml +57 -0
  74. package/mcp_server/atlas/tools.py +291 -0
  75. package/mcp_server/m4l_bridge.py +19 -2
  76. package/mcp_server/sample_engine/tools.py +190 -72
  77. package/mcp_server/server.py +18 -6
  78. package/mcp_server/splice_client/client.py +90 -18
  79. package/mcp_server/splice_client/http_bridge.py +414 -138
  80. package/mcp_server/splice_client/models.py +12 -0
  81. package/mcp_server/tools/analyzer.py +150 -1
  82. package/mcp_server/tools/automation.py +168 -0
  83. package/package.json +2 -2
  84. package/remote_script/LivePilot/__init__.py +1 -1
  85. package/remote_script/LivePilot/arrangement.py +216 -1
  86. package/server.json +3 -3
@@ -1,40 +1,40 @@
1
1
  """HTTPS bridge for Splice plugin-exclusive features.
2
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.
3
+ The Splice desktop app and its plugin ship capabilities that are NOT on
4
+ the local gRPC service. They route through a single GraphQL endpoint:
5
+
6
+ - **Describe a Sound / keyword search** — GraphQL operation
7
+ `SamplesSearch` with `semantic` + `rephrase` flags. One operation
8
+ serves both modes via variable toggles.
9
+ - **Variations** — GraphQL operation `AssetSimilarSoundsQuery`. A
10
+ recommender lookup ("find similar catalog samples"), not AI audio
11
+ synthesis.
12
+
13
+ Both authenticated with the bearer JWT we can read from the local gRPC
14
+ `GetSession` RPC. Both captured 2026-04-22 via mitmproxy against
15
+ Splice desktop v5.4.9 + Ableton 12.4 on macOS.
16
+
17
+ ## Endpoint config
18
+
19
+ - Base URL: `https://surfaces-graphql.splice.com`
20
+ - Path: `/graphql`
21
+ - Auth: `Authorization: Bearer <JWT>` (via gRPC GetSession)
22
+ - Content-type: `application/json`
23
+ - Body: `{operationName, variables, query}`
24
+ - User-Agent: LivePilot default (override via env var if Cloudflare
25
+ blocks — mimic `Splice Baelish/darwin/arm64/arm64 5.4.9/...`)
26
+
27
+ ## GraphQL query location
28
+
29
+ The full query strings live under `graphql_queries/*.graphql` and are
30
+ loaded lazily at module-import. One file per operation.
31
+
32
+ ## Explicitly NOT wired
33
+
34
+ Search-with-Sound (drag-audio reference search) was removed 2026-04-22
35
+ user handles this directly in Splice's UI. The capture recipe is
36
+ preserved at `docs/2026-04-22-splice-https-capture-recipe.md` for any
37
+ future session that wants to resurrect the tool.
38
38
  """
39
39
 
40
40
  from __future__ import annotations
@@ -55,56 +55,151 @@ logger = logging.getLogger(__name__)
55
55
  # ── Configuration ─────────────────────────────────────────────────────
56
56
 
57
57
 
58
+ _DEFAULT_CONFIG_PATH = os.path.expanduser("~/.livepilot/splice.json")
59
+
60
+
58
61
  @dataclass
59
62
  class SpliceHTTPConfig:
60
63
  """Endpoint configuration for the HTTPS bridge.
61
64
 
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.
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://surfaces-graphql.splice.com",
74
+ "describe_endpoint": "/graphql",
75
+ "variation_endpoint": "/graphql",
76
+ "timeout_sec": 30.0,
77
+ "max_retries": 2,
78
+ "allow_unverified_endpoints": false
79
+ }
80
+
81
+ Any subset of keys is allowed; omitted keys fall through to defaults.
66
82
  """
67
83
 
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"
84
+ # Captured from Splice desktop v5.4.9 via mitmproxy on 2026-04-22.
85
+ base_url: str = "https://surfaces-graphql.splice.com"
86
+ describe_endpoint: str = "/graphql" # GraphQL SamplesSearch operation
87
+ variation_endpoint: str = "/graphql" # GraphQL AssetSimilarSoundsQuery
88
+ # Mimic the desktop client UA when Cloudflare complains.
89
+ user_agent: str = "LivePilot/1.16 (+splice-http-bridge)"
72
90
  timeout_sec: float = 30.0
73
91
  max_retries: int = 2
92
+ # Whether any of the above values came from user config (file or env)
93
+ # rather than the built-in defaults. Used by `is_user_configured`.
94
+ _user_configured: bool = False
74
95
 
75
96
  @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)),
97
+ def from_env(cls, config_path: Optional[str] = None) -> "SpliceHTTPConfig":
98
+ """Load config: defaults JSON file env vars.
99
+
100
+ `config_path` override is test-only. Production always uses
101
+ ~/.livepilot/splice.json (or skips the file silently if absent).
102
+ """
103
+ instance = cls()
104
+ loaded_from_file = False
105
+
106
+ # Layer 1: JSON file (persistent user config)
107
+ path = config_path or _DEFAULT_CONFIG_PATH
108
+ if os.path.isfile(path):
109
+ try:
110
+ with open(path, "r", encoding="utf-8") as f:
111
+ data = json.load(f)
112
+ if isinstance(data, dict):
113
+ for key in (
114
+ "base_url", "describe_endpoint", "variation_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_HTTP_TIMEOUT", "timeout_sec", float),
151
+ ("SPLICE_HTTP_RETRIES", "max_retries", int),
152
+ )
153
+ env_configured = False
154
+ for env_name, attr, cast in env_keys:
155
+ if env_name in os.environ:
156
+ try:
157
+ setattr(instance, attr, cast(os.environ[env_name]))
158
+ env_configured = True
159
+ except (TypeError, ValueError) as exc:
160
+ logger.warning(
161
+ "Env %s has invalid value: %s", env_name, exc,
162
+ )
163
+
164
+ instance._user_configured = (
165
+ loaded_from_file
166
+ or env_configured
167
+ or os.environ.get("SPLICE_ALLOW_UNVERIFIED_ENDPOINTS") == "1"
92
168
  )
169
+ return instance
93
170
 
94
171
  @property
95
172
  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.
173
+ """True when at least one endpoint URL has been overridden by the
174
+ user (JSON config file or env var).
175
+
176
+ Historically this was the gate for all describe/variation tools
177
+ because defaults were unverified. As of 2026-04-22 the describe
178
+ endpoint is verified (GraphQL `surfaces-graphql.splice.com`), so
179
+ `describe_sound` no longer gates on this. Variation and
180
+ search-with-sound still do, because their GraphQL operations
181
+ haven't been captured yet.
100
182
  """
183
+ return self._user_configured
184
+
185
+ @property
186
+ def describe_verified(self) -> bool:
187
+ """True when the describe endpoint is at its known-working value
188
+ OR the user has explicitly overridden it."""
101
189
  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
- )
190
+ self.base_url == "https://surfaces-graphql.splice.com"
191
+ and self.describe_endpoint == "/graphql"
192
+ ) or self._user_configured
193
+
194
+ @property
195
+ def variation_verified(self) -> bool:
196
+ """True when the variation endpoint is at its known-working
197
+ value (the captured AssetSimilarSoundsQuery path) OR the user
198
+ has explicitly overridden it. Captured 2026-04-22."""
199
+ return (
200
+ self.base_url == "https://surfaces-graphql.splice.com"
201
+ and self.variation_endpoint == "/graphql"
202
+ ) or self._user_configured
108
203
 
109
204
 
110
205
  # ── Auth token fetch ─────────────────────────────────────────────────
@@ -132,6 +227,160 @@ async def fetch_session_token(grpc_client) -> Optional[str]:
132
227
  return None
133
228
 
134
229
 
230
+ # ── GraphQL query loading ────────────────────────────────────────────
231
+
232
+
233
+ _QUERY_DIR = os.path.join(os.path.dirname(__file__), "graphql_queries")
234
+ _QUERY_CACHE: dict[str, str] = {}
235
+
236
+
237
+ def _load_graphql_query(name: str) -> str:
238
+ """Load a `.graphql` file from `graphql_queries/` lazily (cached).
239
+
240
+ Separating the 5800-char SamplesSearch query into its own file keeps
241
+ the Python source readable and lets GraphQL-aware tools (IDE syntax
242
+ highlighting, schema-based linters) treat it as a first-class query.
243
+
244
+ Raises FileNotFoundError with a clear message if the query hasn't
245
+ been captured yet.
246
+ """
247
+ if name in _QUERY_CACHE:
248
+ return _QUERY_CACHE[name]
249
+
250
+ path = os.path.join(_QUERY_DIR, f"{name}.graphql")
251
+ if not os.path.isfile(path):
252
+ raise FileNotFoundError(
253
+ f"GraphQL query '{name}' not found at {path}. "
254
+ f"Capture it via mitmproxy against the Splice desktop app "
255
+ f"(see docs/2026-04-22-splice-https-capture-recipe.md) and "
256
+ f"save the captured `query` string to the .graphql file."
257
+ )
258
+
259
+ with open(path, "r", encoding="utf-8") as f:
260
+ query = f.read()
261
+ _QUERY_CACHE[name] = query
262
+ return query
263
+
264
+
265
+ def _flatten_sample_item(it: dict) -> dict:
266
+ """Turn a single Splice GraphQL SampleAsset item into the flat shape
267
+ LivePilot's tools surface. Shared between SamplesSearch.items[] and
268
+ similarSounds[] — both queries return identically-shaped items.
269
+ """
270
+ if not isinstance(it, dict):
271
+ return {}
272
+ tag_labels = [
273
+ t.get("label", "") for t in (it.get("tags") or [])
274
+ if isinstance(t, dict)
275
+ ]
276
+ # Pack info (items have optional `parents` or can be PackAsset-shaped)
277
+ pack_name = None
278
+ parents = it.get("parents") or {}
279
+ if isinstance(parents, dict):
280
+ pitems = parents.get("items") or []
281
+ if pitems and isinstance(pitems[0], dict):
282
+ pack_name = pitems[0].get("name")
283
+ return {
284
+ "uuid": it.get("uuid"),
285
+ "name": it.get("name"),
286
+ "bpm": it.get("bpm"),
287
+ "key": it.get("key"),
288
+ "duration": it.get("duration"),
289
+ "instrument": it.get("instrument"),
290
+ "asset_category": it.get("asset_category_slug"),
291
+ "chord_type": it.get("chord_type"),
292
+ "tags": tag_labels,
293
+ "liked": bool(it.get("liked")),
294
+ "licensed": bool(it.get("licensed")),
295
+ "pack_name": pack_name,
296
+ "files": it.get("files") or [],
297
+ }
298
+
299
+
300
+ def _check_graphql_errors(raw) -> None:
301
+ """Raise SpliceHTTPError if the GraphQL response has top-level errors.
302
+
303
+ Splice returns errors as a top-level `errors: [...]` array alongside
304
+ or instead of `data:`. A 200 response can still carry a logical
305
+ error, so every parser must check.
306
+ """
307
+ if not isinstance(raw, dict):
308
+ return
309
+ if raw.get("errors"):
310
+ errs = raw["errors"]
311
+ first = errs[0] if isinstance(errs, list) and errs else errs
312
+ msg = (first.get("message") if isinstance(first, dict) else str(first))
313
+ raise SpliceHTTPError(
314
+ code="GRAPHQL_ERROR",
315
+ message=f"Splice GraphQL error: {msg}",
316
+ endpoint="/graphql",
317
+ )
318
+
319
+
320
+ def _parse_samples_search(raw: dict) -> dict:
321
+ """Normalize the SamplesSearch GraphQL response into a flat shape.
322
+
323
+ GraphQL shape: { data: { assetsSearch: { items: [...],
324
+ tag_summary: [...], rephrased_query_string, ... } } }
325
+ Flat shape: { samples: [...], total_hits, rephrased_query_string,
326
+ tag_summary, raw }
327
+ """
328
+ if not isinstance(raw, dict):
329
+ return {"samples": [], "total_hits": 0, "raw": raw}
330
+ _check_graphql_errors(raw)
331
+
332
+ data = raw.get("data") or {}
333
+ page = data.get("assetsSearch") or {}
334
+ items = page.get("items") or []
335
+
336
+ samples = [_flatten_sample_item(it) for it in items if isinstance(it, dict)]
337
+
338
+ pm = page.get("pagination_metadata") or {}
339
+ rm = page.get("response_metadata") or {}
340
+ return {
341
+ "samples": samples,
342
+ "total_hits": rm.get("records") or len(samples),
343
+ "total_pages": pm.get("totalPages"),
344
+ "current_page": pm.get("currentPage"),
345
+ "rephrased_query_string": page.get("rephrased_query_string"),
346
+ "tag_summary": [
347
+ {"label": (ts.get("tag") or {}).get("label"),
348
+ "count": ts.get("count")}
349
+ for ts in (page.get("tag_summary") or [])
350
+ if isinstance(ts, dict)
351
+ ],
352
+ "raw": page,
353
+ }
354
+
355
+
356
+ def _parse_similar_sounds(raw: dict) -> dict:
357
+ """Normalize the AssetSimilarSoundsQuery GraphQL response.
358
+
359
+ GraphQL shape: { data: { similarSounds: [SampleAsset, ...] } }
360
+ Flat shape: { similar_samples: [...], count }
361
+
362
+ Note: Splice calls this the "Variations" feature in the UI, but the
363
+ underlying semantics are "find catalog samples similar to this one" —
364
+ not "generate new audio variants". No target-key / target-BPM inputs
365
+ are supported; the API returns whatever the recommender picks.
366
+ """
367
+ if not isinstance(raw, dict):
368
+ return {"similar_samples": [], "count": 0, "raw": raw}
369
+ _check_graphql_errors(raw)
370
+
371
+ data = raw.get("data") or {}
372
+ sims = data.get("similarSounds") or []
373
+ if not isinstance(sims, list):
374
+ return {"similar_samples": [], "count": 0, "raw": sims}
375
+
376
+ samples = [_flatten_sample_item(it) for it in sims if isinstance(it, dict)]
377
+ return {
378
+ "similar_samples": samples,
379
+ "count": len(samples),
380
+ "raw": sims,
381
+ }
382
+
383
+
135
384
  # ── HTTP client ───────────────────────────────────────────────────────
136
385
 
137
386
 
@@ -201,7 +450,7 @@ class SpliceHTTPBridge:
201
450
  headers = {
202
451
  "Authorization": f"Bearer {token}",
203
452
  "Accept": "application/json",
204
- "User-Agent": "LivePilot/1.15 (+splice-http-bridge)",
453
+ "User-Agent": self.config.user_agent,
205
454
  }
206
455
  if body is not None:
207
456
  data_bytes = json.dumps(body).encode("utf-8")
@@ -268,94 +517,121 @@ class SpliceHTTPBridge:
268
517
  bpm: Optional[int] = None,
269
518
  key: Optional[str] = None,
270
519
  limit: int = 20,
520
+ rephrase: bool = True,
271
521
  ) -> 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.
522
+ """Natural-language sample search via the GraphQL SamplesSearch
523
+ operation (captured 2026-04-22).
524
+
525
+ Splice's `SamplesSearch` operation serves both keyword AND
526
+ semantic/describe search from a single endpoint. We set
527
+ `semantic=1` + `rephrase=True` for describe-style queries.
528
+ The server echoes `rephrased_query_string` in the response
529
+ which tells us what the describe engine actually searched for.
530
+
531
+ Returns a dict with keys:
532
+ - `samples`: list of clean sample-metadata dicts (uuid, name,
533
+ bpm, key, tags, duration, …)
534
+ - `total_hits`: response record count (from pagination metadata)
535
+ - `rephrased_query_string`: what Splice rephrased the query to
536
+ - `tag_summary`: list of {label, count} for faceted filtering
537
+ - `raw`: the full GraphQL `data` block for debugging
538
+
539
+ Raises SpliceHTTPError on auth failure, network issues, or
540
+ GraphQL-level errors.
277
541
  """
278
- if not self.config.is_user_configured:
542
+ if not self.config.describe_verified:
279
543
  raise SpliceHTTPError(
280
544
  code="ENDPOINT_NOT_CONFIGURED",
281
545
  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."
546
+ "Describe endpoint points at an unverified URL. "
547
+ "Reset to defaults, or set SPLICE_API_BASE_URL + "
548
+ "SPLICE_DESCRIBE_ENDPOINT to match real Splice "
549
+ "graphql surface (see http_bridge.py docstring)."
286
550
  ),
287
- endpoint=self.config.describe_endpoint,
551
+ endpoint=f"{self.config.base_url}{self.config.describe_endpoint}",
288
552
  )
289
- body = {
290
- "description": description,
553
+
554
+ query = _load_graphql_query("samples_search")
555
+ variables: dict = {
556
+ "query": str(description),
291
557
  "limit": int(limit),
558
+ "order": "DESC",
559
+ "sort": "relevance",
560
+ "semantic": 1,
561
+ "rephrase": bool(rephrase),
562
+ "extract_filters": False,
563
+ "includeSubscriberOnlyResults": False,
564
+ "tags": [],
565
+ "tags_exclude": [],
566
+ "attributes": [],
567
+ "bundled_content_daws": [],
568
+ "legacy": True,
292
569
  }
293
570
  if bpm is not None:
294
- body["bpm"] = int(bpm)
571
+ variables["bpm"] = str(int(bpm))
295
572
  if key:
296
- body["key"] = key
297
- return await self._request("POST", self.config.describe_endpoint, body=body)
573
+ variables["key"] = str(key)
574
+
575
+ body = {
576
+ "operationName": "SamplesSearch",
577
+ "variables": variables,
578
+ "query": query,
579
+ }
580
+
581
+ raw = await self._request("POST", self.config.describe_endpoint, body=body)
582
+ return _parse_samples_search(raw)
298
583
 
299
584
  async def generate_variation(
300
585
  self,
301
- file_hash: str,
302
- target_key: Optional[str] = None,
303
- target_bpm: Optional[int] = None,
304
- count: int = 1,
586
+ uuid: str,
587
+ is_legacy: bool = True,
305
588
  ) -> 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.
589
+ """Find catalog samples similar to a given sample ("Variations").
590
+
591
+ Captured 2026-04-22: Splice's "Variations" right-click menu item
592
+ fires the GraphQL `AssetSimilarSoundsQuery` with just `uuid` +
593
+ `isLegacy`. Returns up to 10 similar samples. The name
594
+ "generate_variation" is a slight misnomer — this is a
595
+ recommender lookup, not AI-synthesis of new audio — but it
596
+ matches Splice's user-facing "Variations" label.
597
+
598
+ uuid: the source sample's catalog uuid (as returned by
599
+ `splice_describe_sound` or gRPC `SearchSamples`)
600
+ is_legacy: match how Splice's own client sets it (true for
601
+ pre-catalog-v2 samples; leave as default)
602
+
603
+ Returns `{similar_samples: [...], count}` — each sample has the
604
+ same flat shape as `splice_describe_sound` items.
310
605
  """
311
- if not self.config.is_user_configured:
606
+ if not self.config.variation_verified:
312
607
  raise SpliceHTTPError(
313
608
  code="ENDPOINT_NOT_CONFIGURED",
314
609
  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."
610
+ "Variation endpoint points at an unverified URL. "
611
+ "Reset config to defaults so the captured "
612
+ "surfaces-graphql.splice.com/graphql endpoint is used."
319
613
  ),
320
- endpoint=self.config.variation_endpoint,
614
+ endpoint=f"{self.config.base_url}{self.config.variation_endpoint}",
321
615
  )
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:
616
+ if not uuid or not isinstance(uuid, str):
342
617
  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,
618
+ code="INVALID_UUID",
619
+ message="uuid must be a non-empty string",
620
+ endpoint=self.config.variation_endpoint,
351
621
  )
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
- )
622
+ query = _load_graphql_query("asset_similar_sounds")
623
+ body = {
624
+ "operationName": "AssetSimilarSoundsQuery",
625
+ "variables": {
626
+ "uuid": uuid,
627
+ "isLegacy": bool(is_legacy),
628
+ },
629
+ "query": query,
630
+ }
631
+ raw = await self._request("POST", self.config.variation_endpoint, body=body)
632
+ return _parse_similar_sounds(raw)
633
+
634
+ # NOTE: `search_with_sound` method removed 2026-04-22. User does
635
+ # audio-reference search in-Splice manually. Capture recipe is at
636
+ # docs/2026-04-22-splice-https-capture-recipe.md if anyone wants to
637
+ # resurrect it.
@@ -70,10 +70,16 @@ def classify_plan(
70
70
  sounds_status: str,
71
71
  sounds_plan: int,
72
72
  features: Optional[dict[str, bool]] = None,
73
+ override: Optional[str] = None,
73
74
  ) -> PlanKind:
74
75
  """Classify the user's Splice plan from the ValidateLogin response.
75
76
 
76
77
  Priority order (most authoritative first):
78
+ 0. Manual override from ~/.livepilot/splice.json → `plan_kind_override`.
79
+ Lets users who KNOW their plan bypass the safe-default classifier
80
+ when Splice's gRPC data is ambiguous (e.g. plan_id we don't
81
+ recognize + empty `features` + generic "subscribed" status —
82
+ observed 2026-04-22 with sounds_plan_id=6).
77
83
  1. Feature flags — if `ableton_unmetered` etc. is set, trust it.
78
84
  2. Non-zero numeric plan IDs we recognize.
79
85
  3. Free-form status string heuristics — catches "subscribed",
@@ -83,6 +89,12 @@ def classify_plan(
83
89
  free — it's just a plan we don't have a numeric ID for yet.)
84
90
  5. Fallback: UNKNOWN so callers keep the safe credit-floor default.
85
91
  """
92
+ if override:
93
+ override_norm = override.strip().lower()
94
+ for member in PlanKind:
95
+ if member.value == override_norm:
96
+ return member
97
+
86
98
  features = features or {}
87
99
 
88
100
  # Step 1: feature flags are authoritative