livepilot 1.16.1 → 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 (50) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +16 -15
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/mcp_server/__init__.py +1 -1
  5. package/mcp_server/atlas/__init__.py +85 -0
  6. package/mcp_server/atlas/device_atlas.json +3183 -382
  7. package/mcp_server/atlas/device_techniques_index.json +1510 -0
  8. package/mcp_server/atlas/enrichments/__init__.py +1 -0
  9. package/mcp_server/atlas/enrichments/audio_effects/amp.yaml +112 -0
  10. package/mcp_server/atlas/enrichments/audio_effects/audio_effect_rack.yaml +77 -0
  11. package/mcp_server/atlas/enrichments/audio_effects/cabinet.yaml +81 -0
  12. package/mcp_server/atlas/enrichments/audio_effects/corpus.yaml +128 -0
  13. package/mcp_server/atlas/enrichments/audio_effects/envelope_follower.yaml +99 -0
  14. package/mcp_server/atlas/enrichments/audio_effects/external_audio_effect.yaml +64 -0
  15. package/mcp_server/atlas/enrichments/audio_effects/looper.yaml +85 -0
  16. package/mcp_server/atlas/enrichments/audio_effects/resonators.yaml +121 -0
  17. package/mcp_server/atlas/enrichments/audio_effects/snipper.yaml +17 -0
  18. package/mcp_server/atlas/enrichments/audio_effects/spectrum.yaml +61 -0
  19. package/mcp_server/atlas/enrichments/audio_effects/tuner.yaml +43 -0
  20. package/mcp_server/atlas/enrichments/audio_effects/utility.yaml +118 -0
  21. package/mcp_server/atlas/enrichments/audio_effects/vocoder.yaml +94 -0
  22. package/mcp_server/atlas/enrichments/instruments/analog.yaml +11 -0
  23. package/mcp_server/atlas/enrichments/instruments/bass.yaml +11 -0
  24. package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +38 -0
  25. package/mcp_server/atlas/enrichments/instruments/collision.yaml +11 -0
  26. package/mcp_server/atlas/enrichments/instruments/drift.yaml +11 -0
  27. package/mcp_server/atlas/enrichments/instruments/drum_rack.yaml +142 -0
  28. package/mcp_server/atlas/enrichments/instruments/electric.yaml +11 -0
  29. package/mcp_server/atlas/enrichments/instruments/emit.yaml +11 -0
  30. package/mcp_server/atlas/enrichments/instruments/meld.yaml +11 -0
  31. package/mcp_server/atlas/enrichments/instruments/operator.yaml +11 -0
  32. package/mcp_server/atlas/enrichments/instruments/poli.yaml +11 -0
  33. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +12 -0
  34. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +15 -0
  35. package/mcp_server/atlas/enrichments/instruments/tension.yaml +11 -0
  36. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +11 -0
  37. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +11 -0
  38. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +11 -0
  39. package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +17 -0
  40. package/mcp_server/atlas/enrichments/utility/performer.yaml +15 -0
  41. package/mcp_server/atlas/enrichments/utility/vector_map.yaml +21 -0
  42. package/mcp_server/atlas/tools.py +291 -0
  43. package/mcp_server/m4l_bridge.py +19 -2
  44. package/mcp_server/sample_engine/tools.py +140 -68
  45. package/mcp_server/splice_client/http_bridge.py +319 -116
  46. package/mcp_server/tools/automation.py +168 -0
  47. package/package.json +2 -2
  48. package/remote_script/LivePilot/__init__.py +1 -1
  49. package/remote_script/LivePilot/arrangement.py +216 -1
  50. 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
@@ -70,10 +70,9 @@ class SpliceHTTPConfig:
70
70
 
71
71
  JSON config shape:
72
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/...",
73
+ "base_url": "https://surfaces-graphql.splice.com",
74
+ "describe_endpoint": "/graphql",
75
+ "variation_endpoint": "/graphql",
77
76
  "timeout_sec": 30.0,
78
77
  "max_retries": 2,
79
78
  "allow_unverified_endpoints": false
@@ -82,10 +81,12 @@ class SpliceHTTPConfig:
82
81
  Any subset of keys is allowed; omitted keys fall through to defaults.
