livepilot 1.16.0 → 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.
- package/CHANGELOG.md +75 -5
- package/README.md +11 -11
- 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/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/snipper.yaml +36 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_blur.yaml +64 -0
- package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +37 -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/sting_iftah.yaml +44 -0
- package/mcp_server/atlas/enrichments/midi_effects/expressive_chords.yaml +38 -0
- package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +32 -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 +36 -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 +36 -0
- package/mcp_server/sample_engine/tools.py +50 -4
- package/mcp_server/server.py +18 -6
- package/mcp_server/splice_client/client.py +90 -18
- package/mcp_server/splice_client/http_bridge.py +101 -28
- package/mcp_server/splice_client/models.py +12 -0
- package/mcp_server/tools/analyzer.py +150 -1
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +3 -3
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
id: vector_map
|
|
2
|
+
name: Vector Map
|
|
3
|
+
sonic_description: >
|
|
4
|
+
Particle-physics modulation router (Inspired by Nature, Dillon Bastan).
|
|
5
|
+
One particle drives multiple parameter destinations simultaneously. Unlike
|
|
6
|
+
an LFO (one source → one destination), Vector Map lets a single particle's
|
|
7
|
+
position control filter cutoff AND send level AND reverb decay at once —
|
|
8
|
+
coupled modulation that LFOs cannot produce. Essential for organic,
|
|
9
|
+
physically-coherent sound design.
|
|
10
|
+
category: modulation_source
|
|
11
|
+
character_tags: [particle_physics, coupled_modulation, multi_destination, generative]
|
|
12
|
+
use_cases: [coupled_modulation, non_trivial_modulation, physical_motion]
|
|
13
|
+
genre_affinity:
|
|
14
|
+
primary: [experimental, ambient, deep_minimal]
|
|
15
|
+
secondary: [idm, sound_design]
|
|
16
|
+
complexity: advanced
|
|
17
|
+
introduced_in: "11.0"
|
|
18
|
+
pack: Inspired by Nature
|
|
19
|
+
creator: Dillon Bastan
|
|
20
|
+
class_name: PluginDevice
|
|
21
|
+
|
|
22
|
+
key_parameters:
|
|
23
|
+
- name: "Particle Behavior"
|
|
24
|
+
description: "Gravity, friction, forces — defines how the particle moves."
|
|
25
|
+
- name: "Destination Mappings"
|
|
26
|
+
description: "Route the particle's X/Y/velocity to multiple Live parameters."
|
|
27
|
+
- name: "Depth per destination"
|
|
28
|
+
description: "How much the particle influences each destination."
|
|
29
|
+
|
|
30
|
+
signature_techniques:
|
|
31
|
+
- name: "Coupled filter + reverb motion"
|
|
32
|
+
description: "Particle X → filter cutoff, particle Y → reverb wet, particle velocity → dry/wet balance. Single physical motion drives a 3-parameter sonic change."
|
|
33
|
+
aesthetic: [ambient, experimental]
|
|
34
|
+
|
|
35
|
+
learn_more:
|
|
36
|
+
pack: "Inspired by Nature"
|
|
@@ -205,6 +205,7 @@ async def search_samples(
|
|
|
205
205
|
max_results: int = 10,
|
|
206
206
|
free_only: bool = False,
|
|
207
207
|
q: Optional[str] = None,
|
|
208
|
+
collection_uuid: str = "",
|
|
208
209
|
) -> dict:
|
|
209
210
|
"""Search for samples across Splice library, Ableton browser, and local filesystem.
|
|
210
211
|
|
|
@@ -224,6 +225,9 @@ async def search_samples(
|
|
|
224
225
|
key: prefer samples in this key (e.g., "Cm", "F#")
|
|
225
226
|
bpm_range: "min-max" BPM range (e.g., "120-130")
|
|
226
227
|
source: "splice", "browser", "filesystem", or None for all
|
|
228
|
+
collection_uuid: scope Splice results to a user collection (Likes,
|
|
229
|
+
bass, keys, etc.). Obtain via splice_list_collections. When set,
|
|
230
|
+
browser/filesystem sources are skipped — this is taste-scoped search.
|
|
227
231
|
max_results: maximum results to return (default 10)
|
|
228
232
|
free_only: if True, only return samples that cost nothing to license
|
|
229
233
|
(IsPremium=False or Price=0). Under the Ableton Live plan these
|
|
@@ -247,6 +251,11 @@ async def search_samples(
|
|
|
247
251
|
except ValueError:
|
|
248
252
|
pass
|
|
249
253
|
|
|
254
|
+
# When scoped to a Splice collection, force source=splice and skip
|
|
255
|
+
# browser/filesystem since those don't carry collection metadata.
|
|
256
|
+
if collection_uuid and source is None:
|
|
257
|
+
source = "splice"
|
|
258
|
+
|
|
250
259
|
# Splice search — prefer gRPC online catalog when available, fall back
|
|
251
260
|
# to local SQLite index. See docs/2026-04-14-bugs-discovered.md — P0-2.
|
|
252
261
|
if source in (None, "splice"):
|
|
@@ -267,6 +276,7 @@ async def search_samples(
|
|
|
267
276
|
per_page=max_results,
|
|
268
277
|
page=1,
|
|
269
278
|
purchased_only=False,
|
|
279
|
+
collection_uuid=collection_uuid,
|
|
270
280
|
)
|
|
271
281
|
for s in grpc_result.samples[:max_results]:
|
|
272
282
|
if free_only and not s.is_free:
|
|
@@ -782,11 +792,15 @@ async def get_splice_credits(ctx: Context) -> dict:
|
|
|
782
792
|
gating = "credit_floor"
|
|
783
793
|
can_download = remaining > CREDIT_HARD_FLOOR
|
|
784
794
|
|
|
795
|
+
from ..splice_client.client import _read_plan_kind_override
|
|
796
|
+
plan_override_active = _read_plan_kind_override()
|
|
797
|
+
|
|
785
798
|
return {
|
|
786
799
|
"connected": True,
|
|
787
800
|
"username": info.username,
|
|
788
801
|
"plan_raw": info.plan,
|
|
789
802
|
"plan_kind": plan.value,
|
|
803
|
+
"plan_kind_override": plan_override_active,
|
|
790
804
|
"sounds_plan_id": info.sounds_plan_id,
|
|
791
805
|
"features": info.features,
|
|
792
806
|
"user_uuid": info.user_uuid,
|
|
@@ -1091,16 +1105,34 @@ async def splice_preview_sample(
|
|
|
1091
1105
|
if client is None or not getattr(client, "connected", False):
|
|
1092
1106
|
return {"ok": False, "error": "Splice gRPC not connected"}
|
|
1093
1107
|
|
|
1108
|
+
# Two-stage lookup: SampleInfo is the fast path but only returns
|
|
1109
|
+
# full metadata (including the signed PreviewURL) for downloaded or
|
|
1110
|
+
# purchased samples. For un-downloaded catalog items, fall back to
|
|
1111
|
+
# SearchSamples(FileHash=...) which hits the catalog index and always
|
|
1112
|
+
# returns PreviewURL. Observed live 2026-04-22.
|
|
1094
1113
|
sample = None
|
|
1095
1114
|
try:
|
|
1096
1115
|
sample = await client.get_sample_info(file_hash)
|
|
1097
1116
|
except Exception as exc:
|
|
1098
|
-
|
|
1117
|
+
logger.debug("SampleInfo lookup failed, falling back to search: %s", exc)
|
|
1118
|
+
|
|
1119
|
+
if sample is None or not sample.preview_url:
|
|
1120
|
+
try:
|
|
1121
|
+
search = await client.search_samples(file_hash=file_hash, per_page=1)
|
|
1122
|
+
if search.samples:
|
|
1123
|
+
sample = search.samples[0]
|
|
1124
|
+
except Exception as exc:
|
|
1125
|
+
return {"ok": False, "error": f"PreviewURL lookup failed: {exc}"}
|
|
1099
1126
|
|
|
1100
1127
|
if sample is None or not sample.preview_url:
|
|
1101
1128
|
return {
|
|
1102
1129
|
"ok": False,
|
|
1103
|
-
"error":
|
|
1130
|
+
"error": (
|
|
1131
|
+
"No preview URL available for this sample. Splice may "
|
|
1132
|
+
"require the sample to be in a public catalog index. "
|
|
1133
|
+
"Try splice_catalog_hunt first to obtain a fresh "
|
|
1134
|
+
"preview_url from the search result directly."
|
|
1135
|
+
),
|
|
1104
1136
|
"file_hash": file_hash,
|
|
1105
1137
|
}
|
|
1106
1138
|
|
|
@@ -1410,9 +1442,23 @@ async def splice_pack_info(ctx: Context, pack_uuid: str) -> dict:
|
|
|
1410
1442
|
return err
|
|
1411
1443
|
if not pack_uuid:
|
|
1412
1444
|
return {"ok": False, "error": "pack_uuid is required"}
|
|
1413
|
-
|
|
1445
|
+
# Pass the UUID through unchanged — Splice uses two valid UUID formats
|
|
1446
|
+
# (canonical 36-char and extended 43-char with longer last group). The
|
|
1447
|
+
# client tries BOTH forms during ListSamplePacks matching. An earlier
|
|
1448
|
+
# revision pre-truncated to 36 chars here, which incorrectly discarded
|
|
1449
|
+
# part of a legitimate extended UUID (observed 2026-04-22 live: pack
|
|
1450
|
+
# "1170db75-0ce1-5280-bb61-887a0dd7f26bf5a3951" is an owned pack but
|
|
1451
|
+
# pre-truncation made the client look for a UUID that didn't exist in
|
|
1452
|
+
# ListSamplePacks' response).
|
|
1453
|
+
submitted = pack_uuid.strip()
|
|
1454
|
+
pack, err_msg = await client.get_pack_info(submitted)
|
|
1414
1455
|
if pack is None:
|
|
1415
|
-
return {
|
|
1456
|
+
return {
|
|
1457
|
+
"ok": False,
|
|
1458
|
+
"error": err_msg or "Pack not found",
|
|
1459
|
+
"pack_uuid_submitted": submitted,
|
|
1460
|
+
"pack_uuid_original": pack_uuid,
|
|
1461
|
+
}
|
|
1416
1462
|
return {"ok": True, "pack": pack.to_dict()}
|
|
1417
1463
|
|
|
1418
1464
|
|
package/mcp_server/server.py
CHANGED
|
@@ -378,11 +378,21 @@ def _get_all_tools():
|
|
|
378
378
|
"_local_provider._tools",
|
|
379
379
|
lambda: list(mcp._local_provider._tools.values()),
|
|
380
380
|
),
|
|
381
|
-
#
|
|
382
|
-
#
|
|
383
|
-
#
|
|
384
|
-
(
|
|
381
|
+
# NB: mcp.list_tools() IS the public API, but it's a coroutine —
|
|
382
|
+
# can't be iterated in a sync context. We skip it here and rely on
|
|
383
|
+
# the internal probes. When FastMCP exposes a sync view we'll add
|
|
384
|
+
# it back. (Earlier form tried `list(mcp.list_tools())` which
|
|
385
|
+
# raised `RuntimeWarning: coroutine was never awaited` at every
|
|
386
|
+
# module import — removed 2026-04-22.)
|
|
385
387
|
]
|
|
388
|
+
# Observation 2026-04-22: some FastMCP 3.x builds keep BOTH the legacy
|
|
389
|
+
# `_tool_manager._tools` dict AND the newer `_local_provider._components`
|
|
390
|
+
# registry populated at the same time — but the legacy one lags the
|
|
391
|
+
# newer one (385 vs 422 during a recent startup). Returning the FIRST
|
|
392
|
+
# non-empty probe accidentally picked the stale view. Instead, collect
|
|
393
|
+
# every working probe and return the LARGEST view (the registry with
|
|
394
|
+
# the most tools is the authoritative one).
|
|
395
|
+
best: list = []
|
|
386
396
|
for label, fn in probes:
|
|
387
397
|
try:
|
|
388
398
|
tools = fn()
|
|
@@ -390,8 +400,10 @@ def _get_all_tools():
|
|
|
390
400
|
continue
|
|
391
401
|
except Exception: # noqa: BLE001 — any error from an internal probe means "skip"
|
|
392
402
|
continue
|
|
393
|
-
if tools:
|
|
394
|
-
|
|
403
|
+
if tools and len(tools) > len(best):
|
|
404
|
+
best = tools
|
|
405
|
+
if best:
|
|
406
|
+
return best
|
|
395
407
|
|
|
396
408
|
# All probes empty. Surface fastmcp version + attempted paths so the
|
|
397
409
|
# breakage is diagnosable without re-reading the code.
|
|
@@ -119,6 +119,30 @@ def _try_import_protos():
|
|
|
119
119
|
return None, None
|
|
120
120
|
|
|
121
121
|
|
|
122
|
+
def _read_plan_kind_override() -> Optional[str]:
|
|
123
|
+
"""Read `plan_kind_override` from ~/.livepilot/splice.json, if present.
|
|
124
|
+
|
|
125
|
+
Lets the user pin their Splice plan_kind when gRPC data is ambiguous.
|
|
126
|
+
Example config:
|
|
127
|
+
{"plan_kind_override": "ableton_live"}
|
|
128
|
+
Returns None silently on any I/O or JSON error.
|
|
129
|
+
"""
|
|
130
|
+
import json
|
|
131
|
+
path = os.path.expanduser("~/.livepilot/splice.json")
|
|
132
|
+
if not os.path.isfile(path):
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
136
|
+
data = json.load(f)
|
|
137
|
+
if isinstance(data, dict):
|
|
138
|
+
value = data.get("plan_kind_override")
|
|
139
|
+
if isinstance(value, str) and value.strip():
|
|
140
|
+
return value.strip()
|
|
141
|
+
except (OSError, json.JSONDecodeError):
|
|
142
|
+
pass
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
122
146
|
class SpliceGRPCClient:
|
|
123
147
|
"""Async gRPC client for Splice desktop's App service."""
|
|
124
148
|
|
|
@@ -486,10 +510,15 @@ class SpliceGRPCClient:
|
|
|
486
510
|
int(user.SoundsPlan) if hasattr(user, "SoundsPlan") else 0
|
|
487
511
|
)
|
|
488
512
|
uuid_str = str(user.UUID) if hasattr(user, "UUID") else ""
|
|
513
|
+
# Read optional plan_kind_override from ~/.livepilot/splice.json.
|
|
514
|
+
# Users who know their Splice plan can pin the classification
|
|
515
|
+
# here when the gRPC data is ambiguous. See models.classify_plan.
|
|
516
|
+
override = _read_plan_kind_override()
|
|
489
517
|
plan_kind = classify_plan(
|
|
490
518
|
sounds_status=user.SoundsStatus,
|
|
491
519
|
sounds_plan=sounds_plan,
|
|
492
520
|
features=features,
|
|
521
|
+
override=override,
|
|
493
522
|
)
|
|
494
523
|
creds = SpliceCredits(
|
|
495
524
|
credits=user.Credits,
|
|
@@ -658,28 +687,71 @@ class SpliceGRPCClient:
|
|
|
658
687
|
|
|
659
688
|
# ── Packs ───────────────────────────────────────────────────────
|
|
660
689
|
|
|
661
|
-
async def get_pack_info(
|
|
662
|
-
|
|
690
|
+
async def get_pack_info(
|
|
691
|
+
self, pack_uuid: str, max_pages: int = 5,
|
|
692
|
+
) -> tuple[Optional[SplicePack], Optional[str]]:
|
|
693
|
+
"""Fetch metadata for a single sample pack.
|
|
694
|
+
|
|
695
|
+
Splice's gRPC `App` service does NOT expose a per-UUID
|
|
696
|
+
`SamplePackInfo` RPC (only `ListSamplePacks` is published in the
|
|
697
|
+
service definition — the `SamplePackInfoRequest` / `...Response`
|
|
698
|
+
messages exist in the descriptor but no RPC binds them). So this
|
|
699
|
+
implementation paginates `ListSamplePacks` and matches client-side.
|
|
700
|
+
|
|
701
|
+
Only finds packs the user has engaged with (owned / downloaded /
|
|
702
|
+
in their library). Catalog-only packs return None with an
|
|
703
|
+
explanatory error.
|
|
704
|
+
|
|
705
|
+
Returns (pack, error) — `error` is a user-readable string when the
|
|
706
|
+
lookup didn't find a match.
|
|
707
|
+
"""
|
|
663
708
|
if not self.connected:
|
|
664
|
-
return None
|
|
709
|
+
return None, "Splice gRPC not connected"
|
|
665
710
|
pb2 = self._pb2
|
|
711
|
+
target = pack_uuid.strip()
|
|
712
|
+
# Splice uses two UUID formats: canonical 36-char and an "extended"
|
|
713
|
+
# form with a longer last group (observed 43 chars, e.g.
|
|
714
|
+
# "1170db75-0ce1-5280-bb61-887a0dd7f26bf5a3951"). Both variants
|
|
715
|
+
# appear in sounds.db and search results. We match BOTH when the
|
|
716
|
+
# caller submits one form — the other form might be the one the
|
|
717
|
+
# server returns for the same pack. Observed 2026-04-22.
|
|
718
|
+
canonical = target[:36] if len(target) > 36 else target
|
|
719
|
+
targets = {target, canonical}
|
|
720
|
+
next_token = 0
|
|
666
721
|
try:
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
722
|
+
for _page in range(max(1, int(max_pages))):
|
|
723
|
+
response = await self.stub.ListSamplePacks(
|
|
724
|
+
pb2.ListSamplePacksRequest(NextToken=next_token),
|
|
725
|
+
timeout=INFO_TIMEOUT,
|
|
726
|
+
)
|
|
727
|
+
for p in response.SamplePacks:
|
|
728
|
+
p_uuid = p.UUID
|
|
729
|
+
p_canonical = p_uuid[:36] if len(p_uuid) > 36 else p_uuid
|
|
730
|
+
if p_uuid in targets or p_canonical in targets:
|
|
731
|
+
return SplicePack(
|
|
732
|
+
uuid=p_uuid,
|
|
733
|
+
name=p.Name,
|
|
734
|
+
cover_url=p.CoverURL,
|
|
735
|
+
genre=p.Genre,
|
|
736
|
+
permalink=p.Permalink,
|
|
737
|
+
provider_name=p.ProviderName,
|
|
738
|
+
), None
|
|
739
|
+
# If no next-page token, we've exhausted the list.
|
|
740
|
+
new_token = int(response.NextToken)
|
|
741
|
+
if new_token == 0 or new_token == next_token:
|
|
742
|
+
break
|
|
743
|
+
next_token = new_token
|
|
680
744
|
except Exception as exc:
|
|
681
|
-
|
|
682
|
-
|
|
745
|
+
msg = f"ListSamplePacks gRPC call failed: {type(exc).__name__}: {exc}"
|
|
746
|
+
logger.warning(msg)
|
|
747
|
+
return None, msg
|
|
748
|
+
return None, (
|
|
749
|
+
f"Pack '{target}' not found in the user's library. "
|
|
750
|
+
"Splice's gRPC only lists packs the user has engaged with "
|
|
751
|
+
"(owned/downloaded/in library). Catalog-only packs can't be "
|
|
752
|
+
"looked up via this RPC. Use the Splice website or Desktop app "
|
|
753
|
+
"to browse un-engaged packs."
|
|
754
|
+
)
|
|
683
755
|
|
|
684
756
|
# ── Presets ─────────────────────────────────────────────────────
|
|
685
757
|
|
|
@@ -55,14 +55,31 @@ 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://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.
|
|
66
83
|
"""
|
|
67
84
|
|
|
68
85
|
base_url: str = "https://api.splice.com"
|
|
@@ -71,40 +88,96 @@ class SpliceHTTPConfig:
|
|
|
71
88
|
search_with_sound_endpoint: str = "/v1/search-with-sound"
|
|
72
89
|
timeout_sec: float = 30.0
|
|
73
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
|
|
74
94
|
|
|
75
95
|
@classmethod
|
|
76
|
-
def from_env(cls) -> "SpliceHTTPConfig":
|
|
77
|
-
"""Load config
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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),
|
|
92
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
|
|
93
171
|
|
|
94
172
|
@property
|
|
95
173
|
def is_user_configured(self) -> bool:
|
|
96
|
-
"""True when at least one endpoint URL has been overridden by
|
|
174
|
+
"""True when at least one endpoint URL has been overridden by the
|
|
175
|
+
user (JSON config file or env var).
|
|
97
176
|
|
|
98
177
|
Defaults are unverified guesses; callers check this before making
|
|
99
178
|
requests so we don't silently hit non-existent endpoints.
|
|
100
179
|
"""
|
|
101
|
-
return
|
|
102
|
-
"SPLICE_API_BASE_URL" in os.environ
|
|
103
|
-
or "SPLICE_DESCRIBE_ENDPOINT" in os.environ
|
|
104
|
-
or "SPLICE_VARIATION_ENDPOINT" in os.environ
|
|
105
|
-
or "SPLICE_SEARCH_WITH_SOUND_ENDPOINT" in os.environ
|
|
106
|
-
or os.environ.get("SPLICE_ALLOW_UNVERIFIED_ENDPOINTS") == "1"
|
|
107
|
-
)
|
|
180
|
+
return self._user_configured
|
|
108
181
|
|
|
109
182
|
|
|
110
183
|
# ── Auth token fetch ─────────────────────────────────────────────────
|
|
@@ -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
|