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.
Files changed (135) hide show
  1. package/CHANGELOG.md +254 -0
  2. package/README.md +19 -17
  3. package/bin/livepilot.js +146 -28
  4. package/installer/install.js +117 -11
  5. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  6. package/m4l_device/livepilot_bridge.js +1 -1
  7. package/mcp_server/__init__.py +1 -1
  8. package/mcp_server/atlas/__init__.py +39 -7
  9. package/mcp_server/atlas/tools.py +56 -15
  10. package/mcp_server/composer/layer_planner.py +27 -0
  11. package/mcp_server/composer/prompt_parser.py +15 -6
  12. package/mcp_server/connection.py +11 -3
  13. package/mcp_server/corpus/__init__.py +14 -4
  14. package/mcp_server/evaluation/fabric.py +62 -1
  15. package/mcp_server/m4l_bridge.py +63 -12
  16. package/mcp_server/project_brain/automation_graph.py +23 -1
  17. package/mcp_server/project_brain/builder.py +2 -0
  18. package/mcp_server/project_brain/models.py +20 -1
  19. package/mcp_server/project_brain/tools.py +10 -3
  20. package/mcp_server/runtime/execution_router.py +16 -2
  21. package/mcp_server/runtime/remote_commands.py +6 -0
  22. package/mcp_server/sample_engine/models.py +22 -3
  23. package/mcp_server/semantic_moves/__init__.py +1 -0
  24. package/mcp_server/semantic_moves/compiler.py +9 -1
  25. package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
  26. package/mcp_server/semantic_moves/mix_compilers.py +170 -0
  27. package/mcp_server/semantic_moves/mix_moves.py +1 -1
  28. package/mcp_server/semantic_moves/models.py +5 -0
  29. package/mcp_server/semantic_moves/tools.py +154 -35
  30. package/mcp_server/server.py +147 -17
  31. package/mcp_server/services/singletons.py +68 -0
  32. package/mcp_server/session_continuity/models.py +13 -0
  33. package/mcp_server/session_continuity/tools.py +2 -0
  34. package/mcp_server/session_continuity/tracker.py +93 -0
  35. package/mcp_server/splice_client/client.py +29 -8
  36. package/mcp_server/tools/_analyzer_engine/__init__.py +39 -0
  37. package/mcp_server/tools/_analyzer_engine/context.py +103 -0
  38. package/mcp_server/tools/_analyzer_engine/flucoma.py +23 -0
  39. package/mcp_server/tools/_analyzer_engine/sample.py +122 -0
  40. package/mcp_server/tools/_motif_engine.py +19 -4
  41. package/mcp_server/tools/analyzer.py +25 -180
  42. package/mcp_server/tools/clips.py +240 -2
  43. package/mcp_server/tools/midi_io.py +10 -0
  44. package/mcp_server/tools/tracks.py +1 -1
  45. package/mcp_server/tools/transport.py +59 -4
  46. package/mcp_server/translation_engine/tools.py +8 -4
  47. package/package.json +25 -3
  48. package/remote_script/LivePilot/__init__.py +36 -9
  49. package/remote_script/LivePilot/arrangement.py +12 -2
  50. package/remote_script/LivePilot/browser.py +16 -6
  51. package/remote_script/LivePilot/devices.py +10 -5
  52. package/remote_script/LivePilot/notes.py +13 -2
  53. package/remote_script/LivePilot/server.py +51 -13
  54. package/remote_script/LivePilot/version_detect.py +7 -4
  55. package/server.json +20 -0
  56. package/.claude-plugin/marketplace.json +0 -21
  57. package/.mcp.json.disabled +0 -9
  58. package/.mcpbignore +0 -60
  59. package/AGENTS.md +0 -46
  60. package/BUGS.md +0 -1570
  61. package/CODE_OF_CONDUCT.md +0 -27
  62. package/CONTRIBUTING.md +0 -131
  63. package/SECURITY.md +0 -48
  64. package/livepilot/.Codex-plugin/plugin.json +0 -8
  65. package/livepilot/.claude-plugin/plugin.json +0 -8
  66. package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
  67. package/livepilot/commands/arrange.md +0 -47
  68. package/livepilot/commands/beat.md +0 -77
  69. package/livepilot/commands/evaluate.md +0 -49
  70. package/livepilot/commands/memory.md +0 -22
  71. package/livepilot/commands/mix.md +0 -44
  72. package/livepilot/commands/perform.md +0 -42
  73. package/livepilot/commands/session.md +0 -13
  74. package/livepilot/commands/sounddesign.md +0 -43
  75. package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
  76. package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
  77. package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
  78. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
  79. package/livepilot/skills/livepilot-core/SKILL.md +0 -184
  80. package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
  81. package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
  82. package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
  83. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
  84. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
  85. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
  86. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
  87. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
  88. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
  89. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
  90. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
  91. package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
  92. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
  93. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
  94. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
  95. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
  96. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
  97. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
  98. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
  99. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
  100. package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
  101. package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
  102. package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
  103. package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
  104. package/livepilot/skills/livepilot-core/references/overview.md +0 -290
  105. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
  106. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
  107. package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
  108. package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
  109. package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
  110. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
  111. package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
  112. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
  113. package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
  114. package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
  115. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
  116. package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
  117. package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
  118. package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
  119. package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
  120. package/livepilot/skills/livepilot-release/SKILL.md +0 -130
  121. package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
  122. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
  123. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
  124. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
  125. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
  126. package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
  127. package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
  128. package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
  129. package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
  130. package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
  131. package/m4l_device/LivePilot_Analyzer.maxproj +0 -53
  132. package/manifest.json +0 -91
  133. package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
  134. package/scripts/generate_tool_catalog.py +0 -106
  135. 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
- deadline = asyncio.get_event_loop().time() + timeout
230
- while asyncio.get_event_loop().time() < deadline:
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(pb2.SyncSoundsRequest())
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(min(len(pattern) + 1, len(sorted_notes) - first_pos))
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=[], # TODO: rhythm detection in Phase 3
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
- CAPTURE_DIR = os.path.expanduser("~/Documents/LivePilot/captures")
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
- if analyzer_loaded:
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 = re.compile(r"(\d{2,3})\s*bpm", re.IGNORECASE)
308
-
309
-
310
- def _is_warped_loop(file_path: str) -> bool:
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
- PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
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()