livepilot 1.10.8 → 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.
@@ -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,90 +2,38 @@
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
 
9
14
  import logging
10
15
  import os
11
- import re # used below in filename parsing helpers
12
16
  from typing import Optional
13
17
 
14
18
  from fastmcp import Context
15
19
 
16
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
+ )
17
31
 
18
- # Logger must be defined before any helper that uses it — _require_analyzer
19
- # below calls logger.debug on an exception path, so defining the logger later
20
- # in the file risked NameError under unusual import orderings.
21
32
  logger = logging.getLogger(__name__)
22
33
 
23
34
  CAPTURE_DIR = os.path.expanduser("~/Documents/LivePilot/captures")
24
35
 
25
36
 
26
- def _get_spectral(ctx: Context):
27
- """Get SpectralCache from lifespan context."""
28
- cache = ctx.lifespan_context.get("spectral")
29
- if not cache:
30
- raise ValueError("Spectral cache not initialized — restart the MCP server")
31
- # Keep the active request context attached so analyzer error paths can
32
- # distinguish "device missing" from "bridge disconnected".
33
- setattr(cache, "_livepilot_ctx", ctx)
34
- return cache
35
-
36
-
37
- def _get_m4l(ctx: Context):
38
- """Get M4LBridge from lifespan context."""
39
- bridge = ctx.lifespan_context.get("m4l")
40
- if not bridge:
41
- raise ValueError("M4L bridge not initialized — restart the MCP server")
42
- return bridge
43
-
44
-
45
- def _require_analyzer(cache) -> None:
46
- """Raise a helpful error if the analyzer is not connected."""
47
- if not cache.is_connected:
48
- ctx = getattr(cache, "_livepilot_ctx", None)
49
- try:
50
- track = (
51
- ctx.lifespan_context["ableton"].send_command("get_master_track")
52
- if ctx else {}
53
- )
54
- except Exception as exc:
55
- logger.debug("_require_analyzer failed: %s", exc)
56
- track = {}
57
-
58
- devices = track.get("devices", []) if isinstance(track, dict) else []
59
- analyzer_loaded = False
60
- for device in devices:
61
- normalized = " ".join(
62
- str(device.get("name") or "").replace("_", " ").replace("-", " ").lower().split()
63
- )
64
- if normalized == "livepilot analyzer":
65
- analyzer_loaded = True
66
- break
67
-
68
- if analyzer_loaded:
69
- holder = _identify_port_holder(9880)
70
- detail = (
71
- "LivePilot Analyzer is loaded on the master track, but its UDP bridge is not connected. "
72
- )
73
- if holder:
74
- detail += (
75
- "UDP port 9880 is currently held by another LivePilot instance "
76
- f"({holder}). Close the other client/server, then retry."
77
- )
78
- else:
79
- detail += "Reload the analyzer device or restart the MCP server."
80
- raise ValueError(detail)
81
-
82
- raise ValueError(
83
- "LivePilot Analyzer not detected. "
84
- "Drag 'LivePilot Analyzer' onto the master track from "
85
- "Audio Effects > Max Audio Effect."
86
- )
87
-
88
-
89
37
  @mcp.tool()
90
38
  async def reconnect_bridge(ctx: Context) -> dict:
