livepilot 1.10.8 → 1.12.2
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 +373 -0
- package/README.md +16 -16
- 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/evaluation/fabric.py +62 -1
- package/mcp_server/m4l_bridge.py +503 -18
- 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 +7 -0
- package/mcp_server/runtime/mcp_dispatch.py +32 -0
- package/mcp_server/runtime/remote_commands.py +54 -0
- package/mcp_server/sample_engine/slice_classifier.py +169 -0
- package/mcp_server/semantic_moves/tools.py +139 -31
- package/mcp_server/server.py +151 -17
- 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/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 +204 -180
- package/mcp_server/tools/clips.py +304 -1
- package/mcp_server/tools/devices.py +517 -5
- package/mcp_server/tools/diagnostics.py +42 -0
- package/mcp_server/tools/follow_actions.py +202 -0
- package/mcp_server/tools/grooves.py +142 -0
- package/mcp_server/tools/miditool.py +280 -0
- package/mcp_server/tools/scales.py +126 -0
- package/mcp_server/tools/take_lanes.py +135 -0
- package/mcp_server/tools/tracks.py +46 -3
- package/mcp_server/tools/transport.py +120 -4
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +15 -4
- package/remote_script/LivePilot/clips.py +62 -0
- package/remote_script/LivePilot/devices.py +444 -0
- package/remote_script/LivePilot/diagnostics.py +52 -1
- package/remote_script/LivePilot/follow_actions.py +235 -0
- package/remote_script/LivePilot/grooves.py +185 -0
- package/remote_script/LivePilot/scales.py +138 -0
- package/remote_script/LivePilot/take_lanes.py +175 -0
- package/remote_script/LivePilot/tracks.py +59 -1
- package/remote_script/LivePilot/transport.py +90 -1
- package/remote_script/LivePilot/version_detect.py +9 -0
- package/server.json +3 -3
package/mcp_server/server.py
CHANGED
|
@@ -9,7 +9,7 @@ import subprocess
|
|
|
9
9
|
from fastmcp import FastMCP, Context # noqa: F401
|
|
10
10
|
|
|
11
11
|
from .connection import AbletonConnection
|
|
12
|
-
from .m4l_bridge import SpectralCache, SpectralReceiver, M4LBridge
|
|
12
|
+
from .m4l_bridge import SpectralCache, SpectralReceiver, M4LBridge, MidiToolCache
|
|
13
13
|
|
|
14
14
|
# Logger must be defined before any function uses it — several module-level
|
|
15
15
|
# helpers below (e.g. _master_has_livepilot_analyzer) call logger.debug on
|
|
@@ -144,6 +144,28 @@ async def _warm_analyzer_bridge(
|
|
|
144
144
|
await asyncio.sleep(0.05)
|
|
145
145
|
|
|
146
146
|
|
|
147
|
+
def _bind_session_continuity(ableton: AbletonConnection) -> None:
|
|
148
|
+
"""Hydrate the session-continuity tracker from persistent per-project state.
|
|
149
|
+
|
|
150
|
+
Fetches a minimal session fingerprint (tempo, signature, track/scene
|
|
151
|
+
layout) from the Remote Script, computes a project hash, and asks the
|
|
152
|
+
tracker to bind the matching ProjectStore + restore any previously-saved
|
|
153
|
+
creative threads and turn resolutions from disk.
|
|
154
|
+
|
|
155
|
+
Never raises: startup must succeed even if Ableton isn't reachable. In
|
|
156
|
+
that case, the tracker stays in-memory and the first ``record_turn_*`` /
|
|
157
|
+
``open_thread`` call will lazy-bind via ``ensure_project_store_bound()``.
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
from .session_continuity.tracker import bind_project_store_from_session
|
|
161
|
+
|
|
162
|
+
info = ableton.send_command("get_session_info")
|
|
163
|
+
if isinstance(info, dict) and not info.get("error"):
|
|
164
|
+
bind_project_store_from_session(info)
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
logger.debug("_bind_session_continuity: lazy-bind (reason: %s)", exc)
|
|
167
|
+
|
|
168
|
+
|
|
147
169
|
@asynccontextmanager
|
|
148
170
|
async def lifespan(server):
|
|
149
171
|
"""Create and yield the shared AbletonConnection + M4L bridge + registries."""
|
|
@@ -152,8 +174,9 @@ async def lifespan(server):
|
|
|
152
174
|
|
|
153
175
|
ableton = AbletonConnection()
|
|
154
176
|
spectral = SpectralCache()
|
|
155
|
-
|
|
156
|
-
|
|
177
|
+
miditool = MidiToolCache()
|
|
178
|
+
receiver = SpectralReceiver(spectral, miditool_cache=miditool)
|
|
179
|
+
m4l = M4LBridge(spectral, receiver, miditool_cache=miditool)
|
|
157
180
|
mcp_dispatch = build_mcp_dispatch_registry()
|
|
158
181
|
|
|
159
182
|
# Splice gRPC client — graceful degradation if Splice desktop isn't
|
|
@@ -203,9 +226,16 @@ async def lifespan(server):
|
|
|
203
226
|
_check_remote_script_version(ableton)
|
|
204
227
|
if bridge_state["transport"] is not None:
|
|
205
228
|
await _warm_analyzer_bridge(ableton, spectral)
|
|
229
|
+
# Bind per-project persistent store so creative threads and turn
|
|
230
|
+
# history survive server restarts. Until v1.10.9 this was plumbed
|
|
231
|
+
# through the tracker but never called — threads/turns were effectively
|
|
232
|
+
# in-memory only. If Ableton isn't reachable yet, tools will lazy-bind
|
|
233
|
+
# on first write via ensure_project_store_bound().
|
|
234
|
+
_bind_session_continuity(ableton)
|
|
206
235
|
yield {
|
|
207
236
|
"ableton": ableton,
|
|
208
237
|
"spectral": spectral,
|
|
238
|
+
"miditool": miditool,
|
|
209
239
|
"m4l": m4l,
|
|
210
240
|
"_bridge_state": bridge_state,
|
|
211
241
|
"mcp_dispatch": mcp_dispatch,
|
|
@@ -229,6 +259,10 @@ from .tools import clips # noqa: F401, E402
|
|
|
229
259
|
from .tools import notes # noqa: F401, E402
|
|
230
260
|
from .tools import devices # noqa: F401, E402
|
|
231
261
|
from .tools import scenes # noqa: F401, E402
|
|
262
|
+
from .tools import scales # noqa: F401, E402
|
|
263
|
+
from .tools import follow_actions # noqa: F401, E402
|
|
264
|
+
from .tools import grooves # noqa: F401, E402
|
|
265
|
+
from .tools import take_lanes # noqa: F401, E402
|
|
232
266
|
from .tools import mixing # noqa: F401, E402
|
|
233
267
|
from .tools import browser # noqa: F401, E402
|
|
234
268
|
from .tools import arrangement # noqa: F401, E402
|
|
@@ -271,6 +305,8 @@ from .device_forge import tools as device_forge_tools # noqa: F401, E40
|
|
|
271
305
|
from .sample_engine import tools as sample_engine_tools # noqa: F401, E402
|
|
272
306
|
from .atlas import tools as atlas_tools # noqa: F401, E402
|
|
273
307
|
from .composer import tools as composer_tools # noqa: F401, E402
|
|
308
|
+
from .tools import diagnostics # noqa: F401, E402
|
|
309
|
+
from .tools import miditool # noqa: F401, E402
|
|
274
310
|
|
|
275
311
|
# ---------------------------------------------------------------------------
|
|
276
312
|
# Schema coercion patch — accept strings for numeric parameters
|
|
@@ -312,28 +348,125 @@ def _coerce_schema_property(prop: dict) -> None:
|
|
|
312
348
|
|
|
313
349
|
|
|
314
350
|
def _get_all_tools():
|
|
315
|
-
"""Get all registered tools
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
351
|
+
"""Get all registered tools — defends against FastMCP internal drift.
|
|
352
|
+
|
|
353
|
+
FastMCP's public API doesn't expose the registry as of 3.2.x (see
|
|
354
|
+
docs/FASTMCP_UPSTREAM_FR.md). Until it does, we probe known internal
|
|
355
|
+
attribute paths. Each probe fires in try/except so a structural
|
|
356
|
+
rearrangement (e.g. ``_components`` renamed under 3.3+) falls through
|
|
357
|
+
to the next path rather than exploding.
|
|
358
|
+
|
|
359
|
+
WARNING: Accesses FastMCP private internals. Pinned to
|
|
360
|
+
fastmcp>=3.0.0,<3.3.0 in requirements.txt. The startup self-test
|
|
361
|
+
(_assert_tool_registry_accessible) will fail loudly if every probe
|
|
362
|
+
returns empty — better than silently returning [] and disabling
|
|
363
|
+
schema coercion.
|
|
320
364
|
"""
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
365
|
+
probes = [
|
|
366
|
+
# FastMCP 0.x: mcp._tool_manager._tools (dict of name -> Tool)
|
|
367
|
+
("_tool_manager._tools", lambda: list(mcp._tool_manager._tools.values())),
|
|
368
|
+
# FastMCP 3.0–3.2: mcp._local_provider._components
|
|
369
|
+
(
|
|
370
|
+
"_local_provider._components",
|
|
371
|
+
lambda: list(mcp._local_provider._components.values()),
|
|
372
|
+
),
|
|
373
|
+
# FastMCP 3.3+ speculative: mcp._local_provider._tools (anticipated
|
|
374
|
+
# rename based on naming conventions in other providers). Kept here
|
|
375
|
+
# so a future bump surfaces a partial match rather than a full miss.
|
|
376
|
+
(
|
|
377
|
+
"_local_provider._tools",
|
|
378
|
+
lambda: list(mcp._local_provider._tools.values()),
|
|
379
|
+
),
|
|
380
|
+
# Public-API future path (what we're asking for in the upstream FR);
|
|
381
|
+
# harmless to probe now so that once it ships we can lift the ceiling
|
|
382
|
+
# without touching this function again.
|
|
383
|
+
("list_tools", lambda: list(mcp.list_tools())),
|
|
384
|
+
]
|
|
385
|
+
for label, fn in probes:
|
|
386
|
+
try:
|
|
387
|
+
tools = fn()
|
|
388
|
+
except (AttributeError, TypeError):
|
|
389
|
+
continue
|
|
390
|
+
except Exception: # noqa: BLE001 — any error from an internal probe means "skip"
|
|
391
|
+
continue
|
|
392
|
+
if tools:
|
|
393
|
+
return tools
|
|
394
|
+
|
|
395
|
+
# All probes empty. Surface fastmcp version + attempted paths so the
|
|
396
|
+
# breakage is diagnosable without re-reading the code.
|
|
327
397
|
import sys
|
|
328
|
-
|
|
398
|
+
try:
|
|
399
|
+
import fastmcp as _fm
|
|
400
|
+
fm_version = getattr(_fm, "__version__", "unknown")
|
|
401
|
+
except Exception: # noqa: BLE001
|
|
402
|
+
fm_version = "unknown"
|
|
329
403
|
print(
|
|
330
|
-
"LivePilot:
|
|
331
|
-
"
|
|
404
|
+
"LivePilot: ERROR — could not access FastMCP tool registry "
|
|
405
|
+
f"(fastmcp=={fm_version}). Tried: "
|
|
406
|
+
+ ", ".join(label for label, _ in probes)
|
|
407
|
+
+ ". Schema coercion and tool-catalog generation will be broken. "
|
|
408
|
+
"If FastMCP updated its internals, see docs/FASTMCP_UPSTREAM_FR.md.",
|
|
332
409
|
file=sys.stderr,
|
|
333
410
|
)
|
|
334
411
|
return []
|
|
335
412
|
|
|
336
413
|
|
|
414
|
+
def _assert_tool_registry_accessible() -> None:
|
|
415
|
+
"""Loudly fail startup if the FastMCP registry probe returns nothing.
|
|
416
|
+
|
|
417
|
+
Called once at module import, just before schema patching. The schema
|
|
418
|
+
patch silently no-ops on an empty registry, so without this assertion
|
|
419
|
+
a FastMCP-internals rename would degrade silently and produce a server
|
|
420
|
+
with 324 tools but no string-to-number coercion — a subtle, hard-to-
|
|
421
|
+
diagnose class of failure we've paid for once already.
|
|
422
|
+
|
|
423
|
+
Reads the expected count from ``tests/test_tools_contract.py`` (same
|
|
424
|
+
source of truth sync_metadata.py uses), so no second magic number.
|
|
425
|
+
"""
|
|
426
|
+
import re
|
|
427
|
+
import sys
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
contract_src = (
|
|
431
|
+
(__file__.rsplit("/", 2)[0] + "/tests/test_tools_contract.py")
|
|
432
|
+
if "__file__" in globals() else None
|
|
433
|
+
)
|
|
434
|
+
# Prefer an absolute path via Path for reliability:
|
|
435
|
+
from pathlib import Path
|
|
436
|
+
contract_path = Path(__file__).resolve().parents[1] / "tests" / "test_tools_contract.py"
|
|
437
|
+
expected = None
|
|
438
|
+
if contract_path.exists():
|
|
439
|
+
match = re.search(
|
|
440
|
+
r"assert len\(tools\) == (\d+)",
|
|
441
|
+
contract_path.read_text(encoding="utf-8"),
|
|
442
|
+
)
|
|
443
|
+
if match:
|
|
444
|
+
expected = int(match.group(1))
|
|
445
|
+
except Exception: # noqa: BLE001 — self-test must not block startup
|
|
446
|
+
expected = None
|
|
447
|
+
|
|
448
|
+
actual = len(_get_all_tools())
|
|
449
|
+
if actual == 0:
|
|
450
|
+
# Registry probe returned empty — this is the regression the test guards.
|
|
451
|
+
# Don't sys.exit (some test harnesses import server.py without a live
|
|
452
|
+
# FastMCP); print a loud diagnostic and let downstream code react.
|
|
453
|
+
print(
|
|
454
|
+
"LivePilot: STARTUP SELF-TEST FAILED — _get_all_tools() returned 0. "
|
|
455
|
+
"FastMCP internals likely changed. Verify requirements.txt pin "
|
|
456
|
+
"(fastmcp>=3.0.0,<3.3.0) matches the installed version.",
|
|
457
|
+
file=sys.stderr,
|
|
458
|
+
)
|
|
459
|
+
return
|
|
460
|
+
if expected is not None and actual != expected:
|
|
461
|
+
print(
|
|
462
|
+
f"LivePilot: STARTUP SELF-TEST WARNING — _get_all_tools() "
|
|
463
|
+
f"returned {actual} tools, tests/test_tools_contract.py expects "
|
|
464
|
+
f"{expected}. If you've added/removed tools, update the contract "
|
|
465
|
+
"and run scripts/sync_metadata.py --fix.",
|
|
466
|
+
file=sys.stderr,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
337
470
|
def _patch_tool_schemas() -> None:
|
|
338
471
|
"""Post-process all registered tool schemas for string coercion."""
|
|
339
472
|
for tool in _get_all_tools():
|
|
@@ -346,6 +479,7 @@ def _patch_tool_schemas() -> None:
|
|
|
346
479
|
if isinstance(definition, dict):
|
|
347
480
|
_coerce_schema_property(definition)
|
|
348
481
|
|
|
482
|
+
_assert_tool_registry_accessible()
|
|
349
483
|
_patch_tool_schemas()
|
|
350
484
|
|
|
351
485
|
|
|
@@ -22,6 +22,13 @@ class CreativeThread:
|
|
|
22
22
|
def to_dict(self) -> dict:
|
|
23
23
|
return asdict(self)
|
|
24
24
|
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_dict(cls, data: dict) -> "CreativeThread":
|
|
27
|
+
"""Rehydrate from persisted dict; unknown keys are ignored so a future
|
|
28
|
+
schema bump won't break load on older on-disk state."""
|
|
29
|
+
allowed = {f for f in cls.__dataclass_fields__}
|
|
30
|
+
return cls(**{k: v for k, v in data.items() if k in allowed})
|
|
31
|
+
|
|
25
32
|
@property
|
|
26
33
|
def is_stale(self) -> bool:
|
|
27
34
|
"""A thread is stale if untouched for >30 minutes."""
|
|
@@ -44,6 +51,12 @@ class TurnResolution:
|
|
|
44
51
|
def to_dict(self) -> dict:
|
|
45
52
|
return asdict(self)
|
|
46
53
|
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_dict(cls, data: dict) -> "TurnResolution":
|
|
56
|
+
"""Rehydrate from persisted dict; unknown keys are ignored."""
|
|
57
|
+
allowed = {f for f in cls.__dataclass_fields__}
|
|
58
|
+
return cls(**{k: v for k, v in data.items() if k in allowed})
|
|
59
|
+
|
|
47
60
|
|
|
48
61
|
@dataclass
|
|
49
62
|
class SessionStory:
|
|
@@ -65,6 +65,7 @@ def record_turn_resolution(
|
|
|
65
65
|
identity_effect: "preserves", "evolves", "contrasts", or "resets"
|
|
66
66
|
user_sentiment: "loved", "liked", "neutral", "disliked", or "hated"
|
|
67
67
|
"""
|
|
68
|
+
tracker.ensure_project_store_bound(ctx)
|
|
68
69
|
turn = tracker.record_turn_resolution(
|
|
69
70
|
request_text=request_text,
|
|
70
71
|
outcome=outcome,
|
|
@@ -130,6 +131,7 @@ def open_creative_thread(
|
|
|
130
131
|
if not description.strip():
|
|
131
132
|
return {"error": "description cannot be empty"}
|
|
132
133
|
|
|
134
|
+
tracker.ensure_project_store_bound(ctx)
|
|
133
135
|
thread = tracker.open_thread(description, domain=domain, priority=priority)
|
|
134
136
|
return thread.to_dict()
|
|
135
137
|
|
|
@@ -44,6 +44,99 @@ def reset_story() -> None:
|
|
|
44
44
|
_project_store = None
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def bind_project_store_from_session(session_info: dict) -> Optional[str]:
|
|
48
|
+
"""Bind a per-project persistent store and hydrate in-memory state.
|
|
49
|
+
|
|
50
|
+
Computes a project fingerprint from ``session_info`` (tempo, time sig,
|
|
51
|
+
song length, track/scene/return layout), opens the matching
|
|
52
|
+
``ProjectStore`` under ``~/.livepilot/projects/<hash>/``, and rehydrates
|
|
53
|
+
the in-memory ``_threads`` and ``_turns`` from disk so that restarting
|
|
54
|
+
the MCP server preserves the user's creative threads and turn history.
|
|
55
|
+
|
|
56
|
+
Returns the project_id (12-char hash) on success, ``None`` on failure
|
|
57
|
+
(so callers can log without aborting startup). If the hash hasn't
|
|
58
|
+
changed since the last bind, this is a no-op — hot path is safe to
|
|
59
|
+
call on every turn.
|
|
60
|
+
|
|
61
|
+
Without this function, ``set_project_store()`` existed but nobody
|
|
62
|
+
called it, meaning README's "return to a project with prior creative
|
|
63
|
+
threads intact" was literally false — threads/turns were in-memory
|
|
64
|
+
only and reset on every server restart.
|
|
65
|
+
"""
|
|
66
|
+
global _threads, _turns, _project_store
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
from ..persistence.project_store import ProjectStore, project_hash
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
logger.debug("bind_project_store_from_session: import failed: %s", exc)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
new_id = project_hash(session_info or {})
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
logger.debug("bind_project_store_from_session: hash failed: %s", exc)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
# Already bound to this project? Nothing to do.
|
|
81
|
+
if _project_store is not None and getattr(_project_store, "project_id", None) == new_id:
|
|
82
|
+
return new_id
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
store = ProjectStore(new_id)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
logger.debug("bind_project_store_from_session: store open failed: %s", exc)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
# Hydrate in-memory threads + turns from the persisted store. We only
|
|
91
|
+
# rebuild what the tracker keeps live — SessionStory is recomputed on
|
|
92
|
+
# each get_session_story() call, so it doesn't need a direct restore.
|
|
93
|
+
try:
|
|
94
|
+
raw_threads = store.get_threads()
|
|
95
|
+
raw_turns = store.get_turns()
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
logger.debug("bind_project_store_from_session: read failed: %s", exc)
|
|
98
|
+
raw_threads, raw_turns = [], []
|
|
99
|
+
|
|
100
|
+
_threads = {
|
|
101
|
+
t["thread_id"]: CreativeThread.from_dict(t)
|
|
102
|
+
for t in raw_threads
|
|
103
|
+
if isinstance(t, dict) and "thread_id" in t
|
|
104
|
+
}
|
|
105
|
+
_turns = [
|
|
106
|
+
TurnResolution.from_dict(t)
|
|
107
|
+
for t in raw_turns
|
|
108
|
+
if isinstance(t, dict)
|
|
109
|
+
]
|
|
110
|
+
_project_store = store
|
|
111
|
+
logger.info(
|
|
112
|
+
"session_continuity: bound project %s (%d threads, %d turns restored)",
|
|
113
|
+
new_id, len(_threads), len(_turns),
|
|
114
|
+
)
|
|
115
|
+
return new_id
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def ensure_project_store_bound(ctx) -> Optional[str]:
|
|
119
|
+
"""Lazy bind on first use — for tools called before lifespan could reach Ableton.
|
|
120
|
+
|
|
121
|
+
``ctx`` is a FastMCP Context; reads the ``ableton`` connection from
|
|
122
|
+
``ctx.lifespan_context`` and fetches session info to compute the project
|
|
123
|
+
hash. Safe to call on every turn — if already bound to this project, it's
|
|
124
|
+
a no-op. Returns the project_id or ``None`` on failure.
|
|
125
|
+
"""
|
|
126
|
+
if _project_store is not None:
|
|
127
|
+
return getattr(_project_store, "project_id", None)
|
|
128
|
+
try:
|
|
129
|
+
ableton = ctx.lifespan_context.get("ableton")
|
|
130
|
+
if ableton is None:
|
|
131
|
+
return None
|
|
132
|
+
info = ableton.send_command("get_session_info")
|
|
133
|
+
if isinstance(info, dict) and not info.get("error"):
|
|
134
|
+
return bind_project_store_from_session(info)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
logger.debug("ensure_project_store_bound: %s", exc)
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
47
140
|
# ── Session story ─────────────────────────────────────────────────
|
|
48
141
|
|
|
49
142
|
|
|
@@ -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
|
+
}
|