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.
- package/CHANGELOG.md +344 -5
- package/README.md +16 -15
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +85 -0
- package/mcp_server/atlas/device_atlas.json +3183 -382
- package/mcp_server/atlas/device_techniques_index.json +1510 -0
- package/mcp_server/atlas/enrichments/__init__.py +1 -0
- package/mcp_server/atlas/enrichments/audio_effects/amp.yaml +112 -0
- package/mcp_server/atlas/enrichments/audio_effects/audio_effect_rack.yaml +77 -0
- package/mcp_server/atlas/enrichments/audio_effects/cabinet.yaml +81 -0
- package/mcp_server/atlas/enrichments/audio_effects/corpus.yaml +128 -0
- package/mcp_server/atlas/enrichments/audio_effects/envelope_follower.yaml +99 -0
- package/mcp_server/atlas/enrichments/audio_effects/external_audio_effect.yaml +64 -0
- package/mcp_server/atlas/enrichments/audio_effects/looper.yaml +85 -0
- package/mcp_server/atlas/enrichments/audio_effects/pitch_hack.yaml +61 -0
- package/mcp_server/atlas/enrichments/audio_effects/pitchloop89.yaml +111 -0
- package/mcp_server/atlas/enrichments/audio_effects/re_enveloper.yaml +51 -0
- package/mcp_server/atlas/enrichments/audio_effects/resonators.yaml +121 -0
- package/mcp_server/atlas/enrichments/audio_effects/snipper.yaml +53 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_blur.yaml +64 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectrum.yaml +61 -0
- package/mcp_server/atlas/enrichments/audio_effects/tuner.yaml +43 -0
- package/mcp_server/atlas/enrichments/audio_effects/utility.yaml +118 -0
- package/mcp_server/atlas/enrichments/audio_effects/vocoder.yaml +94 -0
- package/mcp_server/atlas/enrichments/instruments/analog.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/bass.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +75 -0
- package/mcp_server/atlas/enrichments/instruments/collision.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/drift.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/drum_rack.yaml +142 -0
- package/mcp_server/atlas/enrichments/instruments/electric.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/emit.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/granulator_iii.yaml +124 -0
- package/mcp_server/atlas/enrichments/instruments/harmonic_drone_generator.yaml +83 -0
- package/mcp_server/atlas/enrichments/instruments/impulse.yaml +47 -0
- package/mcp_server/atlas/enrichments/instruments/meld.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/operator.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/poli.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/sampler.yaml +12 -0
- package/mcp_server/atlas/enrichments/instruments/simpler.yaml +15 -0
- package/mcp_server/atlas/enrichments/instruments/sting_iftah.yaml +44 -0
- package/mcp_server/atlas/enrichments/instruments/tension.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +11 -0
- package/mcp_server/atlas/enrichments/midi_effects/expressive_chords.yaml +38 -0
- package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +49 -0
- package/mcp_server/atlas/enrichments/midi_effects/microtuner.yaml +83 -0
- package/mcp_server/atlas/enrichments/midi_effects/patterns_iftah.yaml +38 -0
- package/mcp_server/atlas/enrichments/midi_effects/phase_pattern.yaml +51 -0
- package/mcp_server/atlas/enrichments/midi_effects/polyrhythm.yaml +46 -0
- package/mcp_server/atlas/enrichments/midi_effects/retrigger.yaml +40 -0
- package/mcp_server/atlas/enrichments/midi_effects/slice_shuffler.yaml +39 -0
- package/mcp_server/atlas/enrichments/midi_effects/sq_sequencer.yaml +39 -0
- package/mcp_server/atlas/enrichments/midi_effects/stages.yaml +38 -0
- package/mcp_server/atlas/enrichments/utility/arrangement_looper.yaml +31 -0
- package/mcp_server/atlas/enrichments/utility/cv_clock_in.yaml +25 -0
- package/mcp_server/atlas/enrichments/utility/cv_clock_out.yaml +25 -0
- package/mcp_server/atlas/enrichments/utility/cv_envelope_follower.yaml +38 -0
- package/mcp_server/atlas/enrichments/utility/cv_in.yaml +26 -0
- package/mcp_server/atlas/enrichments/utility/cv_instrument.yaml +34 -0
- package/mcp_server/atlas/enrichments/utility/cv_lfo.yaml +38 -0
- package/mcp_server/atlas/enrichments/utility/cv_shaper.yaml +35 -0
- package/mcp_server/atlas/enrichments/utility/cv_triggers.yaml +26 -0
- package/mcp_server/atlas/enrichments/utility/cv_utility.yaml +37 -0
- package/mcp_server/atlas/enrichments/utility/performer.yaml +51 -0
- package/mcp_server/atlas/enrichments/utility/prearranger.yaml +36 -0
- package/mcp_server/atlas/enrichments/utility/rotating_rhythm_generator.yaml +35 -0
- package/mcp_server/atlas/enrichments/utility/surround_panner.yaml +40 -0
- package/mcp_server/atlas/enrichments/utility/variations.yaml +40 -0
- package/mcp_server/atlas/enrichments/utility/vector_map.yaml +57 -0
- package/mcp_server/atlas/tools.py +291 -0
- package/mcp_server/m4l_bridge.py +19 -2
- package/mcp_server/sample_engine/tools.py +190 -72
- package/mcp_server/server.py +18 -6
- package/mcp_server/splice_client/client.py +90 -18
- package/mcp_server/splice_client/http_bridge.py +414 -138
- package/mcp_server/splice_client/models.py +12 -0
- package/mcp_server/tools/analyzer.py +150 -1
- package/mcp_server/tools/automation.py +168 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +216 -1
- package/server.json +3 -3
|
@@ -1,40 +1,40 @@
|
|
|
1
1
|
"""HTTPS bridge for Splice plugin-exclusive features.
|
|
2
2
|
|
|
3
|
-
The Splice
|
|
4
|
-
local gRPC service:
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
##
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
`
|
|
37
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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":
|
|
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
|
-
|
|
275
|
-
`
|
|
276
|
-
|
|
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.
|
|
542
|
+
if not self.config.describe_verified:
|
|
279
543
|
raise SpliceHTTPError(
|
|
280
544
|
code="ENDPOINT_NOT_CONFIGURED",
|
|
281
545
|
message=(
|
|
282
|
-
"Describe
|
|
283
|
-
"
|
|
284
|
-
"
|
|
285
|
-
"
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
571
|
+
variables["bpm"] = str(int(bpm))
|
|
295
572
|
if key:
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
target_bpm: Optional[int] = None,
|
|
304
|
-
count: int = 1,
|
|
586
|
+
uuid: str,
|
|
587
|
+
is_legacy: bool = True,
|
|
305
588
|
) -> dict:
|
|
306
|
-
"""
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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.
|
|
606
|
+
if not self.config.variation_verified:
|
|
312
607
|
raise SpliceHTTPError(
|
|
313
608
|
code="ENDPOINT_NOT_CONFIGURED",
|
|
314
609
|
message=(
|
|
315
|
-
"
|
|
316
|
-
"
|
|
317
|
-
"
|
|
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
|
-
|
|
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="
|
|
344
|
-
message=
|
|
345
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
"
|
|
357
|
-
"
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|