livepilot 1.9.8 → 1.9.11
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/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +45 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +131 -0
- package/README.md +119 -395
- package/SECURITY.md +48 -0
- package/bin/livepilot.js +41 -1
- package/livepilot/.Codex-plugin/plugin.json +8 -0
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/commands/beat.md +18 -6
- package/livepilot/commands/sounddesign.md +6 -5
- package/livepilot/skills/livepilot-core/SKILL.md +30 -7
- package/livepilot/skills/livepilot-core/references/overview.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +7 -4
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.maxpat +378 -4
- package/m4l_device/livepilot_bridge.js +413 -184
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +35 -0
- package/mcp_server/m4l_bridge.py +55 -5
- package/mcp_server/server.py +16 -29
- package/mcp_server/tools/_perception_engine.py +26 -3
- package/mcp_server/tools/_theory_engine.py +36 -5
- package/mcp_server/tools/analyzer.py +62 -17
- package/mcp_server/tools/automation.py +4 -1
- package/mcp_server/tools/devices.py +199 -10
- package/mcp_server/tools/generative.py +4 -1
- package/mcp_server/tools/harmony.py +16 -3
- package/mcp_server/tools/notes.py +4 -1
- package/mcp_server/tools/perception.py +27 -1
- package/mcp_server/tools/scenes.py +4 -1
- package/mcp_server/tools/theory.py +15 -7
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +13 -116
- package/remote_script/LivePilot/diagnostics.py +81 -5
- package/remote_script/LivePilot/router.py +22 -0
- package/remote_script/LivePilot/server.py +25 -13
- package/remote_script/LivePilot/tracks.py +17 -2
package/mcp_server/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
|
|
2
|
-
__version__ = "1.9.
|
|
2
|
+
__version__ = "1.9.11"
|
package/mcp_server/connection.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
7
|
import socket
|
|
8
|
+
import subprocess
|
|
8
9
|
import threading
|
|
9
10
|
import time
|
|
10
11
|
import uuid
|
|
@@ -44,6 +45,33 @@ def _friendly_error(code: str, message: str, command_type: str) -> str:
|
|
|
44
45
|
return " ".join(parts)
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
def _identify_other_tcp_client(host: str, port: int) -> str | None:
|
|
49
|
+
"""Return a short description of another established client on the Live port."""
|
|
50
|
+
try:
|
|
51
|
+
out = subprocess.check_output(
|
|
52
|
+
["lsof", "-nP", f"-iTCP:{port}"],
|
|
53
|
+
text=True,
|
|
54
|
+
timeout=3,
|
|
55
|
+
)
|
|
56
|
+
except (subprocess.CalledProcessError, FileNotFoundError, ValueError, OSError):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
target = f"->{host}:{port}"
|
|
60
|
+
my_pid = os.getpid()
|
|
61
|
+
for line in out.splitlines()[1:]:
|
|
62
|
+
parts = line.split()
|
|
63
|
+
if len(parts) < 2 or target not in line or "(ESTABLISHED)" not in line:
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
pid = int(parts[1])
|
|
67
|
+
except ValueError:
|
|
68
|
+
continue
|
|
69
|
+
if pid == my_pid:
|
|
70
|
+
continue
|
|
71
|
+
return f"PID {pid} ({parts[0]})"
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
47
75
|
class AbletonConnection:
|
|
48
76
|
"""TCP client that sends JSON commands to the LivePilot Remote Script."""
|
|
49
77
|
|
|
@@ -197,6 +225,13 @@ class AbletonConnection:
|
|
|
197
225
|
except socket.timeout as exc:
|
|
198
226
|
self._recv_buf = buf
|
|
199
227
|
self.disconnect()
|
|
228
|
+
other_client = _identify_other_tcp_client(self.host, self.port)
|
|
229
|
+
if other_client:
|
|
230
|
+
raise AbletonConnectionError(
|
|
231
|
+
"Timeout waiting for response from Ableton. "
|
|
232
|
+
f"Another LivePilot client appears to be connected on {self.host}:{self.port} "
|
|
233
|
+
f"({other_client}). Disconnect the other client and retry."
|
|
234
|
+
) from exc
|
|
200
235
|
raise AbletonConnectionError(
|
|
201
236
|
f"Timeout waiting for response from Ableton ({RECV_TIMEOUT}s)"
|
|
202
237
|
) from exc
|
package/mcp_server/m4l_bridge.py
CHANGED
|
@@ -21,6 +21,50 @@ import time
|
|
|
21
21
|
from typing import Any, Optional
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def _encode_string_arg(value: str) -> str:
|
|
25
|
+
"""Encode a Python string arg into an ASCII-safe OSC payload.
|
|
26
|
+
|
|
27
|
+
The Max JS side decodes values with the ``b64:`` prefix back to UTF-8.
|
|
28
|
+
Keeping the wire payload ASCII-only avoids OSC/client issues with
|
|
29
|
+
non-ASCII file paths and device names.
|
|
30
|
+
"""
|
|
31
|
+
encoded = base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii")
|
|
32
|
+
return "b64:" + encoded.rstrip("=")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_macos_path(path: str) -> str:
|
|
36
|
+
"""Convert Max-style HFS-ish paths into POSIX paths when possible."""
|
|
37
|
+
if len(path) >= 3 and path[1] == ":" and path[2] in ("/", "\\"):
|
|
38
|
+
return path
|
|
39
|
+
|
|
40
|
+
colon = path.find(":")
|
|
41
|
+
slash = path.find("/")
|
|
42
|
+
if colon <= 0 or (slash != -1 and colon > slash):
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
rest = path[colon + 1:]
|
|
46
|
+
if ":" in rest:
|
|
47
|
+
rest = rest.replace(":", "/")
|
|
48
|
+
if not rest.startswith("/"):
|
|
49
|
+
rest = "/" + rest.lstrip("/\\")
|
|
50
|
+
return rest
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _normalize_bridge_payload(value: Any) -> Any:
|
|
54
|
+
"""Normalize filesystem paths inside bridge payloads."""
|
|
55
|
+
if isinstance(value, dict):
|
|
56
|
+
normalized = {}
|
|
57
|
+
for key, item in value.items():
|
|
58
|
+
if key == "file_path" and isinstance(item, str):
|
|
59
|
+
normalized[key] = _normalize_macos_path(item)
|
|
60
|
+
else:
|
|
61
|
+
normalized[key] = _normalize_bridge_payload(item)
|
|
62
|
+
return normalized
|
|
63
|
+
if isinstance(value, list):
|
|
64
|
+
return [_normalize_bridge_payload(item) for item in value]
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
24
68
|
class SpectralCache:
|
|
25
69
|
"""Thread-safe cache for incoming spectral data from M4L.
|
|
26
70
|
|
|
@@ -226,7 +270,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
226
270
|
# URL-safe base64 decode (- and _ instead of + and /)
|
|
227
271
|
padded = encoded + "=" * (-len(encoded) % 4)
|
|
228
272
|
decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
|
|
229
|
-
result = json.loads(decoded)
|
|
273
|
+
result = _normalize_bridge_payload(json.loads(decoded))
|
|
230
274
|
if self._response_callback and not self._response_callback.done():
|
|
231
275
|
self._response_callback.set_result(result)
|
|
232
276
|
except Exception as exc:
|
|
@@ -256,7 +300,7 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
256
300
|
try:
|
|
257
301
|
padded = encoded + "=" * (-len(encoded) % 4)
|
|
258
302
|
decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
|
|
259
|
-
result = json.loads(decoded)
|
|
303
|
+
result = _normalize_bridge_payload(json.loads(decoded))
|
|
260
304
|
if self._capture_future and not self._capture_future.done():
|
|
261
305
|
self._capture_future.set_result(result)
|
|
262
306
|
except Exception as exc:
|
|
@@ -343,8 +387,13 @@ class M4LBridge:
|
|
|
343
387
|
self.receiver._capture_future = None
|
|
344
388
|
|
|
345
389
|
def _build_osc(self, address: str, args: tuple) -> bytes:
|
|
346
|
-
"""Build a minimal OSC message.
|
|
347
|
-
|
|
390
|
+
"""Build a minimal OSC message.
|
|
391
|
+
|
|
392
|
+
OSC addresses are ASCII-only command names.
|
|
393
|
+
String arguments are encoded into an ASCII-safe ``b64:...`` payload
|
|
394
|
+
and decoded back to UTF-8 in the Max bridge.
|
|
395
|
+
"""
|
|
396
|
+
# Address string — always ASCII (command names like "get_params")
|
|
348
397
|
addr_bytes = address.encode('ascii') + b'\x00'
|
|
349
398
|
while len(addr_bytes) % 4 != 0:
|
|
350
399
|
addr_bytes += b'\x00'
|
|
@@ -361,7 +410,8 @@ class M4LBridge:
|
|
|
361
410
|
arg_data += struct.pack('>f', arg)
|
|
362
411
|
elif isinstance(arg, str):
|
|
363
412
|
type_tags += "s"
|
|
364
|
-
|
|
413
|
+
encoded = _encode_string_arg(arg)
|
|
414
|
+
s_bytes = encoded.encode('ascii') + b'\x00'
|
|
365
415
|
while len(s_bytes) % 4 != 0:
|
|
366
416
|
s_bytes += b'\x00'
|
|
367
417
|
arg_data += s_bytes
|
package/mcp_server/server.py
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from contextlib import asynccontextmanager
|
|
4
4
|
import asyncio
|
|
5
5
|
import os
|
|
6
|
-
import signal
|
|
7
6
|
import subprocess
|
|
8
7
|
|
|
9
8
|
from fastmcp import FastMCP, Context # noqa: F401
|
|
@@ -12,12 +11,11 @@ from .connection import AbletonConnection
|
|
|
12
11
|
from .m4l_bridge import SpectralCache, SpectralReceiver, M4LBridge
|
|
13
12
|
|
|
14
13
|
|
|
15
|
-
def
|
|
16
|
-
"""
|
|
14
|
+
def _identify_port_holder(port: int) -> str | None:
|
|
15
|
+
"""Identify which process holds the given UDP port (for logging only).
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
.mcp.json launch the server).
|
|
17
|
+
Returns a string like "PID 12345 (python3 mcp_server)" or None if
|
|
18
|
+
identification fails. Never kills or modifies the holder.
|
|
21
19
|
"""
|
|
22
20
|
try:
|
|
23
21
|
out = subprocess.check_output(
|
|
@@ -29,18 +27,17 @@ def _kill_port_holder(port: int) -> None:
|
|
|
29
27
|
for pid_str in out.splitlines():
|
|
30
28
|
pid = int(pid_str)
|
|
31
29
|
if pid != my_pid:
|
|
32
|
-
# Only kill if it looks like a Python/LivePilot process
|
|
33
30
|
try:
|
|
34
31
|
cmdline = subprocess.check_output(
|
|
35
32
|
["ps", "-p", str(pid), "-o", "command="],
|
|
36
33
|
text=True, timeout=2,
|
|
37
34
|
).strip()
|
|
38
|
-
|
|
39
|
-
os.kill(pid, signal.SIGTERM)
|
|
35
|
+
return f"{pid} ({cmdline[:60]})"
|
|
40
36
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
41
|
-
|
|
37
|
+
return str(pid)
|
|
38
|
+
return None
|
|
42
39
|
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
|
43
|
-
|
|
40
|
+
return None
|
|
44
41
|
|
|
45
42
|
|
|
46
43
|
@asynccontextmanager
|
|
@@ -60,28 +57,18 @@ async def lifespan(server):
|
|
|
60
57
|
local_addr=('127.0.0.1', 9880),
|
|
61
58
|
)
|
|
62
59
|
except OSError:
|
|
63
|
-
# Port 9880 already bound —
|
|
64
|
-
#
|
|
65
|
-
# Kill the stale holder so this instance gets the port.
|
|
60
|
+
# Port 9880 already bound — another LivePilot instance is running.
|
|
61
|
+
# Do NOT kill the holder; degrade gracefully instead.
|
|
66
62
|
import sys
|
|
63
|
+
holder_info = _identify_port_holder(9880)
|
|
67
64
|
print(
|
|
68
|
-
"LivePilot: UDP port 9880 in use —
|
|
65
|
+
"LivePilot: UDP port 9880 already in use%s — "
|
|
66
|
+
"analyzer/bridge tools will be unavailable. "
|
|
67
|
+
"Stop the other LivePilot instance to enable them."
|
|
68
|
+
% (f" (PID {holder_info})" if holder_info else ""),
|
|
69
69
|
file=sys.stderr,
|
|
70
70
|
)
|
|
71
|
-
|
|
72
|
-
await asyncio.sleep(0.3)
|
|
73
|
-
try:
|
|
74
|
-
transport, _ = await loop.create_datagram_endpoint(
|
|
75
|
-
lambda: receiver,
|
|
76
|
-
local_addr=('127.0.0.1', 9880),
|
|
77
|
-
)
|
|
78
|
-
except OSError:
|
|
79
|
-
print(
|
|
80
|
-
"LivePilot: WARNING — could not bind UDP 9880, "
|
|
81
|
-
"analyzer tools will be unavailable",
|
|
82
|
-
file=sys.stderr,
|
|
83
|
-
)
|
|
84
|
-
transport = None
|
|
71
|
+
transport = None
|
|
85
72
|
|
|
86
73
|
try:
|
|
87
74
|
yield {
|
|
@@ -77,6 +77,25 @@ def _normalize_to_lufs(
|
|
|
77
77
|
return tmp_path
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# True-peak helper
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def _true_peak_dbtp(data: np.ndarray, sr: int) -> float:
|
|
85
|
+
"""Estimate EBU R128 true peak via 4x oversampling.
|
|
86
|
+
|
|
87
|
+
Uses scipy's resample_poly for phase-accurate upsampling,
|
|
88
|
+
then measures the absolute peak of the oversampled signal.
|
|
89
|
+
Returns the result in dBTP (decibels relative to true peak).
|
|
90
|
+
"""
|
|
91
|
+
from scipy.signal import resample_poly
|
|
92
|
+
|
|
93
|
+
# 4x oversample each channel independently
|
|
94
|
+
oversampled = resample_poly(data, up=4, down=1, axis=0)
|
|
95
|
+
peak_linear = float(np.max(np.abs(oversampled)))
|
|
96
|
+
return float(20.0 * np.log10(max(peak_linear, 1e-10)))
|
|
97
|
+
|
|
98
|
+
|
|
80
99
|
# ---------------------------------------------------------------------------
|
|
81
100
|
# compute_loudness
|
|
82
101
|
# ---------------------------------------------------------------------------
|
|
@@ -89,8 +108,8 @@ def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
|
|
|
89
108
|
detail: "summary" (default) or "full" (includes short_term_lufs array).
|
|
90
109
|
|
|
91
110
|
Returns:
|
|
92
|
-
dict with integrated_lufs, sample_peak_dbfs, rms_dbfs,
|
|
93
|
-
lra_lu, meets_streaming, and optionally short_term_lufs.
|
|
111
|
+
dict with integrated_lufs, true_peak_dbtp, sample_peak_dbfs, rms_dbfs,
|
|
112
|
+
crest_factor_db, lra_lu, meets_streaming, and optionally short_term_lufs.
|
|
94
113
|
"""
|
|
95
114
|
import pyloudnorm as pyln
|
|
96
115
|
|
|
@@ -110,12 +129,15 @@ def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
|
|
|
110
129
|
peak_linear = float(np.max(np.abs(data)))
|
|
111
130
|
sample_peak_dbfs = float(20.0 * np.log10(max(peak_linear, 1e-10)))
|
|
112
131
|
|
|
132
|
+
# True peak via 4x oversampling (EBU R128 compliant)
|
|
133
|
+
true_peak_dbtp = _true_peak_dbtp(data, sr)
|
|
134
|
+
|
|
113
135
|
# RMS dBFS
|
|
114
136
|
rms_linear = float(np.sqrt(np.mean(data ** 2)))
|
|
115
137
|
rms_dbfs = float(20.0 * np.log10(max(rms_linear, 1e-10)))
|
|
116
138
|
|
|
117
139
|
# Crest factor
|
|
118
|
-
crest_factor_db =
|
|
140
|
+
crest_factor_db = true_peak_dbtp - rms_dbfs
|
|
119
141
|
|
|
120
142
|
# Short-term LUFS (3s window, 1s hop) — also used for LRA
|
|
121
143
|
window_samples = int(sr * 3.0)
|
|
@@ -162,6 +184,7 @@ def compute_loudness(file_path: str, detail: str = "summary") -> dict[str, Any]:
|
|
|
162
184
|
|
|
163
185
|
result: dict[str, Any] = {
|
|
164
186
|
"integrated_lufs": round(integrated_lufs, 2),
|
|
187
|
+
"true_peak_dbtp": round(true_peak_dbtp, 2),
|
|
165
188
|
"sample_peak_dbfs": round(sample_peak_dbfs, 2),
|
|
166
189
|
"rms_dbfs": round(rms_dbfs, 2),
|
|
167
190
|
"crest_factor_db": round(crest_factor_db, 2),
|
|
@@ -35,19 +35,20 @@ PHRYGIAN_PROFILE = MAJOR_PROFILE[4:] + MAJOR_PROFILE[:4]
|
|
|
35
35
|
LYDIAN_PROFILE = MAJOR_PROFILE[5:] + MAJOR_PROFILE[:5]
|
|
36
36
|
MIXOLYDIAN_PROFILE = MAJOR_PROFILE[7:] + MAJOR_PROFILE[:7]
|
|
37
37
|
LOCRIAN_PROFILE = MAJOR_PROFILE[11:] + MAJOR_PROFILE[:11]
|
|
38
|
+
PHRYGIAN_DOMINANT_PROFILE = [6.35, 4.75, 1.75, 1.95, 4.9, 3.9, 1.8, 5.2, 3.6, 1.7, 3.4, 1.6]
|
|
38
39
|
|
|
39
40
|
MODE_PROFILES = {
|
|
40
41
|
'major': MAJOR_PROFILE, 'minor': MINOR_PROFILE,
|
|
41
42
|
'dorian': DORIAN_PROFILE, 'phrygian': PHRYGIAN_PROFILE,
|
|
42
43
|
'lydian': LYDIAN_PROFILE, 'mixolydian': MIXOLYDIAN_PROFILE,
|
|
43
|
-
'locrian': LOCRIAN_PROFILE,
|
|
44
|
+
'locrian': LOCRIAN_PROFILE, 'phrygian_dominant': PHRYGIAN_DOMINANT_PROFILE,
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
SCALES = {
|
|
47
48
|
'major': [0, 2, 4, 5, 7, 9, 11], 'minor': [0, 2, 3, 5, 7, 8, 10],
|
|
48
49
|
'dorian': [0, 2, 3, 5, 7, 9, 10], 'phrygian': [0, 1, 3, 5, 7, 8, 10],
|
|
49
50
|
'lydian': [0, 2, 4, 6, 7, 9, 11], 'mixolydian': [0, 2, 4, 5, 7, 9, 10],
|
|
50
|
-
'locrian': [0, 1, 3, 5, 6, 8, 10],
|
|
51
|
+
'locrian': [0, 1, 3, 5, 6, 8, 10], 'phrygian_dominant': [0, 1, 4, 5, 7, 8, 10],
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
TRIAD_QUALITIES = {
|
|
@@ -58,6 +59,25 @@ TRIAD_QUALITIES = {
|
|
|
58
59
|
'lydian': ['major', 'major', 'minor', 'diminished', 'major', 'minor', 'minor'],
|
|
59
60
|
'mixolydian': ['major', 'minor', 'diminished', 'major', 'minor', 'minor', 'major'],
|
|
60
61
|
'locrian': ['diminished', 'major', 'minor', 'minor', 'major', 'major', 'minor'],
|
|
62
|
+
'phrygian_dominant': ['major', 'major', 'diminished', 'minor', 'diminished', 'augmented', 'minor'],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
MODE_ALIASES = {
|
|
66
|
+
'major': 'major',
|
|
67
|
+
'ionian': 'major',
|
|
68
|
+
'maj': 'major',
|
|
69
|
+
'minor': 'minor',
|
|
70
|
+
'aeolian': 'minor',
|
|
71
|
+
'min': 'minor',
|
|
72
|
+
'dorian': 'dorian',
|
|
73
|
+
'phrygian': 'phrygian',
|
|
74
|
+
'lydian': 'lydian',
|
|
75
|
+
'mixolydian': 'mixolydian',
|
|
76
|
+
'locrian': 'locrian',
|
|
77
|
+
'phrygian dominant': 'phrygian_dominant',
|
|
78
|
+
'phrygian_dominant': 'phrygian_dominant',
|
|
79
|
+
'phrygian-dominant': 'phrygian_dominant',
|
|
80
|
+
'hijaz': 'phrygian_dominant',
|
|
61
81
|
}
|
|
62
82
|
|
|
63
83
|
CHORD_PATTERNS = {
|
|
@@ -81,6 +101,15 @@ def pitch_name(midi: int) -> str:
|
|
|
81
101
|
return NOTE_NAMES[midi % 12] + str(midi // 12 - 1)
|
|
82
102
|
|
|
83
103
|
|
|
104
|
+
def _normalize_mode_name(mode: str) -> str:
|
|
105
|
+
"""Normalize user-facing mode names into canonical scale ids."""
|
|
106
|
+
normalized = " ".join(mode.strip().lower().replace("_", " ").replace("-", " ").split())
|
|
107
|
+
canonical = MODE_ALIASES.get(normalized, normalized.replace(" ", "_"))
|
|
108
|
+
if canonical not in SCALES:
|
|
109
|
+
raise ValueError(f"Unknown mode: {mode}")
|
|
110
|
+
return canonical
|
|
111
|
+
|
|
112
|
+
|
|
84
113
|
def parse_key(key_str: str) -> dict:
|
|
85
114
|
"""Parse key string -> {tonic: 0-11, tonic_name: str, mode: str}.
|
|
86
115
|
|
|
@@ -100,7 +129,8 @@ def parse_key(key_str: str) -> dict:
|
|
|
100
129
|
|
|
101
130
|
parts = s.split()
|
|
102
131
|
raw_tonic = parts[0]
|
|
103
|
-
|
|
132
|
+
raw_mode = " ".join(parts[1:]) if len(parts) > 1 else 'major'
|
|
133
|
+
mode = _normalize_mode_name(raw_mode)
|
|
104
134
|
|
|
105
135
|
# Normalize tonic: capitalize first letter
|
|
106
136
|
tonic_name = raw_tonic[0].upper() + raw_tonic[1:]
|
|
@@ -117,17 +147,18 @@ def parse_key(key_str: str) -> dict:
|
|
|
117
147
|
|
|
118
148
|
def get_scale_pitches(tonic: int, mode: str) -> list[int]:
|
|
119
149
|
"""Return pitch classes (0-11) for the scale."""
|
|
120
|
-
intervals = SCALES
|
|
150
|
+
intervals = SCALES[_normalize_mode_name(mode)]
|
|
121
151
|
return [(tonic + iv) % 12 for iv in intervals]
|
|
122
152
|
|
|
123
153
|
|
|
124
154
|
def build_chord(degree: int, tonic: int, mode: str) -> dict:
|
|
125
155
|
"""Build triad from scale degree (0-indexed)."""
|
|
156
|
+
mode = _normalize_mode_name(mode)
|
|
126
157
|
scale = get_scale_pitches(tonic, mode)
|
|
127
158
|
root = scale[degree % 7]
|
|
128
159
|
third = scale[(degree + 2) % 7]
|
|
129
160
|
fifth = scale[(degree + 4) % 7]
|
|
130
|
-
quality = TRIAD_QUALITIES
|
|
161
|
+
quality = TRIAD_QUALITIES[mode][degree % 7]
|
|
131
162
|
return {
|
|
132
163
|
"root_pc": root,
|
|
133
164
|
"pitch_classes": [root, third, fifth],
|
|
@@ -10,7 +10,7 @@ import os
|
|
|
10
10
|
|
|
11
11
|
from fastmcp import Context
|
|
12
12
|
|
|
13
|
-
from ..server import mcp
|
|
13
|
+
from ..server import mcp, _identify_port_holder
|
|
14
14
|
|
|
15
15
|
CAPTURE_DIR = os.path.expanduser("~/Documents/LivePilot/captures")
|
|
16
16
|
|
|
@@ -19,7 +19,10 @@ def _get_spectral(ctx: Context):
|
|
|
19
19
|
"""Get SpectralCache from lifespan context."""
|
|
20
20
|
cache = ctx.lifespan_context.get("spectral")
|
|
21
21
|
if not cache:
|
|
22
|
-
raise
|
|
22
|
+
raise ValueError("Spectral cache not initialized — restart the MCP server")
|
|
23
|
+
# Keep the active request context attached so analyzer error paths can
|
|
24
|
+
# distinguish "device missing" from "bridge disconnected".
|
|
25
|
+
setattr(cache, "_livepilot_ctx", ctx)
|
|
23
26
|
return cache
|
|
24
27
|
|
|
25
28
|
|
|
@@ -27,13 +30,46 @@ def _get_m4l(ctx: Context):
|
|
|
27
30
|
"""Get M4LBridge from lifespan context."""
|
|
28
31
|
bridge = ctx.lifespan_context.get("m4l")
|
|
29
32
|
if not bridge:
|
|
30
|
-
raise
|
|
33
|
+
raise ValueError("M4L bridge not initialized — restart the MCP server")
|
|
31
34
|
return bridge
|
|
32
35
|
|
|
33
36
|
|
|
34
37
|
def _require_analyzer(cache) -> None:
|
|
35
38
|
"""Raise a helpful error if the analyzer is not connected."""
|
|
36
39
|
if not cache.is_connected:
|
|
40
|
+
ctx = getattr(cache, "_livepilot_ctx", None)
|
|
41
|
+
try:
|
|
42
|
+
track = (
|
|
43
|
+
ctx.lifespan_context["ableton"].send_command("get_master_track")
|
|
44
|
+
if ctx else {}
|
|
45
|
+
)
|
|
46
|
+
except Exception:
|
|
47
|
+
track = {}
|
|
48
|
+
|
|
49
|
+
devices = track.get("devices", []) if isinstance(track, dict) else []
|
|
50
|
+
analyzer_loaded = False
|
|
51
|
+
for device in devices:
|
|
52
|
+
normalized = " ".join(
|
|
53
|
+
str(device.get("name") or "").replace("_", " ").replace("-", " ").lower().split()
|
|
54
|
+
)
|
|
55
|
+
if normalized == "livepilot analyzer":
|
|
56
|
+
analyzer_loaded = True
|
|
57
|
+
break
|
|
58
|
+
|
|
59
|
+
if analyzer_loaded:
|
|
60
|
+
holder = _identify_port_holder(9880)
|
|
61
|
+
detail = (
|
|
62
|
+
"LivePilot Analyzer is loaded on the master track, but its UDP bridge is not connected. "
|
|
63
|
+
)
|
|
64
|
+
if holder:
|
|
65
|
+
detail += (
|
|
66
|
+
"UDP port 9880 is currently held by another LivePilot instance "
|
|
67
|
+
f"({holder}). Close the other client/server, then retry."
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
detail += "Reload the analyzer device or restart the MCP server."
|
|
71
|
+
raise ValueError(detail)
|
|
72
|
+
|
|
37
73
|
raise ValueError(
|
|
38
74
|
"LivePilot Analyzer not detected. "
|
|
39
75
|
"Drag 'LivePilot Analyzer' onto the master track from "
|
|
@@ -140,7 +176,7 @@ async def get_hidden_parameters(
|
|
|
140
176
|
cache = _get_spectral(ctx)
|
|
141
177
|
_require_analyzer(cache)
|
|
142
178
|
bridge = _get_m4l(ctx)
|
|
143
|
-
return await bridge.send_command("get_hidden_params", track_index, device_index)
|
|
179
|
+
return await bridge.send_command("get_hidden_params", track_index, device_index, timeout=15.0)
|
|
144
180
|
|
|
145
181
|
|
|
146
182
|
@mcp.tool()
|
|
@@ -161,7 +197,7 @@ async def get_automation_state(
|
|
|
161
197
|
cache = _get_spectral(ctx)
|
|
162
198
|
_require_analyzer(cache)
|
|
163
199
|
bridge = _get_m4l(ctx)
|
|
164
|
-
return await bridge.send_command("get_auto_state", track_index, device_index)
|
|
200
|
+
return await bridge.send_command("get_auto_state", track_index, device_index, timeout=10.0)
|
|
165
201
|
|
|
166
202
|
|
|
167
203
|
@mcp.tool()
|
|
@@ -267,25 +303,34 @@ async def load_sample_to_simpler(
|
|
|
267
303
|
|
|
268
304
|
# Step 1: Load a sample from the browser to create Simpler with content
|
|
269
305
|
ableton = ctx.lifespan_context["ableton"]
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
306
|
+
try:
|
|
307
|
+
search = ableton.send_command("search_browser", {
|
|
308
|
+
"path": "samples",
|
|
309
|
+
"name_filter": "kick",
|
|
310
|
+
"loadable_only": True,
|
|
311
|
+
"max_results": 1,
|
|
312
|
+
})
|
|
313
|
+
except Exception as exc:
|
|
314
|
+
return {"error": f"Browser search failed: {exc}"}
|
|
276
315
|
results = search.get("results", [])
|
|
277
316
|
if not results:
|
|
278
317
|
return {"error": "No samples found in browser to bootstrap Simpler"}
|
|
279
318
|
|
|
280
319
|
# Load the dummy sample — Ableton auto-creates Simpler
|
|
281
320
|
uri = results[0]["uri"]
|
|
282
|
-
|
|
283
|
-
"
|
|
284
|
-
|
|
285
|
-
|
|
321
|
+
try:
|
|
322
|
+
ableton.send_command("load_browser_item", {
|
|
323
|
+
"track_index": track_index,
|
|
324
|
+
"uri": uri,
|
|
325
|
+
})
|
|
326
|
+
except Exception as exc:
|
|
327
|
+
return {"error": f"Failed to load bootstrap sample: {exc}"}
|
|
286
328
|
|
|
287
329
|
# Step 2: Find the newly created device (it's at the end of the chain)
|
|
288
|
-
|
|
330
|
+
try:
|
|
331
|
+
track_info = ableton.send_command("get_track_info", {"track_index": track_index})
|
|
332
|
+
except Exception as exc:
|
|
333
|
+
return {"error": f"Failed to read track after loading sample: {exc}"}
|
|
289
334
|
actual_device_index = len(track_info.get("devices", [])) - 1
|
|
290
335
|
if actual_device_index < 0:
|
|
291
336
|
actual_device_index = 0
|
|
@@ -515,7 +560,7 @@ async def get_display_values(
|
|
|
515
560
|
cache = _get_spectral(ctx)
|
|
516
561
|
_require_analyzer(cache)
|
|
517
562
|
bridge = _get_m4l(ctx)
|
|
518
|
-
return await bridge.send_command("get_display_values", track_index, device_index)
|
|
563
|
+
return await bridge.send_command("get_display_values", track_index, device_index, timeout=15.0)
|
|
519
564
|
|
|
520
565
|
|
|
521
566
|
# ── Phase 3: Audio Capture ─────────────────────────────────────────────
|
|
@@ -22,7 +22,10 @@ def _get_ableton(ctx: Context):
|
|
|
22
22
|
def _ensure_list(v: Any) -> list:
|
|
23
23
|
if isinstance(v, str):
|
|
24
24
|
import json
|
|
25
|
-
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(v)
|
|
27
|
+
except json.JSONDecodeError as exc:
|
|
28
|
+
raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
|
|
26
29
|
if isinstance(v, list):
|
|
27
30
|
return v
|
|
28
31
|
return [v]
|