livepilot 1.9.18 → 1.9.20
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 +3 -3
- package/CHANGELOG.md +31 -0
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +5 -3
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/server.py +15 -5
- package/mcp_server/tools/_harmony_engine.py +36 -2
- package/mcp_server/tools/_theory_engine.py +92 -35
- package/mcp_server/tools/analyzer.py +36 -0
- package/mcp_server/tools/browser.py +10 -3
- package/mcp_server/tools/research.py +23 -13
- package/mcp_server/tools/theory.py +47 -7
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +8 -6
- package/remote_script/LivePilot/mixing.py +12 -5
- package/remote_script/LivePilot/router.py +3 -0
- package/remote_script/LivePilot/tracks.py +27 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
|
3
3
|
"name": "dreamrec-LivePilot",
|
|
4
|
-
"description": "Agentic MCP production system for Ableton Live 12 —
|
|
4
|
+
"description": "Agentic MCP production system for Ableton Live 12 — 237 tools, 32 domains",
|
|
5
5
|
"owner": {
|
|
6
6
|
"name": "dreamrec",
|
|
7
7
|
"email": "dreamrec@users.noreply.github.com"
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "livepilot",
|
|
12
|
-
"description": "Agentic production system for Ableton Live 12 —
|
|
13
|
-
"version": "1.9.
|
|
12
|
+
"description": "Agentic production system for Ableton Live 12 — 237 tools, 32 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
13
|
+
"version": "1.9.20",
|
|
14
14
|
"author": {
|
|
15
15
|
"name": "Pilot Studio"
|
|
16
16
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.20 — Deep Production Test Pass (April 2026)
|
|
4
|
+
|
|
5
|
+
### New Tool
|
|
6
|
+
- **feat(analyzer):** `reconnect_bridge` — rebind UDP 9880 mid-session after port conflict clears, without restarting the MCP server
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
- **fix(arrangement):** `set_arrangement_automation` now returns `STATE_ERROR` (not `INVALID_PARAM`) with clear workaround suggestions for the known Live API limitation
|
|
10
|
+
- **fix(router):** added `RuntimeError` → `STATE_ERROR` mapping so state-related errors don't masquerade as parameter validation failures
|
|
11
|
+
- **fix(browser):** `load_browser_item` now accepts negative track_index (-1/-2 for returns, -1000 for master) — was incorrectly rejected by MCP-side validator
|
|
12
|
+
- **fix(tracks):** `set_track_name` on return tracks strips auto-prefix letter to prevent doubling (e.g. "C-Resonators" stays as-is, not "C-C-Resonators")
|
|
13
|
+
- **fix(theory):** `suggest_next_chord` now uses mode-aware progression maps — separate major/minor chord tables for common_practice, jazz, modal, and pop styles
|
|
14
|
+
- **fix(research):** `research_technique` now searches instruments, audio_effects, AND drums categories (was instruments-only); deep scope notes that web search is agent-layer responsibility
|
|
15
|
+
- **fix(server):** improved port competition error messages — directs users to `reconnect_bridge` tool instead of requiring server restart
|
|
16
|
+
- **fix(analyzer):** M4L Analyzer User Library copy synced to latest version (presentation mode enabled, bridge JS updated)
|
|
17
|
+
|
|
18
|
+
### Documentation
|
|
19
|
+
- **docs(skill):** added "Volume reset on scene fire" and "M4L Analyzer auto-load" to error handling protocol in livepilot-core skill
|
|
20
|
+
- **docs(CLAUDE.md):** tool count updated from 236 to 237
|
|
21
|
+
|
|
22
|
+
## 1.9.19 — Theory Engine & Meters Fix Pass (April 2026)
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
- **fix(mixing):** `get_track_meters` crashed on tracks with MIDI-only output — now guards `output_meter_*` with `has_audio_output` check
|
|
26
|
+
- **fix(mixing):** `get_mix_snapshot` same crash on MIDI-output tracks — same guard applied
|
|
27
|
+
- **fix(tracks):** `create_midi_track` / `create_audio_track` left newly created tracks armed — now auto-disarms unless `arm=true` param is passed
|
|
28
|
+
- **fix(theory):** `roman_numeral()` failed to recognize 7th chords (Dm7, Gm7, Bbmaj7) — now detects 7th intervals via triad-subset matching with scored best-match selection
|
|
29
|
+
- **fix(theory):** `roman_figure_to_pitches()` produced out-of-key pitches (C#, G#) for jazz figures in minor keys — now uses scale-derived chord quality and scale-derived 7th intervals instead of forcing quality from Roman numeral case
|
|
30
|
+
- **fix(harmony):** `parse_chord()` rejected "minor seventh", "dominant seventh" and other extended chord qualities — now normalizes to base triad for neo-Riemannian analysis
|
|
31
|
+
- **fix(harmony):** `classify_transform_sequence()` only detected single P/L/R transforms — now tries 2-step compound transforms (PL, PR, RL, etc.)
|
|
32
|
+
- **fix(theory):** `roman_numeral()` picked wrong chord when 7th created ambiguity (Bbmaj7 matched as Dm instead of Bb) — scoring prefers highest overlap + root-position bonus
|
|
33
|
+
|
|
3
34
|
## 1.9.18 — Deep Audit Fix Pass (April 2026)
|
|
4
35
|
|
|
5
36
|
### Critical Fixes
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.9.
|
|
4
|
-
"description": "Agentic production system for Ableton Live 12 —
|
|
3
|
+
"version": "1.9.20",
|
|
4
|
+
"description": "Agentic production system for Ableton Live 12 — 237 tools, 32 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Pilot Studio"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.9.
|
|
4
|
-
"description": "Agentic production system for Ableton Live 12 —
|
|
3
|
+
"version": "1.9.20",
|
|
4
|
+
"description": "Agentic production system for Ableton Live 12 — 237 tools, 32 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Pilot Studio"
|
|
7
7
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: livepilot-core
|
|
3
|
-
description: Core discipline for LivePilot — agentic production system for Ableton Live 12.
|
|
3
|
+
description: Core discipline for LivePilot — agentic production system for Ableton Live 12. 237 tools across 32 domains. This skill should be used whenever working with Ableton Live through MCP tools. Provides golden rules, tool speed tiers, error handling protocol, and pointers to domain and engine skills.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# LivePilot Core — Ableton Live 12
|
|
7
7
|
|
|
8
|
-
Agentic production system for Ableton Live 12.
|
|
8
|
+
Agentic production system for Ableton Live 12. 237 tools across 32 domains, three layers:
|
|
9
9
|
|
|
10
10
|
- **Device Atlas** — 280+ instruments, 139 drum kits, 350+ impulse responses. Consult `references/device-atlas/` before loading any device. Never guess a device name.
|
|
11
11
|
- **M4L Analyzer** — Real-time audio analysis on the master bus (8-band spectrum, RMS/peak, key detection). Optional — all core tools work without it.
|
|
@@ -54,6 +54,8 @@ Report ALL errors to the user immediately. Common failure modes:
|
|
|
54
54
|
- **Empty Drum Rack** — bare rack = silence → always load a kit preset
|
|
55
55
|
- **M4L bridge timeout** — device may be busy or removed → retry or skip analyzer features
|
|
56
56
|
- **Connection timeout** — Ableton unresponsive → check if session is heavy
|
|
57
|
+
- **Volume reset on scene fire** — Ableton restores mixer state when firing scenes. Always re-apply `set_track_volume`/`set_track_pan` after `fire_scene` if your mix settings differ from what was stored in the clips
|
|
58
|
+
- **M4L Analyzer not connected** — if `get_master_spectrum` errors with "Analyzer not detected", auto-load it: `find_and_load_device(track_index=-1000, device_name="LivePilot_Analyzer")`. If it errors with "UDP bridge not connected", try `reconnect_bridge` first
|
|
57
59
|
|
|
58
60
|
## Technique Memory
|
|
59
61
|
|
|
@@ -91,7 +93,7 @@ Deep production knowledge in `references/`:
|
|
|
91
93
|
|
|
92
94
|
| File | Content |
|
|
93
95
|
|------|---------|
|
|
94
|
-
| `references/overview.md` | All
|
|
96
|
+
| `references/overview.md` | All 237 tools with params and ranges |
|
|
95
97
|
| `references/device-atlas/` | 280+ device corpus with URIs and presets |
|
|
96
98
|
| `references/midi-recipes.md` | Drum patterns, chord voicings, humanization |
|
|
97
99
|
| `references/sound-design.md` | Synth recipes, device chain patterns |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# LivePilot v1.9.
|
|
1
|
+
# LivePilot v1.9.20 — Architecture & Tool Reference
|
|
2
2
|
|
|
3
|
-
Agentic production system for Ableton Live 12.
|
|
3
|
+
Agentic production system for Ableton Live 12. 237 tools across 32 domains. Device atlas (280+ devices), spectral perception (M4L analyzer), technique memory, automation intelligence (16 curve types, 15 recipes), music theory (Krumhansl-Schmuckler, species counterpoint), generative algorithms (Euclidean rhythm, tintinnabuli, phase shift, additive process), neo-Riemannian harmony (PRL transforms, Tonnetz), MIDI file I/O.
|
|
4
4
|
|
|
5
5
|
## Architecture
|
|
6
6
|
|
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.20"
|
package/mcp_server/server.py
CHANGED
|
@@ -58,27 +58,37 @@ async def lifespan(server):
|
|
|
58
58
|
)
|
|
59
59
|
except OSError:
|
|
60
60
|
# Port 9880 already bound — another LivePilot instance is running.
|
|
61
|
-
#
|
|
61
|
+
# Degrade gracefully. The reconnect_bridge tool can retry later
|
|
62
|
+
# if the other instance is stopped.
|
|
62
63
|
import sys
|
|
63
64
|
holder_info = _identify_port_holder(9880)
|
|
64
65
|
print(
|
|
65
66
|
"LivePilot: UDP port 9880 already in use%s — "
|
|
66
|
-
"analyzer/bridge tools
|
|
67
|
-
"
|
|
67
|
+
"analyzer/bridge tools unavailable at startup. "
|
|
68
|
+
"Use the reconnect_bridge tool after stopping the other instance, "
|
|
69
|
+
"or restart this server."
|
|
68
70
|
% (f" (PID {holder_info})" if holder_info else ""),
|
|
69
71
|
file=sys.stderr,
|
|
70
72
|
)
|
|
71
73
|
transport = None
|
|
72
74
|
|
|
75
|
+
# Store transport + loop so tools can attempt reconnection mid-session
|
|
76
|
+
bridge_state = {
|
|
77
|
+
"transport": transport,
|
|
78
|
+
"loop": loop,
|
|
79
|
+
"receiver": receiver,
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
try:
|
|
74
83
|
yield {
|
|
75
84
|
"ableton": ableton,
|
|
76
85
|
"spectral": spectral,
|
|
77
86
|
"m4l": m4l,
|
|
87
|
+
"_bridge_state": bridge_state,
|
|
78
88
|
}
|
|
79
89
|
finally:
|
|
80
|
-
if transport:
|
|
81
|
-
transport.close()
|
|
90
|
+
if bridge_state["transport"]:
|
|
91
|
+
bridge_state["transport"].close()
|
|
82
92
|
m4l.close()
|
|
83
93
|
ableton.disconnect()
|
|
84
94
|
|
|
@@ -29,8 +29,26 @@ def chord_to_str(root_pc: int, quality: str) -> str:
|
|
|
29
29
|
def parse_chord(chord_str: str) -> tuple[int, str]:
|
|
30
30
|
"""Parse 'C major' → (0, 'major'), 'F# minor' → (6, 'minor').
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Also handles 7th chord qualities by reducing to base triad:
|
|
33
|
+
'D minor seventh' → (2, 'minor'), 'G dominant seventh' → (7, 'major').
|
|
34
|
+
Neo-Riemannian transforms operate on triads, so we strip extensions.
|
|
33
35
|
"""
|
|
36
|
+
# Normalize extended chord quality names to base triad
|
|
37
|
+
s = chord_str.strip()
|
|
38
|
+
_QUALITY_MAP = {
|
|
39
|
+
"minor seventh": "minor", "minor 7th": "minor", "minor7": "minor",
|
|
40
|
+
"major seventh": "major", "major 7th": "major", "major7": "major",
|
|
41
|
+
"dominant seventh": "major", "dominant 7th": "major", "dominant7": "major",
|
|
42
|
+
"diminished seventh": "minor", "diminished 7th": "minor",
|
|
43
|
+
"half-diminished seventh": "minor", "half-diminished": "minor",
|
|
44
|
+
}
|
|
45
|
+
for ext, base in _QUALITY_MAP.items():
|
|
46
|
+
if ext in s.lower():
|
|
47
|
+
# Extract root (everything before the quality)
|
|
48
|
+
idx = s.lower().index(ext)
|
|
49
|
+
root = s[:idx].strip() or s.split()[0]
|
|
50
|
+
return (engine.parse_key(f"{root} {base}")["tonic"], base)
|
|
51
|
+
|
|
34
52
|
parsed = engine.parse_key(chord_str)
|
|
35
53
|
mode = parsed["mode"]
|
|
36
54
|
if mode not in ("major", "minor"):
|
|
@@ -177,14 +195,30 @@ def find_shortest_path(
|
|
|
177
195
|
# ---------------------------------------------------------------------------
|
|
178
196
|
|
|
179
197
|
def classify_transform_sequence(chords: list[tuple[int, str]]) -> list[str]:
|
|
180
|
-
"""Identify the PRL transform between each consecutive pair of chords.
|
|
198
|
+
"""Identify the PRL transform between each consecutive pair of chords.
|
|
199
|
+
|
|
200
|
+
Tries single transforms (P, L, R) first, then 2-step compound
|
|
201
|
+
transforms (PL, PR, LP, LR, RP, RL) for richer classification.
|
|
202
|
+
"""
|
|
203
|
+
_COMPOUNDS = ["PL", "PR", "LP", "LR", "RP", "RL",
|
|
204
|
+
"PP", "LL", "RR"]
|
|
181
205
|
result = []
|
|
182
206
|
for i in range(len(chords) - 1):
|
|
183
207
|
found = "?"
|
|
208
|
+
# Try single transforms first
|
|
184
209
|
for label, fn in TRANSFORMS.items():
|
|
185
210
|
if fn(*chords[i]) == chords[i + 1]:
|
|
186
211
|
found = label
|
|
187
212
|
break
|
|
213
|
+
# Try 2-step compound transforms
|
|
214
|
+
if found == "?":
|
|
215
|
+
for compound in _COMPOUNDS:
|
|
216
|
+
try:
|
|
217
|
+
if apply_transforms(*chords[i], compound) == chords[i + 1]:
|
|
218
|
+
found = compound
|
|
219
|
+
break
|
|
220
|
+
except (ValueError, KeyError):
|
|
221
|
+
continue
|
|
188
222
|
result.append(found)
|
|
189
223
|
return result
|
|
190
224
|
|
|
@@ -231,33 +231,73 @@ def chord_name(midi_pitches: list[int]) -> str:
|
|
|
231
231
|
|
|
232
232
|
|
|
233
233
|
def roman_numeral(chord_pcs: list[int], tonic: int, mode: str) -> dict:
|
|
234
|
-
"""Match chord pitch classes -> Roman numeral figure.
|
|
234
|
+
"""Match chord pitch classes -> Roman numeral figure.
|
|
235
|
+
|
|
236
|
+
Recognizes triads and 7th chords by checking if the input contains
|
|
237
|
+
a scale-degree triad, then detecting the 7th (if any).
|
|
238
|
+
"""
|
|
235
239
|
pcs_set = set(pc % 12 for pc in chord_pcs)
|
|
236
240
|
bass_pc = chord_pcs[0] % 12 if chord_pcs else 0
|
|
237
241
|
|
|
238
242
|
best = {"figure": "?", "quality": "unknown", "degree": 0,
|
|
239
243
|
"inversion": 0, "root_name": NOTE_NAMES[tonic]}
|
|
244
|
+
best_score = -1
|
|
240
245
|
|
|
241
246
|
for degree in range(7):
|
|
242
247
|
triad = build_chord(degree, tonic, mode)
|
|
243
248
|
triad_set = set(triad["pitch_classes"])
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
249
|
+
# Match: exact triad, triad is subset of input (7th chord),
|
|
250
|
+
# or input is subset of triad (power chord / omitted note)
|
|
251
|
+
if not (pcs_set == triad_set or triad_set.issubset(pcs_set)
|
|
252
|
+
or pcs_set.issubset(triad_set)):
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
# Score: prefer matches with more overlap and bass-note match
|
|
256
|
+
overlap = len(pcs_set & triad_set)
|
|
257
|
+
score = overlap * 10
|
|
258
|
+
if bass_pc == triad["root_pc"]:
|
|
259
|
+
score += 5 # root position bonus
|
|
260
|
+
|
|
261
|
+
if score <= best_score:
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
quality = triad["quality"]
|
|
265
|
+
label = ROMAN_LABELS[degree]
|
|
266
|
+
if quality in ("minor", "diminished"):
|
|
267
|
+
label = label.lower()
|
|
268
|
+
if quality == "diminished":
|
|
269
|
+
label += "\u00b0"
|
|
270
|
+
|
|
271
|
+
# Detect 7th: extra pitch class beyond the triad
|
|
272
|
+
extra_pcs = pcs_set - triad_set
|
|
273
|
+
if extra_pcs:
|
|
274
|
+
seventh_interval = (list(extra_pcs)[0] - triad["root_pc"]) % 12
|
|
275
|
+
if seventh_interval == 10: # minor/dominant 7th
|
|
276
|
+
label += "7"
|
|
277
|
+
if quality == "diminished":
|
|
278
|
+
quality = "half-diminished seventh"
|
|
279
|
+
elif quality == "minor":
|
|
280
|
+
quality = "minor seventh"
|
|
281
|
+
else:
|
|
282
|
+
quality = "dominant seventh"
|
|
283
|
+
elif seventh_interval == 11: # major 7th
|
|
284
|
+
label += "maj7"
|
|
285
|
+
quality = "major seventh"
|
|
286
|
+
elif seventh_interval == 9: # diminished 7th
|
|
287
|
+
label += "o7"
|
|
288
|
+
quality = "diminished seventh"
|
|
289
|
+
|
|
290
|
+
# Detect inversion
|
|
291
|
+
inv = 0
|
|
292
|
+
if bass_pc != triad["root_pc"]:
|
|
293
|
+
if bass_pc == triad["pitch_classes"][1]:
|
|
294
|
+
inv = 1
|
|
295
|
+
elif bass_pc == triad["pitch_classes"][2]:
|
|
296
|
+
inv = 2
|
|
297
|
+
|
|
298
|
+
best = {"figure": label, "quality": quality, "degree": degree,
|
|
299
|
+
"inversion": inv, "root_name": triad["root_name"]}
|
|
300
|
+
best_score = score
|
|
261
301
|
|
|
262
302
|
return best
|
|
263
303
|
|
|
@@ -302,31 +342,48 @@ def roman_figure_to_pitches(figure: str, tonic: int, mode: str) -> dict:
|
|
|
302
342
|
chord = build_chord(degree, tonic, mode)
|
|
303
343
|
root_pc = (chord["root_pc"] + chromatic_shift) % 12
|
|
304
344
|
|
|
305
|
-
# Build pitch classes based on quality
|
|
306
|
-
|
|
307
|
-
|
|
345
|
+
# Build pitch classes based on quality.
|
|
346
|
+
# When there's no chromatic alteration, use scale-derived quality so
|
|
347
|
+
# that e.g. "vi7" in D minor correctly yields Bb major 7th, not Bb minor.
|
|
348
|
+
# Only force minor from case when there's an explicit accidental.
|
|
349
|
+
if chromatic_shift != 0 and is_minor_quality:
|
|
308
350
|
quality = "minor"
|
|
351
|
+
elif chromatic_shift != 0 and not is_minor_quality:
|
|
352
|
+
quality = "major"
|
|
309
353
|
else:
|
|
310
|
-
# Use scale-derived quality
|
|
311
354
|
quality = chord["quality"]
|
|
312
|
-
if quality == "minor":
|
|
313
|
-
pcs = [root_pc, (root_pc + 3) % 12, (root_pc + 7) % 12]
|
|
314
|
-
elif quality == "diminished":
|
|
315
|
-
pcs = [root_pc, (root_pc + 3) % 12, (root_pc + 6) % 12]
|
|
316
|
-
elif quality == "augmented":
|
|
317
|
-
pcs = [root_pc, (root_pc + 4) % 12, (root_pc + 8) % 12]
|
|
318
|
-
else:
|
|
319
|
-
pcs = [root_pc, (root_pc + 4) % 12, (root_pc + 7) % 12]
|
|
320
355
|
|
|
321
|
-
|
|
356
|
+
if quality == "minor":
|
|
357
|
+
pcs = [root_pc, (root_pc + 3) % 12, (root_pc + 7) % 12]
|
|
358
|
+
elif quality == "diminished":
|
|
359
|
+
pcs = [root_pc, (root_pc + 3) % 12, (root_pc + 6) % 12]
|
|
360
|
+
elif quality == "augmented":
|
|
361
|
+
pcs = [root_pc, (root_pc + 4) % 12, (root_pc + 8) % 12]
|
|
362
|
+
else:
|
|
363
|
+
pcs = [root_pc, (root_pc + 4) % 12, (root_pc + 7) % 12]
|
|
364
|
+
|
|
365
|
+
# Handle suffix — derive 7th from the scale when possible
|
|
322
366
|
suffix = remaining.lower()
|
|
323
367
|
if suffix == "7":
|
|
324
|
-
|
|
368
|
+
# Use scale-derived 7th: pitch class a diatonic 7th above the root
|
|
369
|
+
scale = get_scale_pitches(tonic, mode)
|
|
370
|
+
seventh_degree = (degree + 6) % 7 # 7th of the chord = 6 steps up
|
|
371
|
+
seventh = scale[seventh_degree]
|
|
372
|
+
seventh_interval = (seventh - root_pc) % 12
|
|
325
373
|
pcs.append(seventh)
|
|
326
|
-
if
|
|
327
|
-
quality = "
|
|
374
|
+
if seventh_interval == 11:
|
|
375
|
+
quality = "major seventh"
|
|
376
|
+
elif seventh_interval == 10:
|
|
377
|
+
if quality == "diminished":
|
|
378
|
+
quality = "half-diminished seventh"
|
|
379
|
+
elif quality == "minor":
|
|
380
|
+
quality = "minor seventh"
|
|
381
|
+
else:
|
|
382
|
+
quality = "dominant seventh"
|
|
383
|
+
elif seventh_interval == 9:
|
|
384
|
+
quality = "diminished seventh"
|
|
328
385
|
else:
|
|
329
|
-
quality = "dominant seventh"
|
|
386
|
+
quality = "minor seventh" if quality == "minor" else "dominant seventh"
|
|
330
387
|
elif suffix == "o7":
|
|
331
388
|
seventh = (root_pc + 9) % 12 # diminished 7th
|
|
332
389
|
pcs.append(seventh)
|
|
@@ -77,6 +77,42 @@ def _require_analyzer(cache) -> None:
|
|
|
77
77
|
)
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
async def reconnect_bridge(ctx: Context) -> dict:
|
|
82
|
+
"""Attempt to reconnect the M4L UDP bridge (port 9880).
|
|
83
|
+
|
|
84
|
+
Use this when the bridge was unavailable at server startup (port
|
|
85
|
+
conflict) but is now free. Binds the UDP listener so spectral
|
|
86
|
+
analysis and bridge commands become available without restarting
|
|
87
|
+
the MCP server.
|
|
88
|
+
"""
|
|
89
|
+
import asyncio
|
|
90
|
+
|
|
91
|
+
bridge_state = ctx.lifespan_context.get("_bridge_state")
|
|
92
|
+
if not bridge_state:
|
|
93
|
+
return {"error": "Bridge state not available — restart the MCP server"}
|
|
94
|
+
|
|
95
|
+
if bridge_state["transport"] is not None:
|
|
96
|
+
return {"ok": True, "message": "Bridge already connected on UDP 9880"}
|
|
97
|
+
|
|
98
|
+
loop = bridge_state["loop"]
|
|
99
|
+
receiver = bridge_state["receiver"]
|
|
100
|
+
try:
|
|
101
|
+
transport, _ = await loop.create_datagram_endpoint(
|
|
102
|
+
lambda: receiver,
|
|
103
|
+
local_addr=('127.0.0.1', 9880),
|
|
104
|
+
)
|
|
105
|
+
bridge_state["transport"] = transport
|
|
106
|
+
return {"ok": True, "message": "Bridge reconnected on UDP 9880"}
|
|
107
|
+
except OSError:
|
|
108
|
+
holder = _identify_port_holder(9880)
|
|
109
|
+
return {
|
|
110
|
+
"ok": False,
|
|
111
|
+
"error": f"UDP port 9880 still in use{f' (PID {holder})' if holder else ''}. "
|
|
112
|
+
"Close the other LivePilot instance first.",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
80
116
|
@mcp.tool()
|
|
81
117
|
def get_master_spectrum(ctx: Context) -> dict:
|
|
82
118
|
"""Get 8-band frequency analysis of the master bus.
|
|
@@ -18,9 +18,16 @@ def _get_ableton(ctx: Context):
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _validate_track_index(track_index: int):
|
|
21
|
-
"""Validate track index.
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
"""Validate track index.
|
|
22
|
+
|
|
23
|
+
0+ for regular tracks, -1/-2/... for return tracks (A/B/...),
|
|
24
|
+
-1000 for master track.
|
|
25
|
+
"""
|
|
26
|
+
if track_index < 0 and track_index != -1000 and track_index < -20:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
"track_index must be >= 0 for regular tracks, "
|
|
29
|
+
"-1/-2/... for return tracks, or -1000 for master"
|
|
30
|
+
)
|
|
24
31
|
|
|
25
32
|
|
|
26
33
|
@mcp.tool()
|
|
@@ -53,18 +53,21 @@ def research_technique(
|
|
|
53
53
|
# 1. Analyze query to predict relevant devices
|
|
54
54
|
query_info = research_engine.analyze_query(query)
|
|
55
55
|
|
|
56
|
-
# 2. Search device atlas for relevant devices
|
|
56
|
+
# 2. Search device atlas for relevant devices across all categories
|
|
57
57
|
device_atlas_results = []
|
|
58
58
|
for device_name in query_info.get("likely_devices", [])[:5]:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
59
|
+
for search_path in ("instruments", "audio_effects", "drums"):
|
|
60
|
+
try:
|
|
61
|
+
ref = ableton.send_command("search_browser", {
|
|
62
|
+
"path": search_path,
|
|
63
|
+
"name_filter": device_name,
|
|
64
|
+
"max_results": 5,
|
|
65
|
+
})
|
|
66
|
+
if ref and not ref.get("error") and ref.get("count", 0) > 0:
|
|
67
|
+
device_atlas_results.append(ref)
|
|
68
|
+
break # Found in this category, skip others
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
68
71
|
|
|
69
72
|
# 3. Search memory for related techniques (direct TechniqueStore)
|
|
70
73
|
memory_results = []
|
|
@@ -88,14 +91,21 @@ def research_technique(
|
|
|
88
91
|
query, device_atlas_results, memory_results,
|
|
89
92
|
)
|
|
90
93
|
else:
|
|
91
|
-
# Deep research —
|
|
92
|
-
#
|
|
94
|
+
# Deep research — web search is delegated to the agent (LLM) layer.
|
|
95
|
+
# The MCP server cannot perform web searches directly. When scope
|
|
96
|
+
# is "deep", we still return device atlas + memory results and flag
|
|
97
|
+
# that the agent should supplement with its own web search.
|
|
93
98
|
result = research_engine.deep_research(
|
|
94
99
|
query,
|
|
95
|
-
web_results=[], #
|
|
100
|
+
web_results=[], # Agent supplements with WebSearch tool
|
|
96
101
|
device_atlas_results=device_atlas_results,
|
|
97
102
|
memory_results=memory_results,
|
|
98
103
|
)
|
|
104
|
+
# Flag to the caller that web results should be sourced externally
|
|
105
|
+
result.web_search_note = (
|
|
106
|
+
"Deep scope requested but web search is handled by the agent layer. "
|
|
107
|
+
"Use WebSearch or web browsing tools to supplement these device atlas findings."
|
|
108
|
+
)
|
|
99
109
|
|
|
100
110
|
return result.to_dict()
|
|
101
111
|
|
|
@@ -161,8 +161,9 @@ def suggest_next_chord(
|
|
|
161
161
|
last_rn = engine.roman_numeral(last_group["pitch_classes"], tonic, mode)
|
|
162
162
|
last_figure = last_rn["figure"]
|
|
163
163
|
|
|
164
|
-
# Progression maps by style
|
|
165
|
-
|
|
164
|
+
# Progression maps by style — separate major/minor variants where needed.
|
|
165
|
+
# Minor key maps use lowercase i for tonic and scale-derived numerals.
|
|
166
|
+
_progressions_major = {
|
|
166
167
|
"common_practice": {
|
|
167
168
|
"I": ["IV", "V", "vi", "ii"],
|
|
168
169
|
"ii": ["V", "vii\u00b0", "IV"],
|
|
@@ -173,10 +174,10 @@ def suggest_next_chord(
|
|
|
173
174
|
"vii\u00b0": ["I", "iii"],
|
|
174
175
|
},
|
|
175
176
|
"jazz": {
|
|
176
|
-
"I": ["IV7", "ii7", "vi7", "
|
|
177
|
-
"ii7": ["V7", "
|
|
178
|
-
"IV7": ["V7", "
|
|
179
|
-
"V7": ["I", "vi", "
|
|
177
|
+
"I": ["IV7", "ii7", "vi7", "V7"],
|
|
178
|
+
"ii7": ["V7", "IV7"],
|
|
179
|
+
"IV7": ["V7", "I", "ii7"],
|
|
180
|
+
"V7": ["I", "vi", "IV"],
|
|
180
181
|
"vi7": ["ii7", "IV7"],
|
|
181
182
|
},
|
|
182
183
|
"modal": {
|
|
@@ -194,8 +195,47 @@ def suggest_next_chord(
|
|
|
194
195
|
"vi": ["IV", "V", "I"],
|
|
195
196
|
},
|
|
196
197
|
}
|
|
198
|
+
_progressions_minor = {
|
|
199
|
+
"common_practice": {
|
|
200
|
+
"i": ["iv", "v", "VI", "III"],
|
|
201
|
+
"ii\u00b0": ["v", "VII", "iv"],
|
|
202
|
+
"III": ["VI", "iv", "VII"],
|
|
203
|
+
"iv": ["v", "i", "VII"],
|
|
204
|
+
"v": ["i", "VI", "iv"],
|
|
205
|
+
"VI": ["iv", "ii\u00b0", "v", "VII"],
|
|
206
|
+
"VII": ["III", "i"],
|
|
207
|
+
},
|
|
208
|
+
"jazz": {
|
|
209
|
+
"i": ["iv7", "v", "VI7", "VII7"],
|
|
210
|
+
"i7": ["iv7", "v", "VI7", "VII7"],
|
|
211
|
+
"ii\u00b07": ["v", "VII7"],
|
|
212
|
+
"iv7": ["VII7", "v", "i"],
|
|
213
|
+
"v": ["i", "VI", "iv"],
|
|
214
|
+
"VI7": ["ii\u00b07", "iv7", "VII7"],
|
|
215
|
+
"VImaj7": ["ii\u00b07", "iv7", "VII7"],
|
|
216
|
+
"VII7": ["III", "i", "VI"],
|
|
217
|
+
},
|
|
218
|
+
"modal": {
|
|
219
|
+
"i": ["VII", "iv", "v", "III"],
|
|
220
|
+
"iv": ["i", "VII", "v"],
|
|
221
|
+
"v": ["VII", "iv", "i"],
|
|
222
|
+
"VII": ["i", "iv", "v"],
|
|
223
|
+
"III": ["iv", "VII"],
|
|
224
|
+
},
|
|
225
|
+
"pop": {
|
|
226
|
+
"i": ["VII", "VI", "iv"],
|
|
227
|
+
"iv": ["i", "VII", "VI"],
|
|
228
|
+
"v": ["i", "VI", "iv"],
|
|
229
|
+
"VI": ["iv", "VII", "i"],
|
|
230
|
+
"VII": ["i", "VI", "III"],
|
|
231
|
+
},
|
|
232
|
+
}
|
|
197
233
|
|
|
198
|
-
|
|
234
|
+
# Select the right map based on mode
|
|
235
|
+
is_minor = mode in ("minor", "dorian", "phrygian", "aeolian",
|
|
236
|
+
"phrygian_dominant")
|
|
237
|
+
prog_set = _progressions_minor if is_minor else _progressions_major
|
|
238
|
+
style_map = prog_set.get(style, prog_set.get("common_practice", {}))
|
|
199
239
|
|
|
200
240
|
# Match the last chord to the closest key in the map
|
|
201
241
|
candidates = style_map.get(last_figure)
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.20",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
|
-
"description": "Agentic production system for Ableton Live 12 —
|
|
5
|
+
"description": "Agentic production system for Ableton Live 12 — 237 tools, 32 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
6
6
|
"author": "Pilot Studio",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"type": "commonjs",
|
|
@@ -5,7 +5,7 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
|
|
|
5
5
|
when this script is selected in Preferences > Link, Tempo & MIDI.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
__version__ = "1.9.
|
|
8
|
+
__version__ = "1.9.20"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from .server import LivePilotServer
|
|
@@ -543,13 +543,15 @@ def set_arrangement_automation(song, params):
|
|
|
543
543
|
# arming the track and playing back with parameter changes,
|
|
544
544
|
# or use session-clip automation (set_clip_automation) and then
|
|
545
545
|
# record the session performance to arrangement.
|
|
546
|
-
raise
|
|
546
|
+
raise RuntimeError(
|
|
547
547
|
"Cannot create automation envelope for parameter '%s' on this "
|
|
548
|
-
"arrangement clip.
|
|
549
|
-
"programmatically-created arrangement clips.
|
|
550
|
-
"
|
|
551
|
-
"(
|
|
552
|
-
"
|
|
548
|
+
"arrangement clip. Direct envelope access is not supported for "
|
|
549
|
+
"programmatically-created arrangement clips (known Live API limitation). "
|
|
550
|
+
"Workarounds: "
|
|
551
|
+
"(1) use set_clip_automation on a session clip instead, then fire "
|
|
552
|
+
"the scene and record to arrangement; "
|
|
553
|
+
"(2) use arrangement-level volume/pan fades by creating separate "
|
|
554
|
+
"clips at different volumes for each section."
|
|
553
555
|
% parameter.name
|
|
554
556
|
)
|
|
555
557
|
|
|
@@ -132,11 +132,18 @@ def get_track_meters(song, params):
|
|
|
132
132
|
entry = {
|
|
133
133
|
"index": idx,
|
|
134
134
|
"name": track.name,
|
|
135
|
-
"level": track.output_meter_level,
|
|
136
135
|
}
|
|
137
|
-
if
|
|
138
|
-
entry["
|
|
139
|
-
|
|
136
|
+
if track.has_audio_output:
|
|
137
|
+
entry["level"] = track.output_meter_level
|
|
138
|
+
if include_stereo:
|
|
139
|
+
entry["left"] = track.output_meter_left
|
|
140
|
+
entry["right"] = track.output_meter_right
|
|
141
|
+
else:
|
|
142
|
+
entry["level"] = 0.0
|
|
143
|
+
entry["has_audio_output"] = False
|
|
144
|
+
if include_stereo:
|
|
145
|
+
entry["left"] = 0.0
|
|
146
|
+
entry["right"] = 0.0
|
|
140
147
|
return entry
|
|
141
148
|
|
|
142
149
|
if track_index is not None:
|
|
@@ -170,7 +177,7 @@ def get_mix_snapshot(song, params):
|
|
|
170
177
|
tracks.append({
|
|
171
178
|
"index": i,
|
|
172
179
|
"name": track.name,
|
|
173
|
-
"meter_level": track.output_meter_level,
|
|
180
|
+
"meter_level": track.output_meter_level if track.has_audio_output else 0.0,
|
|
174
181
|
"volume": track.mixer_device.volume.value,
|
|
175
182
|
"pan": track.mixer_device.panning.value,
|
|
176
183
|
"mute": track.mute,
|
|
@@ -11,6 +11,7 @@ from .utils import (
|
|
|
11
11
|
INDEX_ERROR,
|
|
12
12
|
INVALID_PARAM,
|
|
13
13
|
NOT_FOUND,
|
|
14
|
+
STATE_ERROR,
|
|
14
15
|
INTERNAL,
|
|
15
16
|
)
|
|
16
17
|
|
|
@@ -92,5 +93,7 @@ def dispatch(song, command):
|
|
|
92
93
|
return error_response(request_id, str(exc), INDEX_ERROR)
|
|
93
94
|
except ValueError as exc:
|
|
94
95
|
return error_response(request_id, str(exc), INVALID_PARAM)
|
|
96
|
+
except RuntimeError as exc:
|
|
97
|
+
return error_response(request_id, str(exc), STATE_ERROR)
|
|
95
98
|
except Exception as exc:
|
|
96
99
|
return error_response(request_id, str(exc), INTERNAL)
|
|
@@ -122,6 +122,9 @@ def create_midi_track(song, params):
|
|
|
122
122
|
track.name = str(params["name"])
|
|
123
123
|
if "color_index" in params:
|
|
124
124
|
track.color_index = int(params["color_index"])
|
|
125
|
+
# Ableton auto-arms newly created tracks — disarm to avoid surprises
|
|
126
|
+
if track.arm and not params.get("arm", False):
|
|
127
|
+
track.arm = False
|
|
125
128
|
return {"index": new_index, "name": track.name}
|
|
126
129
|
|
|
127
130
|
|
|
@@ -139,6 +142,9 @@ def create_audio_track(song, params):
|
|
|
139
142
|
track.name = str(params["name"])
|
|
140
143
|
if "color_index" in params:
|
|
141
144
|
track.color_index = int(params["color_index"])
|
|
145
|
+
# Ableton auto-arms newly created tracks — disarm to avoid surprises
|
|
146
|
+
if track.arm and not params.get("arm", False):
|
|
147
|
+
track.arm = False
|
|
142
148
|
return {"index": new_index, "name": track.name}
|
|
143
149
|
|
|
144
150
|
|
|
@@ -187,10 +193,29 @@ def duplicate_track(song, params):
|
|
|
187
193
|
|
|
188
194
|
@register("set_track_name")
|
|
189
195
|
def set_track_name(song, params):
|
|
190
|
-
"""Rename a track.
|
|
196
|
+
"""Rename a track.
|
|
197
|
+
|
|
198
|
+
For return tracks, Ableton auto-prefixes with a letter (A-, B-, C-).
|
|
199
|
+
If the requested name already starts with that prefix, strip it to
|
|
200
|
+
avoid doubling (e.g. "C-Resonators" stays as "C-Resonators" not
|
|
201
|
+
"C-C-Resonators").
|
|
202
|
+
"""
|
|
191
203
|
track_index = int(params["track_index"])
|
|
192
204
|
track = get_track(song, track_index)
|
|
193
|
-
|
|
205
|
+
new_name = str(params["name"])
|
|
206
|
+
|
|
207
|
+
# For return tracks, strip auto-prefix from user's name if it matches
|
|
208
|
+
if track_index < 0 and track_index != -1000:
|
|
209
|
+
return_tracks = list(song.return_tracks)
|
|
210
|
+
ri = abs(track_index) - 1
|
|
211
|
+
if ri < len(return_tracks):
|
|
212
|
+
# Return tracks have letter prefixes: "A-", "B-", "C-", etc.
|
|
213
|
+
letter = chr(ord('A') + ri)
|
|
214
|
+
prefix = letter + "-"
|
|
215
|
+
if new_name.startswith(prefix):
|
|
216
|
+
new_name = new_name[len(prefix):]
|
|
217
|
+
|
|
218
|
+
track.name = new_name
|
|
194
219
|
return {"index": track_index, "name": track.name}
|
|
195
220
|
|
|
196
221
|
|