91
39
  """Attempt to reconnect the M4L UDP bridge (port 9880).
@@ -305,102 +253,10 @@ async def get_clip_file_path(
305
253
  # S Start=0, S Length=1, S Loop On=1 so the full loop plays in its
306
254
  # musical phrasing. For ONE-SHOTS, leave defaults alone.
307
255
 
308
- _BPM_IN_FILENAME_RE = re.compile(r"(\d{2,3})\s*bpm", re.IGNORECASE)
309
-
310
-
311
- def _is_warped_loop(file_path: str) -> bool:
312
- """Return True if the filename contains a BPM marker (likely a tempo-locked loop)."""
313
- stem = os.path.splitext(os.path.basename(file_path))[0]
314
- return bool(_BPM_IN_FILENAME_RE.search(stem))
315
-
316
-
317
- def _filename_stem(file_path: str) -> str:
318
- return os.path.splitext(os.path.basename(file_path))[0]
319
-
320
-
321
- async def _simpler_post_load_hygiene(
322
- bridge,
323
- ableton,
324
- track_index: int,
325
- device_index: int,
326
- file_path: str,
327
- ) -> dict:
328
- """Apply post-load hygiene to a newly loaded Simpler and verify success.
329
-
330
- Steps:
331
- 1. Read track info to verify the device's actual name matches the
332
- expected sample stem. If it doesn't, return an error.
333
- 2. Set Snap=0 (Off) — required so sample playback works.
334
- 3. If filename indicates a warped loop, set S Start=0, S Length=1,
335
- S Loop On=1 so the loop plays fully instead of being cropped.
336
- 4. Return a verified response dict.
337
- """
338
- expected_stem = _filename_stem(file_path)
339
-
340
- # Step 1: verify device name matches expected file
341
- try:
342
- track_info = ableton.send_command(
343
- "get_track_info", {"track_index": track_index}
344
- )
345
- except Exception as exc:
346
- return {"error": f"Verification read failed: {exc}"}
347
-
348
- devices = track_info.get("devices", []) or []
349
- if device_index < 0 or device_index >= len(devices):
350
- return {
351
- "error": (
352
- f"Device index {device_index} out of range after load "
353
- f"(track has {len(devices)} devices)"
354
- ),
355
- "verified": False,
356
- }
357
- device = devices[device_index]
358
- actual_name = str(device.get("name") or "")
359
- verified = expected_stem in actual_name or actual_name in expected_stem
360
- if not verified:
361
- return {
362
- "error": (
363
- f"Sample verification FAILED — Simpler name '{actual_name}' "
364
- f"does not match requested file '{expected_stem}'. The bridge "
365
- f"reported success but the actual sample is different. "
366
- f"Try `load_browser_item` with a user_library URI instead."
367
- ),
368
- "verified": False,
369
- "actual_device_name": actual_name,
370
- "expected_stem": expected_stem,
371
- }
372
-
373
- # Step 2: turn Snap OFF — required for reliable playback after replace
374
- hygiene_params: list[dict] = [
375
- {"name_or_index": "Snap", "value": 0},
376
- ]
377
-
378
- # Step 3: smart defaults for warped loops
379
- if _is_warped_loop(file_path):
380
- hygiene_params.extend([
381
- {"name_or_index": "S Start", "value": 0.0},
382
- {"name_or_index": "S Length", "value": 1.0},
383
- {"name_or_index": "S Loop On", "value": 1},
384
- ])
385
-
386
- try:
387
- ableton.send_command("batch_set_parameters", {
388
- "track_index": track_index,
389
- "device_index": device_index,
390
- "parameters": hygiene_params,
391
- })
392
- except Exception as exc:
393
- logger.debug("_simpler_post_load_hygiene failed: %s", exc)
394
- # non-fatal — verification already succeeded
395
- pass
396
-
397
- return {
398
- "verified": True,
399
- "device_name": actual_name,
400
- "track_index": track_index,
401
- "device_index": device_index,
402
- "warped_loop_defaults_applied": _is_warped_loop(file_path),
403
- }
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.
404
260
 
405
261
 
406
262
  @mcp.tool()
@@ -847,21 +703,9 @@ async def capture_stop(ctx: Context) -> dict:
847
703
  return await bridge.send_command("capture_stop")
848
704
 
849
705
  # ── Phase 4: FluCoMa Real-Time ───────────────────────────────────────────
850
-
851
- PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
852
-
853
-
854
- def _flucoma_hint(cache) -> str:
855
- """Return an error hint if no FluCoMa data has arrived.
856
-
857
- If ANY stream has data, FluCoMa is working and the specific stream just
858
- hasn't updated yet — return a 'play audio' hint. If NO streams have data,
859
- FluCoMa may not be installed — return an install hint.
860
- """
861
- for key in ("spectral_shape", "mel_bands", "chroma", "loudness"):
862
- if cache.get(key):
863
- return "play some audio"
864
- 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.
865
709
 
866
710
 
867
711
  @mcp.tool()