livepilot 1.27.2 → 1.27.3

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 (113) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +97 -39
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +1 -1
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  28. package/mcp_server/composer/full/apply.py +40 -34
  29. package/mcp_server/composer/full/engine.py +66 -32
  30. package/mcp_server/composer/tools.py +4 -3
  31. package/mcp_server/connection.py +67 -9
  32. package/mcp_server/creative_constraints/engine.py +10 -2
  33. package/mcp_server/creative_constraints/tools.py +24 -7
  34. package/mcp_server/curves.py +30 -7
  35. package/mcp_server/device_forge/builder.py +40 -11
  36. package/mcp_server/device_forge/models.py +9 -0
  37. package/mcp_server/device_forge/tools.py +30 -11
  38. package/mcp_server/experiment/engine.py +29 -22
  39. package/mcp_server/experiment/tools.py +15 -5
  40. package/mcp_server/m4l_bridge.py +69 -7
  41. package/mcp_server/memory/taste_graph.py +43 -0
  42. package/mcp_server/memory/technique_store.py +26 -3
  43. package/mcp_server/memory/tools.py +62 -4
  44. package/mcp_server/mix_engine/critics.py +187 -30
  45. package/mcp_server/mix_engine/models.py +21 -1
  46. package/mcp_server/mix_engine/state_builder.py +87 -8
  47. package/mcp_server/mix_engine/tools.py +16 -4
  48. package/mcp_server/performance_engine/tools.py +56 -8
  49. package/mcp_server/persistence/base_store.py +11 -0
  50. package/mcp_server/persistence/project_store.py +132 -8
  51. package/mcp_server/persistence/taste_store.py +90 -7
  52. package/mcp_server/preview_studio/engine.py +40 -10
  53. package/mcp_server/preview_studio/models.py +40 -0
  54. package/mcp_server/preview_studio/tools.py +56 -12
  55. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  56. package/mcp_server/project_brain/models.py +2 -0
  57. package/mcp_server/project_brain/role_graph.py +13 -7
  58. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  59. package/mcp_server/reference_engine/profile_builder.py +47 -4
  60. package/mcp_server/reference_engine/tools.py +6 -0
  61. package/mcp_server/runtime/execution_router.py +51 -1
  62. package/mcp_server/runtime/tools.py +0 -1
  63. package/mcp_server/sample_engine/sources.py +0 -2
  64. package/mcp_server/sample_engine/tools.py +19 -38
  65. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  66. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  67. package/mcp_server/semantic_moves/resolvers.py +45 -0
  68. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  69. package/mcp_server/semantic_moves/tools.py +80 -2
  70. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  71. package/mcp_server/server.py +26 -24
  72. package/mcp_server/session_continuity/tracker.py +51 -3
  73. package/mcp_server/song_brain/builder.py +47 -5
  74. package/mcp_server/song_brain/tools.py +21 -7
  75. package/mcp_server/sound_design/critics.py +1 -0
  76. package/mcp_server/splice_client/client.py +117 -33
  77. package/mcp_server/splice_client/http_bridge.py +15 -3
  78. package/mcp_server/splice_client/quota.py +28 -0
  79. package/mcp_server/stuckness_detector/detector.py +8 -5
  80. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  81. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  82. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  83. package/mcp_server/tools/_perception_engine.py +6 -0
  84. package/mcp_server/tools/agent_os.py +4 -1
  85. package/mcp_server/tools/analyzer.py +198 -209
  86. package/mcp_server/tools/arrangement.py +7 -4
  87. package/mcp_server/tools/automation.py +24 -4
  88. package/mcp_server/tools/browser.py +25 -11
  89. package/mcp_server/tools/clips.py +6 -0
  90. package/mcp_server/tools/composition.py +33 -2
  91. package/mcp_server/tools/devices.py +53 -53
  92. package/mcp_server/tools/generative.py +14 -14
  93. package/mcp_server/tools/harmony.py +7 -7
  94. package/mcp_server/tools/mixing.py +4 -4
  95. package/mcp_server/tools/planner.py +68 -6
  96. package/mcp_server/tools/research.py +20 -2
  97. package/mcp_server/tools/theory.py +10 -10
  98. package/mcp_server/tools/transport.py +7 -2
  99. package/mcp_server/transition_engine/critics.py +13 -1
  100. package/mcp_server/user_corpus/tools.py +30 -1
  101. package/mcp_server/wonder_mode/engine.py +82 -9
  102. package/mcp_server/wonder_mode/session.py +32 -10
  103. package/mcp_server/wonder_mode/tools.py +14 -1
  104. package/package.json +1 -1
  105. package/remote_script/LivePilot/__init__.py +1 -1
  106. package/remote_script/LivePilot/arrangement.py +93 -33
  107. package/remote_script/LivePilot/browser.py +60 -4
  108. package/remote_script/LivePilot/devices.py +132 -62
  109. package/remote_script/LivePilot/mixing.py +31 -5
  110. package/remote_script/LivePilot/server.py +94 -22
  111. package/remote_script/LivePilot/transport.py +11 -0
  112. package/requirements.txt +5 -5
  113. package/server.json +2 -2
