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.
Files changed (49) hide show
  1. package/CHANGELOG.md +373 -0
  2. package/README.md +16 -16
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +1 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/evaluation/fabric.py +62 -1
  7. package/mcp_server/m4l_bridge.py +503 -18
  8. package/mcp_server/project_brain/automation_graph.py +23 -1
  9. package/mcp_server/project_brain/builder.py +2 -0
  10. package/mcp_server/project_brain/models.py +20 -1
  11. package/mcp_server/project_brain/tools.py +10 -3
  12. package/mcp_server/runtime/execution_router.py +7 -0
  13. package/mcp_server/runtime/mcp_dispatch.py +32 -0
  14. package/mcp_server/runtime/remote_commands.py +54 -0
  15. package/mcp_server/sample_engine/slice_classifier.py +169 -0
  16. package/mcp_server/semantic_moves/tools.py +139 -31
  17. package/mcp_server/server.py +151 -17
  18. package/mcp_server/session_continuity/models.py +13 -0
  19. package/mcp_server/session_continuity/tools.py +2 -0
  20. package/mcp_server/session_continuity/tracker.py +93 -0
  21. package/mcp_server/tools/_analyzer_engine/__init__.py +39 -0
  22. package/mcp_server/tools/_analyzer_engine/context.py +103 -0
  23. package/mcp_server/tools/_analyzer_engine/flucoma.py +23 -0
  24. package/mcp_server/tools/_analyzer_engine/sample.py +122 -0
  25. package/mcp_server/tools/_motif_engine.py +19 -4
  26. package/mcp_server/tools/analyzer.py +204 -180
  27. package/mcp_server/tools/clips.py +304 -1
  28. package/mcp_server/tools/devices.py +517 -5
  29. package/mcp_server/tools/diagnostics.py +42 -0
  30. package/mcp_server/tools/follow_actions.py +202 -0
  31. package/mcp_server/tools/grooves.py +142 -0
  32. package/mcp_server/tools/miditool.py +280 -0
  33. package/mcp_server/tools/scales.py +126 -0
  34. package/mcp_server/tools/take_lanes.py +135 -0
  35. package/mcp_server/tools/tracks.py +46 -3
  36. package/mcp_server/tools/transport.py +120 -4
  37. package/package.json +2 -2
  38. package/remote_script/LivePilot/__init__.py +15 -4
  39. package/remote_script/LivePilot/clips.py +62 -0
  40. package/remote_script/LivePilot/devices.py +444 -0
  41. package/remote_script/LivePilot/diagnostics.py +52 -1
  42. package/remote_script/LivePilot/follow_actions.py +235 -0
  43. package/remote_script/LivePilot/grooves.py +185 -0
  44. package/remote_script/LivePilot/scales.py +138 -0
  45. package/remote_script/LivePilot/take_lanes.py +175 -0
  46. package/remote_script/LivePilot/tracks.py +59 -1
  47. package/remote_script/LivePilot/transport.py +90 -1
  48. package/remote_script/LivePilot/version_detect.py +9 -0
  49. package/server.json +3 -3
@@ -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
- receiver = SpectralReceiver(spectral)
156
- m4l = M4LBridge(spectral, receiver)
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, compatible with FastMCP 0.x and 3.x.
316
-
317
- WARNING: Accesses FastMCP private internals (_tool_manager, _local_provider).
318
- Pinned to fastmcp>=3.0.0,<3.3.0 in requirements.txt. If upgrading FastMCP,
319
- verify these attributes still exist or update this function.
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
- # FastMCP 0.x: mcp._tool_manager._tools (dict of name -> Tool)
322
- if hasattr(mcp, "_tool_manager"):
323
- return list(mcp._tool_manager._tools.values())
324
- # FastMCP 3.x: mcp._local_provider._components (dict of key -> Tool)
325
- if hasattr(mcp, "_local_provider") and hasattr(mcp._local_provider, "_components"):
326
- return list(mcp._local_provider._components.values())
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: WARNING — could not access FastMCP tool registry, "
331
- "string-to-number schema coercion will not work",
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
+ }