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.
- package/CHANGELOG.md +66 -0
- package/README.md +10 -7
- package/bin/livepilot.js +97 -39
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +33 -1
- package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
- package/livepilot/skills/livepilot-core/references/overview.md +18 -11
- package/livepilot/skills/livepilot-core/references/perception.md +125 -0
- package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
- package/livepilot/skills/livepilot-release/SKILL.md +1 -1
- package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
- 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 +181 -40
- package/mcp_server/atlas/overlays.py +46 -3
- package/mcp_server/atlas/tools.py +226 -86
- package/mcp_server/audit/checks.py +50 -22
- package/mcp_server/audit/state.py +10 -2
- package/mcp_server/audit/tools.py +27 -6
- package/mcp_server/composer/develop/apply.py +7 -7
- package/mcp_server/composer/fast/apply.py +29 -23
- package/mcp_server/composer/framework/atlas_resolver.py +16 -1
- package/mcp_server/composer/full/apply.py +40 -34
- package/mcp_server/composer/full/engine.py +66 -32
- package/mcp_server/composer/tools.py +4 -3
- package/mcp_server/connection.py +67 -9
- package/mcp_server/creative_constraints/engine.py +10 -2
- package/mcp_server/creative_constraints/tools.py +24 -7
- package/mcp_server/curves.py +30 -7
- package/mcp_server/device_forge/builder.py +40 -11
- package/mcp_server/device_forge/models.py +9 -0
- package/mcp_server/device_forge/tools.py +30 -11
- package/mcp_server/experiment/engine.py +29 -22
- package/mcp_server/experiment/tools.py +15 -5
- package/mcp_server/m4l_bridge.py +69 -7
- package/mcp_server/memory/taste_graph.py +43 -0
- package/mcp_server/memory/technique_store.py +26 -3
- package/mcp_server/memory/tools.py +62 -4
- package/mcp_server/mix_engine/critics.py +187 -30
- package/mcp_server/mix_engine/models.py +21 -1
- package/mcp_server/mix_engine/state_builder.py +87 -8
- package/mcp_server/mix_engine/tools.py +16 -4
- package/mcp_server/performance_engine/tools.py +56 -8
- package/mcp_server/persistence/base_store.py +11 -0
- package/mcp_server/persistence/project_store.py +132 -8
- package/mcp_server/persistence/taste_store.py +90 -7
- package/mcp_server/preview_studio/engine.py +40 -10
- package/mcp_server/preview_studio/models.py +40 -0
- package/mcp_server/preview_studio/tools.py +56 -12
- package/mcp_server/project_brain/arrangement_graph.py +4 -0
- package/mcp_server/project_brain/models.py +2 -0
- package/mcp_server/project_brain/role_graph.py +13 -7
- package/mcp_server/reference_engine/gap_analyzer.py +58 -3
- package/mcp_server/reference_engine/profile_builder.py +47 -4
- package/mcp_server/reference_engine/tools.py +6 -0
- package/mcp_server/runtime/execution_router.py +51 -1
- package/mcp_server/runtime/tools.py +0 -1
- package/mcp_server/sample_engine/sources.py +0 -2
- package/mcp_server/sample_engine/tools.py +19 -38
- package/mcp_server/semantic_moves/mix_compilers.py +276 -51
- package/mcp_server/semantic_moves/performance_compilers.py +51 -17
- package/mcp_server/semantic_moves/resolvers.py +45 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
- package/mcp_server/semantic_moves/tools.py +80 -2
- package/mcp_server/semantic_moves/transition_compilers.py +26 -9
- package/mcp_server/server.py +26 -24
- package/mcp_server/session_continuity/tracker.py +51 -3
- package/mcp_server/song_brain/builder.py +47 -5
- package/mcp_server/song_brain/tools.py +21 -7
- package/mcp_server/sound_design/critics.py +1 -0
- package/mcp_server/splice_client/client.py +117 -33
- package/mcp_server/splice_client/http_bridge.py +15 -3
- package/mcp_server/splice_client/quota.py +28 -0
- package/mcp_server/stuckness_detector/detector.py +8 -5
- package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
- package/mcp_server/tools/_perception_engine.py +6 -0
- package/mcp_server/tools/agent_os.py +4 -1
- package/mcp_server/tools/analyzer.py +198 -209
- package/mcp_server/tools/arrangement.py +7 -4
- package/mcp_server/tools/automation.py +24 -4
- package/mcp_server/tools/browser.py +25 -11
- package/mcp_server/tools/clips.py +6 -0
- package/mcp_server/tools/composition.py +33 -2
- package/mcp_server/tools/devices.py +53 -53
- package/mcp_server/tools/generative.py +14 -14
- package/mcp_server/tools/harmony.py +7 -7
- package/mcp_server/tools/mixing.py +4 -4
- package/mcp_server/tools/planner.py +68 -6
- package/mcp_server/tools/research.py +20 -2
- package/mcp_server/tools/theory.py +10 -10
- package/mcp_server/tools/transport.py +7 -2
- package/mcp_server/transition_engine/critics.py +13 -1
- package/mcp_server/user_corpus/tools.py +30 -1
- package/mcp_server/wonder_mode/engine.py +82 -9
- package/mcp_server/wonder_mode/session.py +32 -10
- package/mcp_server/wonder_mode/tools.py +14 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +93 -33
- package/remote_script/LivePilot/browser.py +60 -4
- package/remote_script/LivePilot/devices.py +132 -62
- package/remote_script/LivePilot/mixing.py +31 -5
- package/remote_script/LivePilot/server.py +94 -22
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- 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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
active_set.add(int(t_idx))
|
|
52
55
|
|
|
53
|
-
# Also include all tracks if no
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
#
|
|
28
|
-
#
|
|
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":
|
|
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
|
|
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
|
-
|
|
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
|
|
226
|
-
Splice results
|
|
227
|
-
is_premium
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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.
|
|
1542
|
-
|
|
1543
|
-
|
|
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.
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
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)
|