@@ -128,6 +128,7 @@ class SectionNode:
128
128
  section_type: str = "unknown"
129
129
  energy: float = 0.0
130
130
  density: float = 0.0
131
+ tracks_active: list[int] = field(default_factory=list)
131
132
 
132
133
  def to_dict(self) -> dict:
133
134
  return {
@@ -137,6 +138,7 @@ class SectionNode:
137
138
  "section_type": self.section_type,
138
139
  "energy": self.energy,
139
140
  "density": self.density,
141
+ "tracks_active": list(self.tracks_active),
140
142
  }
141
143
 
142
144
 
@@ -41,18 +41,24 @@ def build_role_graph(
41
41
  # Convert brain section dicts to composition-engine SectionNodes
42
42
  ce_sections = []
43
43
  for sec in sections:
44
- # Determine which tracks are active in this section
45
- # If notes_map has data for this section, those tracks are active
44
+ # Determine which tracks are active in this section.
45
+ # Primary source is the clip-presence matrix (`tracks_active`),
46
+ # which includes AUDIO tracks that carry no MIDI notes. Union in
47
+ # any note-bearing tracks as a fallback so MIDI-only data (legacy
48
+ # callers that pass no tracks_active) still produces roles.
46
49
  section_id = sec.get("section_id", sec.get("id", ""))
47
- active_tracks = []
50
+ active_set = {int(t) for t in (sec.get("tracks_active") or [])}
48
51
  section_notes = notes_map.get(section_id, {})
49
52
  for t_idx, notes in section_notes.items():
50
53
  if notes:
51
- active_tracks.append(t_idx)
54
+ active_set.add(int(t_idx))
52
55
 
53
- # Also include all tracks if no notes data (assume all active)
54
- if not active_tracks and not notes_map:
55
- active_tracks = [t.get("index", 0) for t in track_data]
56
+ # Also include all tracks if no clip-presence and no notes data
57
+ # (assume all active e.g. a bare section with no matrix info).
58
+ if not active_set and not notes_map:
59
+ active_set = {int(t.get("index", 0)) for t in track_data}
60
+
61
+ active_tracks = sorted(active_set)
56
62
 
57
63
  try:
58
64
  stype = CESectionType(sec.get("section_type", "unknown"))
@@ -22,10 +22,49 @@ _RELEVANCE_THRESHOLDS: dict[str, float] = {
22
22
  "harmonic": 0.0, # always relevant if different
23
23
  }
24
24
 
25
+ # Per-domain normalization scales for _compute_overall_distance. Raw deltas
26
+ # live in incompatible units (loudness in LU, width/spectral/density in 0-1
27
+ # fractions, pacing in section COUNT), so summing their squares directly lets
28
+ # the large-magnitude domains (loudness, pacing) dominate while sub-unity
29
+ # spectral/width deltas contribute nothing (P2-37). Dividing each delta by a
30
+ # characteristic "one notable step" for its domain converts every term into a
31
+ # comparable, dimensionless number before the Euclidean sum.
32
+ _NORMALIZATION_SCALES: dict[str, float] = {
33
+ "loudness": 6.0, # ~6 LU is a clearly audible loudness gap
34
+ "spectral": 0.1, # a 0.1 shift in a band's energy fraction is large
35
+ "width": 0.2, # 0.2 of the 0-1 stereo-width range is substantial
36
+ "density": 0.3, # 0.3 of the 0-1 density range is a big arrangement move
37
+ "pacing": 2.0, # a 2-section structural difference is significant
38
+ "harmonic": 1.0, # harmonic delta is already a 0/1 indicator
39
+ }
40
+ _DEFAULT_NORMALIZATION_SCALE = 1.0
41
+
25
42
  # When a gap exceeds this fraction of the project value, closing it
26
43
  # risks flattening identity.
27
44
  _IDENTITY_WARNING_THRESHOLD = 0.6
28
45
 
46
+ # ReferenceProfile.loudness_posture is always integrated LUFS (see models.py).
47
+ # The project snapshot may report loudness in a different scale, so we tag it
48
+ # with "loudness_unit" and convert to LUFS here before differencing. The ITU
49
+ # BS.1770 K-weighting has near-unity broadband gain plus a fixed -0.691 dB
50
+ # absolute-calibration offset, so a full-program RMS dBFS ≈ LUFS - 0.691. This
51
+ # is an approximation (it ignores K-weighting's frequency tilt and gating), but
52
+ # it keeps both sides of the loudness delta on the SAME axis — far better than
53
+ # subtracting a plain dBFS RMS from an integrated LUFS (P2-36).
54
+ _RMS_DBFS_TO_LUFS_OFFSET = -0.691
55
+
56
+
57
+ def _project_loudness_in_lufs(loudness: float, unit: str) -> float:
58
+ """Convert a project-snapshot loudness reading to the integrated-LUFS
59
+ scale used by ReferenceProfile.loudness_posture."""
60
+ if unit in ("lufs", "integrated_lufs"):
61
+ return loudness
62
+ if unit in ("rms_dbfs", "dbfs"):
63
+ return loudness + _RMS_DBFS_TO_LUFS_OFFSET
64
+ # Unknown unit: assume it is already on the LUFS axis rather than
65
+ # corrupting it with an offset we can't justify.
66
+ return loudness
67
+
29
68
 
30
69
  # ── Main analysis ──────────────────────────────────────────────────
31
70
 
@@ -50,7 +89,12 @@ def analyze_gaps(
50
89
  ref_id = f"{reference.source_type}"
51
90
 
52
91
  # 1. Loudness gap
53
- proj_loudness = project_snapshot.get("loudness", 0.0)
92
+ # Both sides must be on the integrated-LUFS axis. The reference always is;
93
+ # the project snapshot declares its unit so we can convert (P2-36).
94
+ raw_loudness = project_snapshot.get("loudness", 0.0)
95
+ proj_loudness = _project_loudness_in_lufs(
96
+ raw_loudness, project_snapshot.get("loudness_unit", "lufs")
97
+ )
54
98
  if reference.loudness_posture != 0.0 or proj_loudness != 0.0:
55
99
  delta = proj_loudness - reference.loudness_posture
56
100
  gaps.append(GapEntry(
@@ -200,11 +244,22 @@ def _is_identity_risk(project_value: float, delta: float) -> bool:
200
244
 
201
245
 
202
246
  def _compute_overall_distance(gaps: list[GapEntry]) -> float:
203
- """Euclidean-like distance across all relevant gap deltas."""
247
+ """Euclidean distance across relevant gap deltas, normalized per-domain.
248
+
249
+ Each delta is divided by a characteristic scale for its domain so that
250
+ deltas measured in incompatible units (LU, 0-1 fractions, section counts)
251
+ become dimensionless and comparable before the Euclidean sum (P2-37).
252
+ """
204
253
  relevant = [g for g in gaps if g.relevant]
205
254
  if not relevant:
206
255
  return 0.0
207
- sum_sq = sum(g.delta ** 2 for g in relevant)
256
+ sum_sq = 0.0
257
+ for g in relevant:
258
+ scale = _NORMALIZATION_SCALES.get(g.domain, _DEFAULT_NORMALIZATION_SCALE)
259
+ if scale <= 0:
260
+ scale = _DEFAULT_NORMALIZATION_SCALE
261
+ normalized = g.delta / scale
262
+ sum_sq += normalized ** 2
208
263
  return math.sqrt(sum_sq)
209
264
 
210
265
 
@@ -24,10 +24,30 @@ def build_audio_reference_profile(comparison_data: dict) -> ReferenceProfile:
24
24
  """
25
25
  band_deltas = comparison_data.get("band_deltas", {})
26
26
 
27
- # Reconstruct approximate reference spectral contour from band deltas.
28
- # The deltas are (mix - ref), so ref bands are conceptually the baseline.
27
+ # The reference profile's band_balance MUST be the reference's OWN
28
+ # absolute per-band energy — NOT band_deltas (mix - ref), which is a
29
+ # different quantity entirely (a signed difference, not a level). Using
30
+ # the deltas here made analyze_gaps compute (proj_band - delta), so an
31
+ # identical mix produced a spurious gap equal to the project's own band
32
+ # energy instead of ~0.
33
+ #
34
+ # Prefer the absolute reference_band_balance emitted by
35
+ # compare_to_reference. If only mix bands + deltas are present,
36
+ # reconstruct ref = mix - delta. Fall back to {} (no spectral contour)
37
+ # rather than silently treating deltas as levels.
38
+ ref_band_balance = comparison_data.get("reference_band_balance")
39
+ if not ref_band_balance:
40
+ mix_band_balance = comparison_data.get("mix_band_balance")
41
+ if mix_band_balance and band_deltas:
42
+ ref_band_balance = {
43
+ band: round(mix_band_balance.get(band, 0.0) - delta, 6)
44
+ for band, delta in band_deltas.items()
45
+ }
46
+ else:
47
+ ref_band_balance = {}
48
+
29
49
  spectral_contour: dict = {
30
- "band_balance": band_deltas,
50
+ "band_balance": ref_band_balance,
31
51
  "centroid_delta_hz": comparison_data.get("centroid_delta_hz", 0.0),
32
52
  }
33
53
 
@@ -103,7 +123,9 @@ def build_style_reference_profile(style_tactics: list[dict]) -> ReferenceProfile
103
123
  # shape — e.g., Auto Filter at 800Hz low-pass = darker spectrum,
104
124
  # Utility Width > 0.6 = wider stereo). Rough but non-empty values
105
125
  # are better than zeros for downstream reference-gap analysis.
106
- loudness_posture = _derive_loudness_posture(style_tactics)
126
+ # loudness_posture is contracted to be integrated LUFS, so the [-1,+1]
127
+ # device-chain posture is mapped onto the LUFS axis (_style_posture_to_lufs).
128
+ loudness_posture = _style_posture_to_lufs(_derive_loudness_posture(style_tactics))
107
129
  spectral_contour = _derive_spectral_contour(style_tactics)
108
130
  width_depth = _derive_width_depth(style_tactics)
109
131
 
@@ -163,6 +185,27 @@ def _estimate_density_from_patterns(style_tactics: list[dict]) -> list[float]:
163
185
  # ── BUG-B50 derivations — style → loudness/spectral/width heuristics ──────
164
186
 
165
187
 
188
+ # Style profiles infer a dimensionless loudness POSTURE in [-1, +1] from the
189
+ # device chain (limiters/glue → louder, reverb-heavy → quieter). But
190
+ # ReferenceProfile.loudness_posture is contracted to be integrated LUFS — the
191
+ # gap analyzer differences it directly against the project's LUFS (gap_analyzer
192
+ # lines 92-99). So the posture is mapped onto the LUFS axis: -14 LUFS is the
193
+ # streaming-normalization neutral; ±6 LU spans a hot/limited master
194
+ # (+1 → -8 LUFS) to a dynamic/quiet mix (-1 → -20 LUFS). A no-signal posture of
195
+ # 0 lands on the -14 neutral, yielding ~no spurious loudness gap against a
196
+ # typical project — whereas a raw 0.0 would read as a bogus ~14 LU gap.
197
+ _STYLE_LOUDNESS_NEUTRAL_LUFS = -14.0
198
+ _STYLE_LOUDNESS_RANGE_LU = 6.0
199
+
200
+
201
+ def _style_posture_to_lufs(posture: float) -> float:
202
+ """Map a [-1, +1] device-chain loudness posture onto the integrated-LUFS
203
+ axis that ReferenceProfile.loudness_posture is contracted to carry."""
204
+ return round(
205
+ _STYLE_LOUDNESS_NEUTRAL_LUFS + posture * _STYLE_LOUDNESS_RANGE_LU, 2
206
+ )
207
+
208
+
166
209
  def _derive_loudness_posture(style_tactics: list[dict]) -> float:
167
210
  """Heuristic: compression / saturation / limiter devices imply a
168
211
  specific loudness posture. Glue / Ratio>3 → loud, saturator drive
@@ -50,6 +50,12 @@ def _fetch_project_snapshot(ctx: Context) -> dict:
50
50
 
51
51
  snapshot: dict = {
52
52
  "loudness": 0.0,
53
+ # Unit of snapshot["loudness"]. The live SpectralCache only exposes a
54
+ # plain-RMS value, so the best we can derive here is RMS dBFS — NOT
55
+ # the K-weighted integrated LUFS that ReferenceProfile.loudness_posture
56
+ # carries. analyze_gaps reads this tag to convert to a common scale
57
+ # instead of blindly subtracting dBFS from LUFS (P2-36).
58
+ "loudness_unit": "rms_dbfs",
53
59
  "spectral": {},
54
60
  "width": 0.0,
55
61
  "density": 0.0,
@@ -22,12 +22,16 @@ Step-result binding:
22
22
 
23
23
  from __future__ import annotations
24
24
 
25
+ import asyncio
25
26
  import inspect
27
+ import logging
26
28
  from dataclasses import dataclass
27
29
  from typing import Any, Awaitable, Callable, Optional
28
30
 
29
31
  from .remote_commands import BRIDGE_COMMANDS, REMOTE_COMMANDS
30
32
 
33
+ logger = logging.getLogger(__name__)
34
+
31
35
 
32
36
  # MCP-only tools that exist as Python functions but NOT as TCP handlers.
33
37
  # These must be called through direct import, not ableton.send_command().
@@ -233,7 +237,17 @@ async def _execute_step_async(
233
237
  error="Ableton connection unavailable",
234
238
  )
235
239
  try:
236
- result = ableton.send_command(tool, params)
240
+ # Offload the blocking TCP round-trip to a worker thread so this
241
+ # shared executor never stalls the event loop (and therefore the
242
+ # concurrent UDP analyzer bridge) for the duration of the call.
243
+ # Prefer the real AbletonConnection's async wrapper when present;
244
+ # fall back to asyncio.to_thread directly for lightweight test
245
+ # doubles that only implement send_command.
246
+ send_command_async = getattr(ableton, "send_command_async", None)
247
+ if callable(send_command_async):
248
+ result = await send_command_async(tool, params)
249
+ else:
250
+ result = await asyncio.to_thread(ableton.send_command, tool, params)
237
251
  if isinstance(result, dict) and "error" in result:
238
252
  return ExecutionResult(ok=False, backend=backend, tool=tool, error=result["error"])
239
253
  return ExecutionResult(ok=True, backend=backend, tool=tool, result=result)
@@ -391,3 +405,39 @@ async def execute_plan_steps_async(
391
405
  break
392
406
 
393
407
  return results
408
+
409
+
410
+ # ── Undo helper ──────────────────────────────────────────────────────────
411
+
412
+ async def undo_remote_steps(ableton: Any, exec_results: list[ExecutionResult]) -> int:
413
+ """Undo the remote_command steps from a list of ExecutionResults.
414
+
415
+ Only remote_command steps land on Ableton's linear undo stack — bridge
416
+ (M4L/OSC) and mcp_tool mutations do NOT, so issuing one `undo` per
417
+ successful step regardless of backend over-undoes and walks back
418
+ unrelated prior user edits. This counts successful remote_command
419
+ steps and issues exactly that many `undo` calls via
420
+ ``ableton.send_command_async``, stopping early if an undo call raises
421
+ (leaving the rest un-undone rather than risking a cascading error).
422
+
423
+ Extracted from the duplicated experiment/engine.py + preview_studio
424
+ /tools.py undo-count logic (both had drifted into copy-pasted
425
+ near-identical blocks) so the "count remote_command successes, then
426
+ undo that many times" rule lives in exactly one place.
427
+
428
+ Returns the number of undo calls actually issued (<= the number of
429
+ successful remote_command steps; less if an undo call raised partway
430
+ through).
431
+ """
432
+ undo_count = sum(
433
+ 1 for r in exec_results if r.ok and r.backend == "remote_command"
434
+ )
435
+ issued = 0
436
+ for _ in range(undo_count):
437
+ try:
438
+ await ableton.send_command_async("undo", {})
439
+ issued += 1
440
+ except Exception as exc:
441
+ logger.debug("undo_remote_steps: undo call failed: %s", exc)
442
+ break
443
+ return issued
@@ -89,7 +89,6 @@ def _probe_flucoma_package() -> bool:
89
89
  externals = package_dir / "externals"
90
90
  if externals.exists() and any(externals.glob("fluid.*")):
91
91
  return True
92
- return True
93
92
  return False
94
93
  except Exception as exc: # noqa: BLE001
95
94
  logger.debug("_probe_flucoma_package failed: %s", exc)
@@ -207,8 +207,6 @@ class SpliceSource:
207
207
  if key:
208
208
  # Normalize: user might pass "Cm" or "C#", Splice stores "c" or "c#"
209
209
  # Strip minor suffix for comparison — Splice uses chord_type column
210
- key_normalized = key.lower().rstrip("m").rstrip("inor")
211
- # But proper suffix removal:
212
210
  k = key.lower()
213
211
  for suffix in ("minor", "min"):
214
212
  if k.endswith(suffix):
@@ -222,15 +222,13 @@ async def search_samples(
222
222
  ) -> dict:
223
223
  """Search for samples across Splice library, Ableton browser, and local filesystem.
224
224
 
225
- Searches all enabled sources in parallel and ranks results.
226
- Splice results include rich metadata (key, BPM, genre, tags, pack info,
227
- is_premium, price, is_free, preview_url).
225
+ Searches all enabled sources in parallel, ranked Splice-first, then
226
+ browser, then filesystem. Splice results carry key/BPM/genre/tags/
227
+ pack/is_premium/price/is_free/preview_url metadata.
228
228
 
229
- When the Splice desktop app is running AND grpcio is installed, this
230
- searches Splice's ONLINE catalog (19,690+ hits for a generic query)
231
- and returns un-downloaded items alongside local files. When gRPC is
232
- unavailable, it falls back to the local SQLite index and only returns
233
- already-downloaded samples.
229
+ With the Splice desktop app running + grpcio installed: searches
230
+ Splice's ONLINE catalog, returning un-downloaded items too. Without
231
+ gRPC: falls back to the local SQLite index (downloaded samples only).
234
232
 
235
233
  query: search text like "dark vocal", "breakbeat", "foley metal"
236
234
  q: alias for `query` (accepts either name for ergonomics)
@@ -776,24 +774,12 @@ async def get_splice_credits(ctx: Context) -> dict:
776
774
  Returns both pockets of the Splice subscription model:
777
775
  - `credits_remaining`: Splice.com credits for presets/MIDI/Instrument
778
776
  - `daily_quota`: sample-download counter (Ableton Live plan only)
777
+ - `download_gating`: "daily_quota" (Ableton Live plan) or
778
+ "credit_floor" (Sounds+/Creator/Creator+ — protects the last
779
+ CREDIT_HARD_FLOOR credits)
779
780
 
780
- Example (Ableton Live plan):
781
- {
782
- "connected": true,
783
- "username": "user-1367453956",
784
- "plan_raw": "subscribed",
785
- "plan_kind": "ableton_live",
786
- "sounds_plan_id": 12,
787
- "features": {"ableton_unmetered": true, ...},
788
- "credits_remaining": 80,
789
- "credit_floor": 5,
790
- "daily_quota": {
791
- "used_today": 3, "remaining_today": 97, "daily_limit": 100,
792
- "near_limit": false, "at_limit": false,
793
- },
794
- "can_download_sample": true,
795
- "download_gating": "daily_quota", # or "credit_floor"
796
- }
781
+ Full example response: livepilot-sample-engine references/
782
+ splice-tools-notes.md#get_splice_credits--full-response-example-ableton-live-plan.
797
783
 
798
784
  Returns connected=False (with zero credits) when the Splice desktop app
799
785
  isn't running or grpcio isn't installed.
@@ -1538,12 +1524,9 @@ async def splice_describe_sound(
1538
1524
  """Natural-language sample search — the Sounds Plugin's "Describe a Sound".
1539
1525
 
1540
1526
  Splice's AI matches free-form descriptions like "dark ambient pad with
1541
- shimmer" or "tight 90s house hi-hat" to catalog samples. Hits the
1542
- GraphQL `SamplesSearch` operation on `surfaces-graphql.splice.com`
1543
- with `semantic=1` + `rephrase=true` enabled.
1544
-
1545
- **Status: LIVE** as of 2026-04-22. Endpoint captured via mitmproxy
1546
- against Splice desktop 5.4.9 + Sounds Plugin.
1527
+ shimmer" or "tight 90s house hi-hat" to catalog samples. Endpoint
1528
+ history: livepilot-sample-engine references/splice-tools-notes.md
1529
+ #splice_describe_sound--splice_generate_variation--endpoint-history.
1547
1530
 
1548
1531
  description: free-text prompt ("warm analog bass under 80bpm")
1549
1532
  bpm: optional BPM filter
@@ -1590,13 +1573,11 @@ async def splice_generate_variation(
1590
1573
  """Find catalog samples similar to a given Splice sample — the "Variations" feature.
1591
1574
 
1592
1575
  Splice's right-click "Variations" menu item surfaces other catalog
1593
- samples with similar sonic character. The GraphQL operation name
1594
- is `AssetSimilarSoundsQuery`. Up to 10 results per call. No credit
1595
- cost (this is a recommender lookup, not AI audio synthesis — the
1596
- original naming in the handoff was aspirational).
1597
-
1598
- **Status: LIVE** as of 2026-04-22. Endpoint captured via mitmproxy
1599
- against Splice desktop v5.4.9.
1576
+ samples with similar sonic character. Up to 10 results per call. No
1577
+ credit cost a recommender lookup, not AI audio synthesis (the
1578
+ "generate" naming was aspirational). Endpoint history: livepilot-
1579
+ sample-engine references/splice-tools-notes.md
1580
+ #splice_describe_sound--splice_generate_variation--endpoint-history.
1600
1581
 
1601
1582
  uuid: source sample's catalog uuid (from `splice_describe_sound`
1602
1583
  results or any other Splice metadata call)