livepilot 1.10.7 → 1.10.9
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 +254 -0
- package/README.md +19 -17
- package/bin/livepilot.js +146 -28
- package/installer/install.js +117 -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/__init__.py +39 -7
- package/mcp_server/atlas/tools.py +56 -15
- package/mcp_server/composer/layer_planner.py +27 -0
- package/mcp_server/composer/prompt_parser.py +15 -6
- package/mcp_server/connection.py +11 -3
- package/mcp_server/corpus/__init__.py +14 -4
- package/mcp_server/evaluation/fabric.py +62 -1
- package/mcp_server/m4l_bridge.py +63 -12
- package/mcp_server/project_brain/automation_graph.py +23 -1
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/models.py +20 -1
- package/mcp_server/project_brain/tools.py +10 -3
- package/mcp_server/runtime/execution_router.py +16 -2
- package/mcp_server/runtime/remote_commands.py +6 -0
- package/mcp_server/sample_engine/models.py +22 -3
- package/mcp_server/semantic_moves/__init__.py +1 -0
- package/mcp_server/semantic_moves/compiler.py +9 -1
- package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
- package/mcp_server/semantic_moves/mix_compilers.py +170 -0
- package/mcp_server/semantic_moves/mix_moves.py +1 -1
- package/mcp_server/semantic_moves/models.py +5 -0
- package/mcp_server/semantic_moves/tools.py +154 -35
- package/mcp_server/server.py +147 -17
- package/mcp_server/services/singletons.py +68 -0
- package/mcp_server/session_continuity/models.py +13 -0
- package/mcp_server/session_continuity/tools.py +2 -0
- package/mcp_server/session_continuity/tracker.py +93 -0
- package/mcp_server/splice_client/client.py +29 -8
- package/mcp_server/tools/_analyzer_engine/__init__.py +39 -0
- package/mcp_server/tools/_analyzer_engine/context.py +103 -0
- package/mcp_server/tools/_analyzer_engine/flucoma.py +23 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +122 -0
- package/mcp_server/tools/_motif_engine.py +19 -4
- package/mcp_server/tools/analyzer.py +25 -180
- package/mcp_server/tools/clips.py +240 -2
- package/mcp_server/tools/midi_io.py +10 -0
- package/mcp_server/tools/tracks.py +1 -1
- package/mcp_server/tools/transport.py +59 -4
- package/mcp_server/translation_engine/tools.py +8 -4
- package/package.json +25 -3
- package/remote_script/LivePilot/__init__.py +36 -9
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/remote_script/LivePilot/browser.py +16 -6
- package/remote_script/LivePilot/devices.py +10 -5
- package/remote_script/LivePilot/notes.py +13 -2
- package/remote_script/LivePilot/server.py +51 -13
- package/remote_script/LivePilot/version_detect.py +7 -4
- package/server.json +20 -0
- package/.claude-plugin/marketplace.json +0 -21
- package/.mcp.json.disabled +0 -9
- package/.mcpbignore +0 -60
- package/AGENTS.md +0 -46
- package/BUGS.md +0 -1570
- package/CODE_OF_CONDUCT.md +0 -27
- package/CONTRIBUTING.md +0 -131
- package/SECURITY.md +0 -48
- package/livepilot/.Codex-plugin/plugin.json +0 -8
- package/livepilot/.claude-plugin/plugin.json +0 -8
- package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
- package/livepilot/commands/arrange.md +0 -47
- package/livepilot/commands/beat.md +0 -77
- package/livepilot/commands/evaluate.md +0 -49
- package/livepilot/commands/memory.md +0 -22
- package/livepilot/commands/mix.md +0 -44
- package/livepilot/commands/perform.md +0 -42
- package/livepilot/commands/session.md +0 -13
- package/livepilot/commands/sounddesign.md +0 -43
- package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
- package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
- package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
- package/livepilot/skills/livepilot-core/SKILL.md +0 -184
- package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
- package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
- package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
- package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
- package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
- package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
- package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
- package/livepilot/skills/livepilot-core/references/overview.md +0 -290
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
- package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
- package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
- package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
- package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
- package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
- package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
- package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
- package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
- package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
- package/livepilot/skills/livepilot-release/SKILL.md +0 -130
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
- package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
- package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
- package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
- package/m4l_device/LivePilot_Analyzer.maxproj +0 -53
- package/manifest.json +0 -91
- package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
- package/scripts/generate_tool_catalog.py +0 -106
- package/scripts/sync_metadata.py +0 -349
|
@@ -27,6 +27,16 @@ _SPLICE_APP_SUPPORT = os.path.expanduser(
|
|
|
27
27
|
# Credit safety floor — never drain below this
|
|
28
28
|
CREDIT_HARD_FLOOR = 5
|
|
29
29
|
|
|
30
|
+
# Per-call gRPC timeouts. The previous implementation passed no timeout, so
|
|
31
|
+
# a hung Splice process could block the MCP event loop until gRPC's default
|
|
32
|
+
# (often infinite) deadline fired. Keep generous enough for cold searches
|
|
33
|
+
# but bounded enough that a dead socket fails the tool call, not the server.
|
|
34
|
+
SEARCH_TIMEOUT = 10.0
|
|
35
|
+
INFO_TIMEOUT = 5.0
|
|
36
|
+
CREDITS_TIMEOUT = 5.0
|
|
37
|
+
SYNC_TIMEOUT = 30.0
|
|
38
|
+
DOWNLOAD_TRIGGER_TIMEOUT = 5.0
|
|
39
|
+
|
|
30
40
|
|
|
31
41
|
def _try_import_grpc():
|
|
32
42
|
"""Import grpcio lazily — graceful degradation if not installed."""
|
|
@@ -148,7 +158,7 @@ class SpliceGRPCClient:
|
|
|
148
158
|
Page=page,
|
|
149
159
|
Purchased=purchased,
|
|
150
160
|
)
|
|
151
|
-
response = await self.stub.SearchSamples(request)
|
|
161
|
+
response = await self.stub.SearchSamples(request, timeout=SEARCH_TIMEOUT)
|
|
152
162
|
return self._parse_search_response(response)
|
|
153
163
|
except Exception as exc:
|
|
154
164
|
logger.warning(f"Splice search failed: {exc}")
|
|
@@ -213,7 +223,8 @@ class SpliceGRPCClient:
|
|
|
213
223
|
try:
|
|
214
224
|
# Trigger download
|
|
215
225
|
await self.stub.DownloadSample(
|
|
216
|
-
pb2.DownloadSampleRequest(FileHash=file_hash)
|
|
226
|
+
pb2.DownloadSampleRequest(FileHash=file_hash),
|
|
227
|
+
timeout=DOWNLOAD_TRIGGER_TIMEOUT,
|
|
217
228
|
)
|
|
218
229
|
# Wait for file to appear on disk
|
|
219
230
|
return await self._wait_for_download(file_hash, timeout)
|
|
@@ -226,11 +237,16 @@ class SpliceGRPCClient:
|
|
|
226
237
|
) -> Optional[str]:
|
|
227
238
|
"""Poll SampleInfo until LocalPath is populated."""
|
|
228
239
|
pb2 = self._pb2
|
|
229
|
-
|
|
230
|
-
|
|
240
|
+
# asyncio.get_event_loop() is deprecated when called inside an
|
|
241
|
+
# already-running coroutine on Python 3.10+. Use get_running_loop()
|
|
242
|
+
# which is the documented replacement.
|
|
243
|
+
loop = asyncio.get_running_loop()
|
|
244
|
+
deadline = loop.time() + timeout
|
|
245
|
+
while loop.time() < deadline:
|
|
231
246
|
try:
|
|
232
247
|
response = await self.stub.SampleInfo(
|
|
233
|
-
pb2.SampleInfoRequest(FileHash=file_hash)
|
|
248
|
+
pb2.SampleInfoRequest(FileHash=file_hash),
|
|
249
|
+
timeout=INFO_TIMEOUT,
|
|
234
250
|
)
|
|
235
251
|
if response.Sample.LocalPath:
|
|
236
252
|
return response.Sample.LocalPath
|
|
@@ -250,7 +266,8 @@ class SpliceGRPCClient:
|
|
|
250
266
|
pb2 = self._pb2
|
|
251
267
|
try:
|
|
252
268
|
response = await self.stub.SampleInfo(
|
|
253
|
-
pb2.SampleInfoRequest(FileHash=file_hash)
|
|
269
|
+
pb2.SampleInfoRequest(FileHash=file_hash),
|
|
270
|
+
timeout=INFO_TIMEOUT,
|
|
254
271
|
)
|
|
255
272
|
s = response.Sample
|
|
256
273
|
return SpliceSample(
|
|
@@ -282,7 +299,8 @@ class SpliceGRPCClient:
|
|
|
282
299
|
pb2 = self._pb2
|
|
283
300
|
try:
|
|
284
301
|
response = await self.stub.ValidateLogin(
|
|
285
|
-
pb2.ValidateLoginRequest()
|
|
302
|
+
pb2.ValidateLoginRequest(),
|
|
303
|
+
timeout=CREDITS_TIMEOUT,
|
|
286
304
|
)
|
|
287
305
|
return SpliceCredits(
|
|
288
306
|
credits=response.User.Credits,
|
|
@@ -315,7 +333,10 @@ class SpliceGRPCClient:
|
|
|
315
333
|
return False
|
|
316
334
|
pb2 = self._pb2
|
|
317
335
|
try:
|
|
318
|
-
await self.stub.SyncSounds(
|
|
336
|
+
await self.stub.SyncSounds(
|
|
337
|
+
pb2.SyncSoundsRequest(),
|
|
338
|
+
timeout=SYNC_TIMEOUT,
|
|
339
|
+
)
|
|
319
340
|
return True
|
|
320
341
|
except Exception as exc:
|
|
321
342
|
logger.debug("sync_sounds failed: %s", exc)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Analyzer helpers — pure-computation + context accessors split out from analyzer.py.
|
|
2
|
+
|
|
3
|
+
The public tool surface (32 ``@mcp.tool()`` functions) still lives in
|
|
4
|
+
``mcp_server/tools/analyzer.py`` — moving decorators across files risks
|
|
5
|
+
reordering FastMCP's tool registration. This package only holds the
|
|
6
|
+
supporting code that ``analyzer.py`` used to carry inline:
|
|
7
|
+
|
|
8
|
+
- ``context`` — SpectralCache + M4LBridge accessors and the analyzer
|
|
9
|
+
health check that formats user-facing error messages.
|
|
10
|
+
- ``sample`` — Simpler post-load hygiene (Snap=0, warped-loop defaults,
|
|
11
|
+
sample-name verification) + filename helpers.
|
|
12
|
+
- ``flucoma`` — FluCoMa-specific hint formatting + pitch-name tables.
|
|
13
|
+
|
|
14
|
+
Re-exports the public-ish helpers (``_`` prefix is intentional — these
|
|
15
|
+
are implementation details of ``analyzer.py``, not tools in their own
|
|
16
|
+
right) so existing ``from .tools.analyzer import _foo`` imports in tests
|
|
17
|
+
continue to resolve via the thin analyzer module.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .context import _get_spectral, _get_m4l, _require_analyzer
|
|
21
|
+
from .sample import (
|
|
22
|
+
_BPM_IN_FILENAME_RE,
|
|
23
|
+
_filename_stem,
|
|
24
|
+
_is_warped_loop,
|
|
25
|
+
_simpler_post_load_hygiene,
|
|
26
|
+
)
|
|
27
|
+
from .flucoma import PITCH_NAMES, _flucoma_hint
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"_get_spectral",
|
|
31
|
+
"_get_m4l",
|
|
32
|
+
"_require_analyzer",
|
|
33
|
+
"_BPM_IN_FILENAME_RE",
|
|
34
|
+
"_filename_stem",
|
|
35
|
+
"_is_warped_loop",
|
|
36
|
+
"_simpler_post_load_hygiene",
|
|
37
|
+
"PITCH_NAMES",
|
|
38
|
+
"_flucoma_hint",
|
|
39
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Analyzer lifespan-context accessors + health check.
|
|
2
|
+
|
|
3
|
+
These were inline in ``analyzer.py`` pre-v1.10.9. Split out as part of
|
|
4
|
+
BUG-C1 so the tool file contains only ``@mcp.tool()`` definitions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from fastmcp import Context
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: no cover — type-only imports
|
|
15
|
+
from ...m4l_bridge import M4LBridge, SpectralCache
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_spectral(ctx: Context):
|
|
21
|
+
"""Get the SpectralCache from the lifespan context.
|
|
22
|
+
|
|
23
|
+
Attaches the active FastMCP ``Context`` to the cache so the analyzer
|
|
24
|
+
error path can distinguish "device missing" from "bridge disconnected"
|
|
25
|
+
— needed for the actionable error messages in ``_require_analyzer``.
|
|
26
|
+
"""
|
|
27
|
+
cache = ctx.lifespan_context.get("spectral")
|
|
28
|
+
if not cache:
|
|
29
|
+
raise ValueError("Spectral cache not initialized — restart the MCP server")
|
|
30
|
+
setattr(cache, "_livepilot_ctx", ctx)
|
|
31
|
+
return cache
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_m4l(ctx: Context):
|
|
35
|
+
"""Get the M4LBridge from the lifespan context."""
|
|
36
|
+
bridge = ctx.lifespan_context.get("m4l")
|
|
37
|
+
if not bridge:
|
|
38
|
+
raise ValueError("M4L bridge not initialized — restart the MCP server")
|
|
39
|
+
return bridge
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_analyzer(cache) -> None:
|
|
43
|
+
"""Raise a user-actionable error if the analyzer device isn't reachable.
|
|
44
|
+
|
|
45
|
+
The error text is the most user-visible surface of the analyzer layer,
|
|
46
|
+
so it spends effort distinguishing:
|
|
47
|
+
|
|
48
|
+
* "not loaded on master" → concrete drag-and-drop instructions
|
|
49
|
+
* "loaded but UDP port 9880 held by another instance" → show the
|
|
50
|
+
PID/command of the holder so the user can close it
|
|
51
|
+
* "loaded but bridge disconnected for some other reason" → generic
|
|
52
|
+
reload/restart hint
|
|
53
|
+
|
|
54
|
+
The ``_livepilot_ctx`` attribute attached by ``_get_spectral`` is what
|
|
55
|
+
lets us probe the master-track devices here; without it, the caller
|
|
56
|
+
would have to pass ``ctx`` through every ``_require_analyzer`` site.
|
|
57
|
+
"""
|
|
58
|
+
if cache.is_connected:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Imported lazily to avoid a circular import: server.py imports this
|
|
62
|
+
# package's parent during tool registration.
|
|
63
|
+
from ...server import _identify_port_holder
|
|
64
|
+
|
|
65
|
+
ctx = getattr(cache, "_livepilot_ctx", None)
|
|
66
|
+
try:
|
|
67
|
+
track = (
|
|
68
|
+
ctx.lifespan_context["ableton"].send_command("get_master_track")
|
|
69
|
+
if ctx else {}
|
|
70
|
+
)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
logger.debug("_require_analyzer failed: %s", exc)
|
|
73
|
+
track = {}
|
|
74
|
+
|
|
75
|
+
devices = track.get("devices", []) if isinstance(track, dict) else []
|
|
76
|
+
analyzer_loaded = False
|
|
77
|
+
for device in devices:
|
|
78
|
+
normalized = " ".join(
|
|
79
|
+
str(device.get("name") or "").replace("_", " ").replace("-", " ").lower().split()
|
|
80
|
+
)
|
|
81
|
+
if normalized == "livepilot analyzer":
|
|
82
|
+
analyzer_loaded = True
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
if analyzer_loaded:
|
|
86
|
+
holder = _identify_port_holder(9880)
|
|
87
|
+
detail = (
|
|
88
|
+
"LivePilot Analyzer is loaded on the master track, but its UDP bridge is not connected. "
|
|
89
|
+
)
|
|
90
|
+
if holder:
|
|
91
|
+
detail += (
|
|
92
|
+
"UDP port 9880 is currently held by another LivePilot instance "
|
|
93
|
+
f"({holder}). Close the other client/server, then retry."
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
detail += "Reload the analyzer device or restart the MCP server."
|
|
97
|
+
raise ValueError(detail)
|
|
98
|
+
|
|
99
|
+
raise ValueError(
|
|
100
|
+
"LivePilot Analyzer not detected. "
|
|
101
|
+
"Drag 'LivePilot Analyzer' onto the master track from "
|
|
102
|
+
"Audio Effects > Max Audio Effect."
|
|
103
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""FluCoMa-specific helpers (hints + pitch-name table).
|
|
2
|
+
|
|
3
|
+
Extracted from ``analyzer.py`` as part of BUG-C1. Purely about formatting
|
|
4
|
+
hints for the FluCoMa real-time streams — no Ableton or bridge access.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _flucoma_hint(cache) -> str:
|
|
14
|
+
"""Return an error hint if no FluCoMa data has arrived on ``cache``.
|
|
15
|
+
|
|
16
|
+
If ANY stream has data, FluCoMa is working and the specific stream just
|
|
17
|
+
hasn't updated yet — return a 'play audio' hint. If NO streams have
|
|
18
|
+
data, FluCoMa may not be installed — return an install hint.
|
|
19
|
+
"""
|
|
20
|
+
for key in ("spectral_shape", "mel_bands", "chroma", "loudness"):
|
|
21
|
+
if cache.get(key):
|
|
22
|
+
return "play some audio"
|
|
23
|
+
return "FluCoMa may not be installed. Install via: npx livepilot --setup-flucoma"
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Simpler post-load hygiene + filename heuristics.
|
|
2
|
+
|
|
3
|
+
Extracted from ``analyzer.py`` as part of BUG-C1. Covers:
|
|
4
|
+
|
|
5
|
+
* BPM-in-filename detection (used to tell warped loops from one-shots)
|
|
6
|
+
* Post-load verification + Snap=0 + warped-loop defaults for Simpler
|
|
7
|
+
|
|
8
|
+
Context (docs/2026-04-14-bugs-discovered.md):
|
|
9
|
+
|
|
10
|
+
The M4L bridge's ``replace_simpler_sample`` command can report success
|
|
11
|
+
even when the sample is still the bootstrap placeholder. Simpler's
|
|
12
|
+
display name also doesn't refresh after a replace. After loading, the
|
|
13
|
+
``Snap`` parameter is ON by default which causes the Sample Start
|
|
14
|
+
position to snap to a location outside the new sample's valid audio —
|
|
15
|
+
resulting in silent playback. The hygiene here fixes both.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_BPM_IN_FILENAME_RE = re.compile(r"(\d{2,3})\s*bpm", re.IGNORECASE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_warped_loop(file_path: str) -> bool:
|
|
31
|
+
"""Return True if the filename contains a BPM marker (likely a tempo-locked loop)."""
|
|
32
|
+
stem = os.path.splitext(os.path.basename(file_path))[0]
|
|
33
|
+
return bool(_BPM_IN_FILENAME_RE.search(stem))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _filename_stem(file_path: str) -> str:
|
|
37
|
+
return os.path.splitext(os.path.basename(file_path))[0]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _simpler_post_load_hygiene(
|
|
41
|
+
bridge,
|
|
42
|
+
ableton,
|
|
43
|
+
track_index: int,
|
|
44
|
+
device_index: int,
|
|
45
|
+
file_path: str,
|
|
46
|
+
) -> dict:
|
|
47
|
+
"""Apply post-load hygiene to a newly loaded Simpler and verify success.
|
|
48
|
+
|
|
49
|
+
Steps:
|
|
50
|
+
1. Read track info to verify the device's actual name matches the
|
|
51
|
+
expected sample stem. If it doesn't, return an error.
|
|
52
|
+
2. Set Snap=0 (Off) — required so sample playback works.
|
|
53
|
+
3. If filename indicates a warped loop, set S Start=0, S Length=1,
|
|
54
|
+
S Loop On=1 so the loop plays fully instead of being cropped.
|
|
55
|
+
4. Return a verified response dict.
|
|
56
|
+
"""
|
|
57
|
+
expected_stem = _filename_stem(file_path)
|
|
58
|
+
|
|
59
|
+
# Step 1: verify device name matches expected file
|
|
60
|
+
try:
|
|
61
|
+
track_info = ableton.send_command(
|
|
62
|
+
"get_track_info", {"track_index": track_index}
|
|
63
|
+
)
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
return {"error": f"Verification read failed: {exc}"}
|
|
66
|
+
|
|
67
|
+
devices = track_info.get("devices", []) or []
|
|
68
|
+
if device_index < 0 or device_index >= len(devices):
|
|
69
|
+
return {
|
|
70
|
+
"error": (
|
|
71
|
+
f"Device index {device_index} out of range after load "
|
|
72
|
+
f"(track has {len(devices)} devices)"
|
|
73
|
+
),
|
|
74
|
+
"verified": False,
|
|
75
|
+
}
|
|
76
|
+
device = devices[device_index]
|
|
77
|
+
actual_name = str(device.get("name") or "")
|
|
78
|
+
verified = expected_stem in actual_name or actual_name in expected_stem
|
|
79
|
+
if not verified:
|
|
80
|
+
return {
|
|
81
|
+
"error": (
|
|
82
|
+
f"Sample verification FAILED — Simpler name '{actual_name}' "
|
|
83
|
+
f"does not match requested file '{expected_stem}'. The bridge "
|
|
84
|
+
f"reported success but the actual sample is different. "
|
|
85
|
+
f"Try `load_browser_item` with a user_library URI instead."
|
|
86
|
+
),
|
|
87
|
+
"verified": False,
|
|
88
|
+
"actual_device_name": actual_name,
|
|
89
|
+
"expected_stem": expected_stem,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# Step 2: turn Snap OFF — required for reliable playback after replace
|
|
93
|
+
hygiene_params: list[dict] = [
|
|
94
|
+
{"name_or_index": "Snap", "value": 0},
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
# Step 3: smart defaults for warped loops
|
|
98
|
+
if _is_warped_loop(file_path):
|
|
99
|
+
hygiene_params.extend([
|
|
100
|
+
{"name_or_index": "S Start", "value": 0.0},
|
|
101
|
+
{"name_or_index": "S Length", "value": 1.0},
|
|
102
|
+
{"name_or_index": "S Loop On", "value": 1},
|
|
103
|
+
])
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
ableton.send_command("batch_set_parameters", {
|
|
107
|
+
"track_index": track_index,
|
|
108
|
+
"device_index": device_index,
|
|
109
|
+
"parameters": hygiene_params,
|
|
110
|
+
})
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
logger.debug("_simpler_post_load_hygiene failed: %s", exc)
|
|
113
|
+
# non-fatal — verification already succeeded
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"verified": True,
|
|
118
|
+
"device_name": actual_name,
|
|
119
|
+
"track_index": track_index,
|
|
120
|
+
"device_index": device_index,
|
|
121
|
+
"warped_loop_defaults_applied": _is_warped_loop(file_path),
|
|
122
|
+
}
|
|
@@ -201,24 +201,39 @@ def detect_motifs(
|
|
|
201
201
|
salience = _score_salience(pattern, len(occurrences), total_note_count)
|
|
202
202
|
fatigue = _score_fatigue(len(occurrences), total_bars)
|
|
203
203
|
|
|
204
|
-
# Get representative pitches from first occurrence
|
|
204
|
+
# Get representative pitches + inter-onset intervals from first occurrence.
|
|
205
|
+
# Rhythm is the list of start_time deltas between successive notes in
|
|
206
|
+
# the pattern window; until v1.10.9 this field was left empty with a
|
|
207
|
+
# "TODO: Phase 3" marker, which is what forced Hook Hunter's rhythm
|
|
208
|
+
# side to fall back to drum-track-name regex. Populating it here lets
|
|
209
|
+
# downstream code actually reason about rhythmic distinctiveness.
|
|
205
210
|
first_occ = occurrences[0] if occurrences else {}
|
|
206
211
|
first_track = first_occ.get("track", 0)
|
|
207
212
|
first_pos = first_occ.get("start_position", 0)
|
|
208
|
-
rep_pitches = []
|
|
213
|
+
rep_pitches: list[int] = []
|
|
214
|
+
rhythm_intervals: list[float] = []
|
|
209
215
|
if first_track in notes_by_track:
|
|
210
216
|
sorted_notes = sorted(notes_by_track[first_track],
|
|
211
217
|
key=lambda n: n.get("start_time", 0))
|
|
218
|
+
span = min(len(pattern) + 1, len(sorted_notes) - first_pos)
|
|
212
219
|
rep_pitches = [
|
|
213
220
|
sorted_notes[first_pos + j].get("pitch", 60)
|
|
214
|
-
for j in range(
|
|
221
|
+
for j in range(span)
|
|
222
|
+
]
|
|
223
|
+
rhythm_intervals = [
|
|
224
|
+
round(
|
|
225
|
+
float(sorted_notes[first_pos + j + 1].get("start_time", 0.0))
|
|
226
|
+
- float(sorted_notes[first_pos + j].get("start_time", 0.0)),
|
|
227
|
+
4,
|
|
228
|
+
)
|
|
229
|
+
for j in range(span - 1)
|
|
215
230
|
]
|
|
216
231
|
|
|
217
232
|
motif = MotifUnit(
|
|
218
233
|
motif_id=f"motif_{len(motifs):03d}",
|
|
219
234
|
kind="melodic" if any(abs(i) > 0 for i in pattern) else "rhythmic",
|
|
220
235
|
intervals=list(pattern),
|
|
221
|
-
rhythm=
|
|
236
|
+
rhythm=rhythm_intervals,
|
|
222
237
|
representative_pitches=rep_pitches,
|
|
223
238
|
occurrences=occurrences,
|
|
224
239
|
salience=salience,
|
|
@@ -2,81 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
30 tools requiring the LivePilot Analyzer M4L device on the master track.
|
|
4
4
|
These tools are optional — all core tools work without the device.
|
|
5
|
+
|
|
6
|
+
Helpers live in ``_analyzer_engine/`` (context accessors, Simpler
|
|
7
|
+
post-load hygiene, FluCoMa hint formatting). This file contains the
|
|
8
|
+
``@mcp.tool()`` surface only — keeping decorator order stable was
|
|
9
|
+
important for BUG-C1's refactor.
|
|
5
10
|
"""
|
|
6
11
|
|
|
7
12
|
from __future__ import annotations
|
|
8
13
|
|
|
14
|
+
import logging
|
|
9
15
|
import os
|
|
10
16
|
from typing import Optional
|
|
11
17
|
|
|
12
18
|
from fastmcp import Context
|
|
13
19
|
|
|
14
20
|
from ..server import mcp, _identify_port_holder
|
|
21
|
+
from ._analyzer_engine import (
|
|
22
|
+
PITCH_NAMES,
|
|
23
|
+
_filename_stem,
|
|
24
|
+
_flucoma_hint,
|
|
25
|
+
_get_m4l,
|
|
26
|
+
_get_spectral,
|
|
27
|
+
_is_warped_loop,
|
|
28
|
+
_require_analyzer,
|
|
29
|
+
_simpler_post_load_hygiene,
|
|
30
|
+
)
|
|
15
31
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _get_spectral(ctx: Context):
|
|
20
|
-
"""Get SpectralCache from lifespan context."""
|
|
21
|
-
cache = ctx.lifespan_context.get("spectral")
|
|
22
|
-
if not cache:
|
|
23
|
-
raise ValueError("Spectral cache not initialized — restart the MCP server")
|
|
24
|
-
# Keep the active request context attached so analyzer error paths can
|
|
25
|
-
# distinguish "device missing" from "bridge disconnected".
|
|
26
|
-
setattr(cache, "_livepilot_ctx", ctx)
|
|
27
|
-
return cache
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _get_m4l(ctx: Context):
|
|
31
|
-
"""Get M4LBridge from lifespan context."""
|
|
32
|
-
bridge = ctx.lifespan_context.get("m4l")
|
|
33
|
-
if not bridge:
|
|
34
|
-
raise ValueError("M4L bridge not initialized — restart the MCP server")
|
|
35
|
-
return bridge
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _require_analyzer(cache) -> None:
|
|
39
|
-
"""Raise a helpful error if the analyzer is not connected."""
|
|
40
|
-
if not cache.is_connected:
|
|
41
|
-
ctx = getattr(cache, "_livepilot_ctx", None)
|
|
42
|
-
try:
|
|
43
|
-
track = (
|
|
44
|
-
ctx.lifespan_context["ableton"].send_command("get_master_track")
|
|
45
|
-
if ctx else {}
|
|
46
|
-
)
|
|
47
|
-
except Exception as exc:
|
|
48
|
-
logger.debug("_require_analyzer failed: %s", exc)
|
|
49
|
-
track = {}
|
|
50
|
-
|
|
51
|
-
devices = track.get("devices", []) if isinstance(track, dict) else []
|
|
52
|
-
analyzer_loaded = False
|
|
53
|
-
for device in devices:
|
|
54
|
-
normalized = " ".join(
|
|
55
|
-
str(device.get("name") or "").replace("_", " ").replace("-", " ").lower().split()
|
|
56
|
-
)
|
|
57
|
-
if normalized == "livepilot analyzer":
|
|
58
|
-
analyzer_loaded = True
|
|
59
|
-
break
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
60
33
|
|
|
61
|
-
|
|
62
|
-
holder = _identify_port_holder(9880)
|
|
63
|
-
detail = (
|
|
64
|
-
"LivePilot Analyzer is loaded on the master track, but its UDP bridge is not connected. "
|
|
65
|
-
)
|
|
66
|
-
if holder:
|
|
67
|
-
detail += (
|
|
68
|
-
"UDP port 9880 is currently held by another LivePilot instance "
|
|
69
|
-
f"({holder}). Close the other client/server, then retry."
|
|
70
|
-
)
|
|
71
|
-
else:
|
|
72
|
-
detail += "Reload the analyzer device or restart the MCP server."
|
|
73
|
-
raise ValueError(detail)
|
|
74
|
-
|
|
75
|
-
raise ValueError(
|
|
76
|
-
"LivePilot Analyzer not detected. "
|
|
77
|
-
"Drag 'LivePilot Analyzer' onto the master track from "
|
|
78
|
-
"Audio Effects > Max Audio Effect."
|
|
79
|
-
)
|
|
34
|
+
CAPTURE_DIR = os.path.expanduser("~/Documents/LivePilot/captures")
|
|
80
35
|
|
|
81
36
|
|
|
82
37
|
@mcp.tool()
|
|
@@ -277,12 +232,6 @@ async def get_clip_file_path(
|
|
|
277
232
|
bridge = _get_m4l(ctx)
|
|
278
233
|
return await bridge.send_command("get_clip_file_path", track_index, clip_index)
|
|
279
234
|
|
|
280
|
-
import os # for filename parsing in smart-defaults helper
|
|
281
|
-
import re
|
|
282
|
-
import logging
|
|
283
|
-
|
|
284
|
-
logger = logging.getLogger(__name__)
|
|
285
|
-
|
|
286
235
|
# ── Sample loading helpers (P0-1, P1-1, P2-6 fixes) ────────────────────────
|
|
287
236
|
#
|
|
288
237
|
# Critical bug 2026-04-14 (see docs/2026-04-14-bugs-discovered.md):
|
|
@@ -304,102 +253,10 @@ logger = logging.getLogger(__name__)
|
|
|
304
253
|
# S Start=0, S Length=1, S Loop On=1 so the full loop plays in its
|
|
305
254
|
# musical phrasing. For ONE-SHOTS, leave defaults alone.
|
|
306
255
|
|
|
307
|
-
_BPM_IN_FILENAME_RE
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
"""Return True if the filename contains a BPM marker (likely a tempo-locked loop)."""
|
|
312
|
-
stem = os.path.splitext(os.path.basename(file_path))[0]
|
|
313
|
-
return bool(_BPM_IN_FILENAME_RE.search(stem))
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def _filename_stem(file_path: str) -> str:
|
|
317
|
-
return os.path.splitext(os.path.basename(file_path))[0]
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
async def _simpler_post_load_hygiene(
|
|
321
|
-
bridge,
|
|
322
|
-
ableton,
|
|
323
|
-
track_index: int,
|
|
324
|
-
device_index: int,
|
|
325
|
-
file_path: str,
|
|
326
|
-
) -> dict:
|
|
327
|
-
"""Apply post-load hygiene to a newly loaded Simpler and verify success.
|
|
328
|
-
|
|
329
|
-
Steps:
|
|
330
|
-
1. Read track info to verify the device's actual name matches the
|
|
331
|
-
expected sample stem. If it doesn't, return an error.
|
|
332
|
-
2. Set Snap=0 (Off) — required so sample playback works.
|
|
333
|
-
3. If filename indicates a warped loop, set S Start=0, S Length=1,
|
|
334
|
-
S Loop On=1 so the loop plays fully instead of being cropped.
|
|
335
|
-
4. Return a verified response dict.
|
|
336
|
-
"""
|
|
337
|
-
expected_stem = _filename_stem(file_path)
|
|
338
|
-
|
|
339
|
-
# Step 1: verify device name matches expected file
|
|
340
|
-
try:
|
|
341
|
-
track_info = ableton.send_command(
|
|
342
|
-
"get_track_info", {"track_index": track_index}
|
|
343
|
-
)
|
|
344
|
-
except Exception as exc:
|
|
345
|
-
return {"error": f"Verification read failed: {exc}"}
|
|
346
|
-
|
|
347
|
-
devices = track_info.get("devices", []) or []
|
|
348
|
-
if device_index < 0 or device_index >= len(devices):
|
|
349
|
-
return {
|
|
350
|
-
"error": (
|
|
351
|
-
f"Device index {device_index} out of range after load "
|
|
352
|
-
f"(track has {len(devices)} devices)"
|
|
353
|
-
),
|
|
354
|
-
"verified": False,
|
|
355
|
-
}
|
|
356
|
-
device = devices[device_index]
|
|
357
|
-
actual_name = str(device.get("name") or "")
|
|
358
|
-
verified = expected_stem in actual_name or actual_name in expected_stem
|
|
359
|
-
if not verified:
|
|
360
|
-
return {
|
|
361
|
-
"error": (
|
|
362
|
-
f"Sample verification FAILED — Simpler name '{actual_name}' "
|
|
363
|
-
f"does not match requested file '{expected_stem}'. The bridge "
|
|
364
|
-
f"reported success but the actual sample is different. "
|
|
365
|
-
f"Try `load_browser_item` with a user_library URI instead."
|
|
366
|
-
),
|
|
367
|
-
"verified": False,
|
|
368
|
-
"actual_device_name": actual_name,
|
|
369
|
-
"expected_stem": expected_stem,
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
# Step 2: turn Snap OFF — required for reliable playback after replace
|
|
373
|
-
hygiene_params: list[dict] = [
|
|
374
|
-
{"name_or_index": "Snap", "value": 0},
|
|
375
|
-
]
|
|
376
|
-
|
|
377
|
-
# Step 3: smart defaults for warped loops
|
|
378
|
-
if _is_warped_loop(file_path):
|
|
379
|
-
hygiene_params.extend([
|
|
380
|
-
{"name_or_index": "S Start", "value": 0.0},
|
|
381
|
-
{"name_or_index": "S Length", "value": 1.0},
|
|
382
|
-
{"name_or_index": "S Loop On", "value": 1},
|
|
383
|
-
])
|
|
384
|
-
|
|
385
|
-
try:
|
|
386
|
-
ableton.send_command("batch_set_parameters", {
|
|
387
|
-
"track_index": track_index,
|
|
388
|
-
"device_index": device_index,
|
|
389
|
-
"parameters": hygiene_params,
|
|
390
|
-
})
|
|
391
|
-
except Exception as exc:
|
|
392
|
-
logger.debug("_simpler_post_load_hygiene failed: %s", exc)
|
|
393
|
-
# non-fatal — verification already succeeded
|
|
394
|
-
pass
|
|
395
|
-
|
|
396
|
-
return {
|
|
397
|
-
"verified": True,
|
|
398
|
-
"device_name": actual_name,
|
|
399
|
-
"track_index": track_index,
|
|
400
|
-
"device_index": device_index,
|
|
401
|
-
"warped_loop_defaults_applied": _is_warped_loop(file_path),
|
|
402
|
-
}
|
|
256
|
+
# _BPM_IN_FILENAME_RE, _is_warped_loop, _filename_stem, and the
|
|
257
|
+
# _simpler_post_load_hygiene coroutine now live in
|
|
258
|
+
# ``_analyzer_engine/sample.py`` — re-exported via this module's imports
|
|
259
|
+
# at the top of the file so tests importing them by name still resolve.
|
|
403
260
|
|
|
404
261
|
|
|
405
262
|
@mcp.tool()
|
|
@@ -846,21 +703,9 @@ async def capture_stop(ctx: Context) -> dict:
|
|
|
846
703
|
return await bridge.send_command("capture_stop")
|
|
847
704
|
|
|
848
705
|
# ── Phase 4: FluCoMa Real-Time ───────────────────────────────────────────
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
def _flucoma_hint(cache) -> str:
|
|
854
|
-
"""Return an error hint if no FluCoMa data has arrived.
|
|
855
|
-
|
|
856
|
-
If ANY stream has data, FluCoMa is working and the specific stream just
|
|
857
|
-
hasn't updated yet — return a 'play audio' hint. If NO streams have data,
|
|
858
|
-
FluCoMa may not be installed — return an install hint.
|
|
859
|
-
"""
|
|
860
|
-
for key in ("spectral_shape", "mel_bands", "chroma", "loudness"):
|
|
861
|
-
if cache.get(key):
|
|
862
|
-
return "play some audio"
|
|
863
|
-
return "FluCoMa may not be installed. Install via: npx livepilot --setup-flucoma"
|
|
706
|
+
#
|
|
707
|
+
# PITCH_NAMES + _flucoma_hint now live in ``_analyzer_engine/flucoma.py``
|
|
708
|
+
# and are re-exported via the top-of-file imports for tests/subclassers.
|
|
864
709
|
|
|
865
710
|
|
|
866
711
|
@mcp.tool()
|