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.
Files changed (50) hide show
  1. package/CHANGELOG.md +75 -5
  2. package/README.md +11 -11
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/atlas/enrichments/audio_effects/pitch_hack.yaml +61 -0
  7. package/mcp_server/atlas/enrichments/audio_effects/pitchloop89.yaml +111 -0
  8. package/mcp_server/atlas/enrichments/audio_effects/re_enveloper.yaml +51 -0
  9. package/mcp_server/atlas/enrichments/audio_effects/snipper.yaml +36 -0
  10. package/mcp_server/atlas/enrichments/audio_effects/spectral_blur.yaml +64 -0
  11. package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +37 -0
  12. package/mcp_server/atlas/enrichments/instruments/granulator_iii.yaml +124 -0
  13. package/mcp_server/atlas/enrichments/instruments/harmonic_drone_generator.yaml +83 -0
  14. package/mcp_server/atlas/enrichments/instruments/impulse.yaml +47 -0
  15. package/mcp_server/atlas/enrichments/instruments/sting_iftah.yaml +44 -0
  16. package/mcp_server/atlas/enrichments/midi_effects/expressive_chords.yaml +38 -0
  17. package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +32 -0
  18. package/mcp_server/atlas/enrichments/midi_effects/microtuner.yaml +83 -0
  19. package/mcp_server/atlas/enrichments/midi_effects/patterns_iftah.yaml +38 -0
  20. package/mcp_server/atlas/enrichments/midi_effects/phase_pattern.yaml +51 -0
  21. package/mcp_server/atlas/enrichments/midi_effects/polyrhythm.yaml +46 -0
  22. package/mcp_server/atlas/enrichments/midi_effects/retrigger.yaml +40 -0
  23. package/mcp_server/atlas/enrichments/midi_effects/slice_shuffler.yaml +39 -0
  24. package/mcp_server/atlas/enrichments/midi_effects/sq_sequencer.yaml +39 -0
  25. package/mcp_server/atlas/enrichments/midi_effects/stages.yaml +38 -0
  26. package/mcp_server/atlas/enrichments/utility/arrangement_looper.yaml +31 -0
  27. package/mcp_server/atlas/enrichments/utility/cv_clock_in.yaml +25 -0
  28. package/mcp_server/atlas/enrichments/utility/cv_clock_out.yaml +25 -0
  29. package/mcp_server/atlas/enrichments/utility/cv_envelope_follower.yaml +38 -0
  30. package/mcp_server/atlas/enrichments/utility/cv_in.yaml +26 -0
  31. package/mcp_server/atlas/enrichments/utility/cv_instrument.yaml +34 -0
  32. package/mcp_server/atlas/enrichments/utility/cv_lfo.yaml +38 -0
  33. package/mcp_server/atlas/enrichments/utility/cv_shaper.yaml +35 -0
  34. package/mcp_server/atlas/enrichments/utility/cv_triggers.yaml +26 -0
  35. package/mcp_server/atlas/enrichments/utility/cv_utility.yaml +37 -0
  36. package/mcp_server/atlas/enrichments/utility/performer.yaml +36 -0
  37. package/mcp_server/atlas/enrichments/utility/prearranger.yaml +36 -0
  38. package/mcp_server/atlas/enrichments/utility/rotating_rhythm_generator.yaml +35 -0
  39. package/mcp_server/atlas/enrichments/utility/surround_panner.yaml +40 -0
  40. package/mcp_server/atlas/enrichments/utility/variations.yaml +40 -0
  41. package/mcp_server/atlas/enrichments/utility/vector_map.yaml +36 -0
  42. package/mcp_server/sample_engine/tools.py +50 -4
  43. package/mcp_server/server.py +18 -6
  44. package/mcp_server/splice_client/client.py +90 -18
  45. package/mcp_server/splice_client/http_bridge.py +101 -28
  46. package/mcp_server/splice_client/models.py +12 -0
  47. package/mcp_server/tools/analyzer.py +150 -1
  48. package/package.json +2 -2
  49. package/remote_script/LivePilot/__init__.py +1 -1
  50. 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
- return {"ok": False, "error": f"SampleInfo failed: {exc}"}
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": "No preview URL available for this sample",
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
- pack = await client.get_pack_info(pack_uuid)
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 {"ok": False, "error": "Pack not found or gRPC call failed"}
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
 
@@ -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
- # Public-API future path (what we're asking for in the upstream FR);
382
- # harmless to probe now so that once it ships we can lift the ceiling
383
- # without touching this function again.
384
- ("list_tools", lambda: list(mcp.list_tools())),
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
- return tools
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(self, pack_uuid: str) -> Optional[SplicePack]:
662
- """Fetch metadata for a single sample pack."""
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
- response = await self.stub.SamplePackInfo(
668
- pb2.SamplePackInfoRequest(UUID=pack_uuid),
669
- timeout=INFO_TIMEOUT,
670
- )
671
- p = response.Pack
672
- return SplicePack(
673
- uuid=p.UUID,
674
- name=p.Name,
675
- cover_url=p.CoverURL,
676
- genre=p.Genre,
677
- permalink=p.Permalink,
678
- provider_name=p.ProviderName,
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
- logger.warning(f"SamplePackInfo failed: {exc}")
682
- return None
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
- 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://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 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)),
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 env var.
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