83
82
  """
84
83
 
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"
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)"
89
90
  timeout_sec: float = 30.0
90
91
  max_retries: int = 2
91
92
  # Whether any of the above values came from user config (file or env)
@@ -111,7 +112,6 @@ class SpliceHTTPConfig:
111
112
  if isinstance(data, dict):
112
113
  for key in (
113
114
  "base_url", "describe_endpoint", "variation_endpoint",
114
- "search_with_sound_endpoint",
115
115
  ):
116
116
  if key in data and isinstance(data[key], str):
117
117
  setattr(instance, key, data[key])
@@ -147,7 +147,6 @@ class SpliceHTTPConfig:
147
147
  ("SPLICE_API_BASE_URL", "base_url", str),
148
148
  ("SPLICE_DESCRIBE_ENDPOINT", "describe_endpoint", str),
149
149
  ("SPLICE_VARIATION_ENDPOINT", "variation_endpoint", str),
150
- ("SPLICE_SEARCH_WITH_SOUND_ENDPOINT", "search_with_sound_endpoint", str),
151
150
  ("SPLICE_HTTP_TIMEOUT", "timeout_sec", float),
152
151
  ("SPLICE_HTTP_RETRIES", "max_retries", int),
153
152
  )
@@ -174,11 +173,34 @@ class SpliceHTTPConfig:
174
173
  """True when at least one endpoint URL has been overridden by the
175
174
  user (JSON config file or env var).
176
175
 
177
- Defaults are unverified guesses; callers check this before making
178
- requests so we don't silently hit non-existent endpoints.
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.
179
182
  """
180
183
  return self._user_configured
181
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."""
189
+ return (
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
203
+
182
204
 
183
205
  # ── Auth token fetch ─────────────────────────────────────────────────
184
206
 
@@ -205,6 +227,160 @@ async def fetch_session_token(grpc_client) -> Optional[str]:
205
227
  return None
206
228
 
207
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
+
208
384
  # ── HTTP client ───────────────────────────────────────────────────────
209
385
 
210
386
 
@@ -274,7 +450,7 @@ class SpliceHTTPBridge:
274
450
  headers = {
275
451
  "Authorization": f"Bearer {token}",
276
452
  "Accept": "application/json",
277
- "User-Agent": "LivePilot/1.15 (+splice-http-bridge)",
453
+ "User-Agent": self.config.user_agent,
278
454
  }
279
455
  if body is not None:
280
456
  data_bytes = json.dumps(body).encode("utf-8")
@@ -341,94 +517,121 @@ class SpliceHTTPBridge:
341
517
  bpm: Optional[int] = None,
342
518
  key: Optional[str] = None,
343
519
  limit: int = 20,
520
+ rephrase: bool = True,
344
521
  ) -> 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.
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.
350
541
  """
351
- if not self.config.is_user_configured:
542
+ if not self.config.describe_verified:
352
543
  raise SpliceHTTPError(
353
544
  code="ENDPOINT_NOT_CONFIGURED",
354
545
  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."
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)."
359
550
  ),
360
- endpoint=self.config.describe_endpoint,
551
+ endpoint=f"{self.config.base_url}{self.config.describe_endpoint}",
361
552
  )
362
- body = {
363
- "description": description,
553
+
554
+ query = _load_graphql_query("samples_search")
555
+ variables: dict = {
556
+ "query": str(description),
364
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,
365
569
  }
366
570
  if bpm is not None:
367
- body["bpm"] = int(bpm)
571
+ variables["bpm"] = str(int(bpm))
368
572
  if key:
369
- body["key"] = key
370
- 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)
371
583
 
372
584
  async def generate_variation(
373
585
  self,
374
- file_hash: str,
375
- target_key: Optional[str] = None,
376
- target_bpm: Optional[int] = None,
377
- count: int = 1,
586
+ uuid: str,
587
+ is_legacy: bool = True,
378
588
  ) -> 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.
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.
383
605
  """
384
- if not self.config.is_user_configured:
606
+ if not self.config.variation_verified:
385
607
  raise SpliceHTTPError(
386
608
  code="ENDPOINT_NOT_CONFIGURED",
387
609
  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."
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."
392
613
  ),
393
- endpoint=self.config.variation_endpoint,
614
+ endpoint=f"{self.config.base_url}{self.config.variation_endpoint}",
394
615
  )
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:
616
+ if not uuid or not isinstance(uuid, str):
415
617
  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,
618
+ code="INVALID_UUID",
619
+ message="uuid must be a non-empty string",
620
+ endpoint=self.config.variation_endpoint,
424
621
  )
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
- )
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.