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.
- package/CHANGELOG.md +128 -0
- package/README.md +15 -15
- 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 +15 -5
- 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/semantic_moves/tools.py +139 -31
- package/mcp_server/server.py +140 -14
- 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 +22 -178
- package/mcp_server/tools/clips.py +239 -1
- package/mcp_server/tools/transport.py +58 -3
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +8 -1
- package/server.json +3 -3
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Analyzer lifespan-context accessors + health check.
|
|
2
|
+
|
|
3
|
+
These were inline in ``analyzer.py`` pre-v1.10.9. Split out as part of
|
|
4
|
+
BUG-C1 so the tool file contains only ``@mcp.tool()`` definitions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from fastmcp import Context
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: no cover — type-only imports
|
|
15
|
+
from ...m4l_bridge import M4LBridge, SpectralCache
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_spectral(ctx: Context):
|
|
21
|
+
"""Get the SpectralCache from the lifespan context.
|
|
22
|
+
|
|
23
|
+
Attaches the active FastMCP ``Context`` to the cache so the analyzer
|
|
24
|
+
error path can distinguish "device missing" from "bridge disconnected"
|
|
25
|
+
— needed for the actionable error messages in ``_require_analyzer``.
|
|
26
|
+
"""
|
|
27
|
+
cache = ctx.lifespan_context.get("spectral")
|
|
28
|
+
if not cache:
|
|
29
|
+
raise ValueError("Spectral cache not initialized — restart the MCP server")
|
|
30
|
+
setattr(cache, "_livepilot_ctx", ctx)
|
|
31
|
+
return cache
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_m4l(ctx: Context):
|
|
35
|
+
"""Get the M4LBridge from the lifespan context."""
|
|
36
|
+
bridge = ctx.lifespan_context.get("m4l")
|
|
37
|
+
if not bridge:
|
|
38
|
+
raise ValueError("M4L bridge not initialized — restart the MCP server")
|
|
39
|
+
return bridge
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _require_analyzer(cache) -> None:
|
|
43
|
+
"""Raise a user-actionable error if the analyzer device isn't reachable.
|
|
44
|
+
|
|
45
|
+
The error text is the most user-visible surface of the analyzer layer,
|
|
46
|
+
so it spends effort distinguishing:
|
|
47
|
+
|
|
48
|
+
* "not loaded on master" → concrete drag-and-drop instructions
|
|
49
|
+
* "loaded but UDP port 9880 held by another instance" → show the
|
|
50
|
+
PID/command of the holder so the user can close it
|
|
51
|
+
* "loaded but bridge disconnected for some other reason" → generic
|
|
52
|
+
reload/restart hint
|
|
53
|
+
|
|
54
|
+
The ``_livepilot_ctx`` attribute attached by ``_get_spectral`` is what
|
|
55
|
+
lets us probe the master-track devices here; without it, the caller
|
|
56
|
+
would have to pass ``ctx`` through every ``_require_analyzer`` site.
|
|
57
|
+
"""
|
|
58
|
+
if cache.is_connected:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Imported lazily to avoid a circular import: server.py imports this
|
|
62
|
+
# package's parent during tool registration.
|
|
63
|
+
from ...server import _identify_port_holder
|
|
64
|
+
|
|
65
|
+
ctx = getattr(cache, "_livepilot_ctx", None)
|
|
66
|
+
try:
|
|
67
|
+
track = (
|
|
68
|
+
ctx.lifespan_context["ableton"].send_command("get_master_track")
|
|
69
|
+
if ctx else {}
|
|
70
|
+
)
|
|
71
|
+
except Exception as exc:
|
|
72
|
+
logger.debug("_require_analyzer failed: %s", exc)
|
|
73
|
+
track = {}
|
|
74
|
+
|
|
75
|
+
devices = track.get("devices", []) if isinstance(track, dict) else []
|
|
76
|
+
analyzer_loaded = False
|
|
77
|
+
for device in devices:
|
|
78
|
+
normalized = " ".join(
|
|
79
|
+
str(device.get("name") or "").replace("_", " ").replace("-", " ").lower().split()
|
|
80
|
+
)
|
|
81
|
+
if normalized == "livepilot analyzer":
|
|
82
|
+
analyzer_loaded = True
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
if analyzer_loaded:
|
|
86
|
+
holder = _identify_port_holder(9880)
|
|
87
|
+
detail = (
|
|
88
|
+
"LivePilot Analyzer is loaded on the master track, but its UDP bridge is not connected. "
|
|
89
|
+
)
|
|
90
|
+
if holder:
|
|
91
|
+
detail += (
|
|
92
|
+
"UDP port 9880 is currently held by another LivePilot instance "
|
|
93
|
+
f"({holder}). Close the other client/server, then retry."
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
detail += "Reload the analyzer device or restart the MCP server."
|
|
97
|
+
raise ValueError(detail)
|
|
98
|
+
|
|
99
|
+
raise ValueError(
|
|
100
|
+
"LivePilot Analyzer not detected. "
|
|
101
|
+
"Drag 'LivePilot Analyzer' onto the master track from "
|
|
102
|
+
"Audio Effects > Max Audio Effect."
|
|
103
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""FluCoMa-specific helpers (hints + pitch-name table).
|
|
2
|
+
|
|
3
|
+
Extracted from ``analyzer.py`` as part of BUG-C1. Purely about formatting
|
|
4
|
+
hints for the FluCoMa real-time streams — no Ableton or bridge access.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
PITCH_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _flucoma_hint(cache) -> str:
|
|
14
|
+
"""Return an error hint if no FluCoMa data has arrived on ``cache``.
|
|
15
|
+
|
|
16
|
+
If ANY stream has data, FluCoMa is working and the specific stream just
|
|
17
|
+
hasn't updated yet — return a 'play audio' hint. If NO streams have
|
|
18
|
+
data, FluCoMa may not be installed — return an install hint.
|
|
19
|
+
"""
|
|
20
|
+
for key in ("spectral_shape", "mel_bands", "chroma", "loudness"):
|
|
21
|
+
if cache.get(key):
|
|
22
|
+
return "play some audio"
|
|
23
|
+
return "FluCoMa may not be installed. Install via: npx livepilot --setup-flucoma"
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Simpler post-load hygiene + filename heuristics.
|
|
2
|
+
|
|
3
|
+
Extracted from ``analyzer.py`` as part of BUG-C1. Covers:
|
|
4
|
+
|
|
5
|
+
* BPM-in-filename detection (used to tell warped loops from one-shots)
|
|
6
|
+
* Post-load verification + Snap=0 + warped-loop defaults for Simpler
|
|
7
|
+
|
|
8
|
+
Context (docs/2026-04-14-bugs-discovered.md):
|
|
9
|
+
|
|
10
|
+
The M4L bridge's ``replace_simpler_sample`` command can report success
|
|
11
|
+
even when the sample is still the bootstrap placeholder. Simpler's
|
|
12
|
+
display name also doesn't refresh after a replace. After loading, the
|
|
13
|
+
``Snap`` parameter is ON by default which causes the Sample Start
|
|
14
|
+
position to snap to a location outside the new sample's valid audio —
|
|
15
|
+
resulting in silent playback. The hygiene here fixes both.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_BPM_IN_FILENAME_RE = re.compile(r"(\d{2,3})\s*bpm", re.IGNORECASE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_warped_loop(file_path: str) -> bool:
|
|
31
|
+
"""Return True if the filename contains a BPM marker (likely a tempo-locked loop)."""
|
|
32
|
+
stem = os.path.splitext(os.path.basename(file_path))[0]
|
|
33
|
+
return bool(_BPM_IN_FILENAME_RE.search(stem))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _filename_stem(file_path: str) -> str:
|
|
37
|
+
return os.path.splitext(os.path.basename(file_path))[0]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _simpler_post_load_hygiene(
|
|
41
|
+
bridge,
|
|
42
|
+
ableton,
|
|
43
|
+
track_index: int,
|
|
44
|
+
device_index: int,
|
|
45
|
+
file_path: str,
|
|
46
|
+
) -> dict:
|
|
47
|
+
"""Apply post-load hygiene to a newly loaded Simpler and verify success.
|
|
48
|
+
|
|
49
|
+
Steps:
|
|
50
|
+
1. Read track info to verify the device's actual name matches the
|
|
51
|
+
expected sample stem. If it doesn't, return an error.
|
|
52
|
+
2. Set Snap=0 (Off) — required so sample playback works.
|
|
53
|
+
3. If filename indicates a warped loop, set S Start=0, S Length=1,
|
|
54
|
+
S Loop On=1 so the loop plays fully instead of being cropped.
|
|
55
|
+
4. Return a verified response dict.
|
|
56
|
+
"""
|
|
57
|
+
expected_stem = _filename_stem(file_path)
|
|
58
|
+
|
|
59
|
+
# Step 1: verify device name matches expected file
|
|
60
|
+
try:
|
|
61
|
+
track_info = ableton.send_command(
|
|
62
|
+
"get_track_info", {"track_index": track_index}
|
|
63
|
+
)
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
return {"error": f"Verification read failed: {exc}"}
|
|
66
|
+
|
|
67
|
+
devices = track_info.get("devices", []) or []
|
|
68
|
+
if device_index < 0 or device_index >= len(devices):
|
|
69
|
+
return {
|
|
70
|
+
"error": (
|
|
71
|
+
f"Device index {device_index} out of range after load "
|
|
72
|
+
f"(track has {len(devices)} devices)"
|
|
73
|
+
),
|
|
74
|
+
"verified": False,
|
|
75
|
+
}
|
|
76
|
+
device = devices[device_index]
|
|
77
|
+
actual_name = str(device.get("name") or "")
|
|
78
|
+
verified = expected_stem in actual_name or actual_name in expected_stem
|
|
79
|
+
if not verified:
|
|
80
|
+
return {
|
|
81
|
+
"error": (
|
|
82
|
+
f"Sample verification FAILED — Simpler name '{actual_name}' "
|
|
83
|
+
f"does not match requested file '{expected_stem}'. The bridge "
|
|
84
|
+
f"reported success but the actual sample is different. "
|
|
85
|
+
f"Try `load_browser_item` with a user_library URI instead."
|
|
86
|
+
),
|
|
87
|
+
"verified": False,
|
|
88
|
+
"actual_device_name": actual_name,
|
|
89
|
+
"expected_stem": expected_stem,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# Step 2: turn Snap OFF — required for reliable playback after replace
|
|
93
|
+
hygiene_params: list[dict] = [
|
|
94
|
+
{"name_or_index": "Snap", "value": 0},
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
# Step 3: smart defaults for warped loops
|
|
98
|
+
if _is_warped_loop(file_path):
|
|
99
|
+
hygiene_params.extend([
|
|
100
|
+
{"name_or_index": "S Start", "value": 0.0},
|
|
101
|
+
{"name_or_index": "S Length", "value": 1.0},
|
|
102
|
+
{"name_or_index": "S Loop On", "value": 1},
|
|
103
|
+
])
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
ableton.send_command("batch_set_parameters", {
|
|
107
|
+
"track_index": track_index,
|
|
108
|
+
"device_index": device_index,
|
|
109
|
+
"parameters": hygiene_params,
|
|
110
|
+
})
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
logger.debug("_simpler_post_load_hygiene failed: %s", exc)
|
|
113
|
+
# non-fatal — verification already succeeded
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"verified": True,
|
|
118
|
+
"device_name": actual_name,
|
|
119
|
+
"track_index": track_index,
|
|
120
|
+
"device_index": device_index,
|
|
121
|
+
"warped_loop_defaults_applied": _is_warped_loop(file_path),
|
|
122
|
+
}
|
|
@@ -201,24 +201,39 @@ def detect_motifs(
|
|
|
201
201
|
salience = _score_salience(pattern, len(occurrences), total_note_count)
|
|
202
202
|
fatigue = _score_fatigue(len(occurrences), total_bars)
|
|
203
203
|
|
|
204
|
-
# Get representative pitches from first occurrence
|
|
204
|
+
# Get representative pitches + inter-onset intervals from first occurrence.
|
|
205
|
+
# Rhythm is the list of start_time deltas between successive notes in
|
|
206
|
+
# the pattern window; until v1.10.9 this field was left empty with a
|
|
207
|
+
# "TODO: Phase 3" marker, which is what forced Hook Hunter's rhythm
|
|
208
|
+
# side to fall back to drum-track-name regex. Populating it here lets
|
|
209
|
+
# downstream code actually reason about rhythmic distinctiveness.
|
|
205
210
|
first_occ = occurrences[0] if occurrences else {}
|
|
206
211
|
first_track = first_occ.get("track", 0)
|
|
207
212
|
first_pos = first_occ.get("start_position", 0)
|
|
208
|
-
rep_pitches = []
|
|
213
|
+
rep_pitches: list[int] = []
|
|
214
|
+
rhythm_intervals: list[float] = []
|
|
209
215
|
if first_track in notes_by_track:
|
|
210
216
|
sorted_notes = sorted(notes_by_track[first_track],
|
|
211
217
|
key=lambda n: n.get("start_time", 0))
|
|
218
|
+
span = min(len(pattern) + 1, len(sorted_notes) - first_pos)
|
|
212
219
|
rep_pitches = [
|
|
213
220
|
sorted_notes[first_pos + j].get("pitch", 60)
|
|
214
|
-
for j in range(
|
|
221
|
+
for j in range(span)
|
|
222
|
+
]
|
|
223
|
+
rhythm_intervals = [
|
|
224
|
+
round(
|
|
225
|
+
float(sorted_notes[first_pos + j + 1].get("start_time", 0.0))
|
|
226
|
+
- float(sorted_notes[first_pos + j].get("start_time", 0.0)),
|
|
227
|
+
4,
|
|
228
|
+
)
|
|
229
|
+
for j in range(span - 1)
|
|
215
230
|
]
|
|
216
231
|
|
|
217
232
|
motif = MotifUnit(
|
|
218
233
|
motif_id=f"motif_{len(motifs):03d}",
|
|
219
234
|
kind="melodic" if any(abs(i) > 0 for i in pattern) else "rhythmic",
|
|
220
235
|
intervals=list(pattern),
|
|
221
|
-
rhythm=
|
|
236
|
+
rhythm=rhythm_intervals,
|
|
222
237
|
representative_pitches=rep_pitches,
|
|
223
238
|
occurrences=occurrences,
|
|
224
239
|
salience=salience,
|
|
@@ -2,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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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()
|