livepilot 1.9.15 → 1.9.16
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 +40 -0
- package/README.md +1 -1
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/references/overview.md +1 -1
- package/m4l_device/livepilot_bridge.js +27 -13
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +24 -2
- package/mcp_server/curves.py +3 -3
- package/mcp_server/evaluation/fabric.py +1 -1
- package/mcp_server/m4l_bridge.py +9 -1
- package/mcp_server/memory/technique_store.py +25 -17
- package/mcp_server/mix_engine/critics.py +1 -1
- package/mcp_server/mix_engine/tools.py +14 -8
- package/mcp_server/performance_engine/safety.py +6 -3
- package/mcp_server/project_brain/refresh.py +8 -2
- package/mcp_server/project_brain/tools.py +12 -12
- package/mcp_server/reference_engine/tools.py +16 -15
- package/mcp_server/runtime/action_ledger_models.py +10 -3
- package/mcp_server/runtime/capability_state.py +3 -2
- package/mcp_server/runtime/tools.py +6 -3
- package/mcp_server/tools/agent_os.py +47 -39
- package/mcp_server/tools/composition.py +114 -32
- package/mcp_server/tools/devices.py +15 -1
- package/mcp_server/tools/midi_io.py +3 -1
- package/mcp_server/tools/research.py +31 -31
- package/mcp_server/tools/tracks.py +3 -3
- package/mcp_server/translation_engine/tools.py +50 -16
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +9 -1
- package/remote_script/LivePilot/clips.py +22 -6
- package/remote_script/LivePilot/notes.py +9 -1
- package/remote_script/LivePilot/server.py +6 -6
|
@@ -9,8 +9,11 @@ from __future__ import annotations
|
|
|
9
9
|
from fastmcp import Context
|
|
10
10
|
|
|
11
11
|
from ..server import mcp
|
|
12
|
+
from ..memory.technique_store import TechniqueStore
|
|
12
13
|
from .capability_state import build_capability_state
|
|
13
14
|
|
|
15
|
+
_memory_store = TechniqueStore()
|
|
16
|
+
|
|
14
17
|
|
|
15
18
|
@mcp.tool()
|
|
16
19
|
def get_capability_state(ctx: Context) -> dict:
|
|
@@ -40,11 +43,11 @@ def get_capability_state(ctx: Context) -> dict:
|
|
|
40
43
|
snap = spectral.get("spectrum")
|
|
41
44
|
analyzer_fresh = snap is not None
|
|
42
45
|
|
|
43
|
-
# ── Probe memory
|
|
46
|
+
# ── Probe memory (direct TechniqueStore, not TCP) ────────────────
|
|
44
47
|
memory_ok = False
|
|
45
48
|
try:
|
|
46
|
-
|
|
47
|
-
memory_ok =
|
|
49
|
+
_memory_store.list_techniques(limit=1)
|
|
50
|
+
memory_ok = True
|
|
48
51
|
except Exception:
|
|
49
52
|
memory_ok = False
|
|
50
53
|
|
|
@@ -15,8 +15,11 @@ from typing import Optional
|
|
|
15
15
|
from fastmcp import Context
|
|
16
16
|
|
|
17
17
|
from ..server import mcp
|
|
18
|
+
from ..memory.technique_store import TechniqueStore
|
|
18
19
|
from . import _agent_os_engine as engine
|
|
19
20
|
|
|
21
|
+
_memory_store = TechniqueStore()
|
|
22
|
+
|
|
20
23
|
|
|
21
24
|
def _get_ableton(ctx: Context):
|
|
22
25
|
return ctx.lifespan_context["ableton"]
|
|
@@ -268,25 +271,25 @@ def analyze_outcomes(
|
|
|
268
271
|
The more outcomes stored (via memory_learn type="outcome"),
|
|
269
272
|
the better the taste analysis becomes.
|
|
270
273
|
"""
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
# Fetch outcome memories
|
|
274
|
+
# Fetch outcome memories directly from TechniqueStore
|
|
274
275
|
try:
|
|
275
|
-
|
|
276
|
-
"
|
|
277
|
-
|
|
278
|
-
"sort_by": "updated_at",
|
|
279
|
-
})
|
|
280
|
-
techniques = memory_result.get("techniques", [])
|
|
276
|
+
techniques = _memory_store.list_techniques(
|
|
277
|
+
type_filter="outcome", sort_by="updated_at", limit=limit,
|
|
278
|
+
)
|
|
281
279
|
except Exception:
|
|
282
280
|
techniques = []
|
|
283
281
|
|
|
284
|
-
# Extract payloads from
|
|
282
|
+
# Extract payloads from full technique records
|
|
285
283
|
outcomes = []
|
|
286
284
|
for t in techniques:
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
285
|
+
# list_techniques returns compact summaries; get full record for payload
|
|
286
|
+
try:
|
|
287
|
+
full = _memory_store.get(t["id"])
|
|
288
|
+
payload = full.get("payload", {})
|
|
289
|
+
if isinstance(payload, dict):
|
|
290
|
+
outcomes.append(payload)
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
290
293
|
|
|
291
294
|
return engine.analyze_outcome_history(outcomes)
|
|
292
295
|
|
|
@@ -308,29 +311,30 @@ def get_technique_card(
|
|
|
308
311
|
query: search term (e.g., "wider pad", "punchy kick", "sidechain bass")
|
|
309
312
|
limit: max results
|
|
310
313
|
"""
|
|
311
|
-
|
|
312
|
-
|
|
314
|
+
# Search technique cards directly from TechniqueStore
|
|
313
315
|
try:
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
"limit": limit,
|
|
318
|
-
})
|
|
319
|
-
techniques = memory_result.get("techniques", [])
|
|
316
|
+
techniques = _memory_store.search(
|
|
317
|
+
query=query, type_filter="technique_card", limit=limit,
|
|
318
|
+
)
|
|
320
319
|
except Exception:
|
|
321
320
|
techniques = []
|
|
322
321
|
|
|
323
322
|
cards = []
|
|
324
323
|
for t in techniques:
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
324
|
+
# search() returns summaries without payload; get full record
|
|
325
|
+
try:
|
|
326
|
+
full = _memory_store.get(t["id"])
|
|
327
|
+
payload = full.get("payload", {})
|
|
328
|
+
if isinstance(payload, dict):
|
|
329
|
+
cards.append({
|
|
330
|
+
"id": t.get("id"),
|
|
331
|
+
"name": t.get("name"),
|
|
332
|
+
"card": payload,
|
|
333
|
+
"rating": t.get("rating", 0),
|
|
334
|
+
"replay_count": t.get("replay_count", 0),
|
|
335
|
+
})
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
334
338
|
|
|
335
339
|
return {
|
|
336
340
|
"query": query,
|
|
@@ -358,19 +362,23 @@ def get_taste_profile(
|
|
|
358
362
|
Returns: {taste_vector, preferred_dimensions, avoided_dimensions,
|
|
359
363
|
keep_rate, sample_size}
|
|
360
364
|
"""
|
|
361
|
-
|
|
362
|
-
|
|
365
|
+
# Fetch outcome memories directly from TechniqueStore
|
|
363
366
|
try:
|
|
364
|
-
|
|
365
|
-
"
|
|
366
|
-
|
|
367
|
-
"sort_by": "updated_at",
|
|
368
|
-
})
|
|
369
|
-
techniques = memory_result.get("techniques", [])
|
|
367
|
+
techniques = _memory_store.list_techniques(
|
|
368
|
+
type_filter="outcome", sort_by="updated_at", limit=limit,
|
|
369
|
+
)
|
|
370
370
|
except Exception:
|
|
371
371
|
techniques = []
|
|
372
372
|
|
|
373
|
-
outcomes = [
|
|
373
|
+
outcomes = []
|
|
374
|
+
for t in techniques:
|
|
375
|
+
try:
|
|
376
|
+
full = _memory_store.get(t["id"])
|
|
377
|
+
payload = full.get("payload", {})
|
|
378
|
+
if isinstance(payload, dict):
|
|
379
|
+
outcomes.append(payload)
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
374
382
|
|
|
375
383
|
return engine.get_taste_profile(outcomes)
|
|
376
384
|
|
|
@@ -19,8 +19,11 @@ from typing import Optional
|
|
|
19
19
|
from fastmcp import Context
|
|
20
20
|
|
|
21
21
|
from ..server import mcp
|
|
22
|
+
from ..memory.technique_store import TechniqueStore
|
|
22
23
|
from . import _composition_engine as engine
|
|
23
24
|
|
|
25
|
+
_memory_store = TechniqueStore()
|
|
26
|
+
|
|
24
27
|
|
|
25
28
|
def _get_ableton(ctx: Context):
|
|
26
29
|
return ctx.lifespan_context["ableton"]
|
|
@@ -229,13 +232,16 @@ def get_phrase_grid(
|
|
|
229
232
|
|
|
230
233
|
section = sections[section_index]
|
|
231
234
|
|
|
232
|
-
# Collect notes for active tracks
|
|
235
|
+
# Collect notes for active tracks — use the section's scene_index
|
|
236
|
+
# (which maps to the actual clip slot), not the section_index
|
|
237
|
+
# (which is a position in the section graph)
|
|
233
238
|
notes_by_track: dict[int, list] = {}
|
|
239
|
+
scene_idx = section.scene_index if hasattr(section, "scene_index") else section_index
|
|
234
240
|
for t_idx in section.tracks_active:
|
|
235
241
|
try:
|
|
236
242
|
result = ableton.send_command("get_notes", {
|
|
237
243
|
"track_index": t_idx,
|
|
238
|
-
"clip_index":
|
|
244
|
+
"clip_index": scene_idx,
|
|
239
245
|
})
|
|
240
246
|
notes_by_track[t_idx] = result.get("notes", [])
|
|
241
247
|
except Exception:
|
|
@@ -377,6 +383,10 @@ def get_harmony_field(
|
|
|
377
383
|
section = sections[section_index]
|
|
378
384
|
|
|
379
385
|
# Find a track with notes to analyze harmony
|
|
386
|
+
# Use theory engine functions directly instead of TCP calls to MCP tools
|
|
387
|
+
from . import _theory_engine as theory_engine
|
|
388
|
+
from . import _harmony_engine as harmony_engine
|
|
389
|
+
|
|
380
390
|
scale_info = None
|
|
381
391
|
harmony_analysis = None
|
|
382
392
|
progression_info = None
|
|
@@ -384,29 +394,97 @@ def get_harmony_field(
|
|
|
384
394
|
|
|
385
395
|
for t_idx in section.tracks_active:
|
|
386
396
|
try:
|
|
387
|
-
#
|
|
388
|
-
|
|
389
|
-
"track_index": t_idx, "clip_index": section_index
|
|
390
|
-
})
|
|
391
|
-
if si.get("top_match"):
|
|
392
|
-
scale_info = si
|
|
393
|
-
|
|
394
|
-
# Try analyze_harmony
|
|
395
|
-
ha = ableton.send_command("analyze_harmony", {
|
|
396
|
-
"track_index": t_idx, "clip_index": section_index
|
|
397
|
+
# Get notes via TCP (valid Remote Script command)
|
|
398
|
+
result = ableton.send_command("get_notes", {
|
|
399
|
+
"track_index": t_idx, "clip_index": section_index,
|
|
397
400
|
})
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
401
|
+
notes = result.get("notes", [])
|
|
402
|
+
if not notes:
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
# identify_scale: run key detection directly
|
|
406
|
+
if not scale_info:
|
|
407
|
+
detected = theory_engine.detect_key(notes, mode_detection=True)
|
|
408
|
+
top = {
|
|
409
|
+
"key": f"{detected['tonic_name']} {detected['mode'].replace('_', ' ')}",
|
|
410
|
+
"confidence": detected["confidence"],
|
|
411
|
+
"mode": detected["mode"].replace("_", " "),
|
|
412
|
+
"mode_id": detected["mode"],
|
|
413
|
+
"tonic": detected["tonic_name"],
|
|
414
|
+
}
|
|
415
|
+
scale_info = {"top_match": top}
|
|
416
|
+
|
|
417
|
+
# analyze_harmony: chordify + roman numeral analysis directly
|
|
418
|
+
if not harmony_analysis:
|
|
419
|
+
key_info = theory_engine.detect_key(notes)
|
|
420
|
+
tonic = key_info["tonic"]
|
|
421
|
+
mode = key_info["mode"]
|
|
422
|
+
chord_groups = theory_engine.chordify(notes)
|
|
423
|
+
if chord_groups:
|
|
424
|
+
chords = []
|
|
425
|
+
for group in chord_groups:
|
|
426
|
+
pitches = group["pitches"]
|
|
427
|
+
pcs = group["pitch_classes"]
|
|
428
|
+
rn = theory_engine.roman_numeral(pcs, tonic, mode)
|
|
429
|
+
cn = theory_engine.chord_name(pitches)
|
|
430
|
+
chords.append({
|
|
431
|
+
"beat": group["beat"],
|
|
432
|
+
"duration": group["duration"],
|
|
433
|
+
"chord_name": cn,
|
|
434
|
+
"roman_numeral": rn["figure"],
|
|
435
|
+
"figure": rn["figure"],
|
|
436
|
+
"quality": rn["quality"],
|
|
407
437
|
})
|
|
408
|
-
|
|
409
|
-
|
|
438
|
+
if chords:
|
|
439
|
+
harmony_analysis = {
|
|
440
|
+
"key": f"{key_info['tonic_name']} {mode.replace('_', ' ')}",
|
|
441
|
+
"chords": chords,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
# classify_progression directly
|
|
445
|
+
chord_names = [c["chord_name"] for c in chords if c.get("chord_name")]
|
|
446
|
+
if len(chord_names) >= 2:
|
|
447
|
+
try:
|
|
448
|
+
parsed = [harmony_engine.parse_chord(c) for c in chord_names[:8]]
|
|
449
|
+
transforms = harmony_engine.classify_transform_sequence(parsed)
|
|
450
|
+
pattern = "".join(transforms)
|
|
451
|
+
classification = "free neo-Riemannian progression"
|
|
452
|
+
clean = pattern.replace("?", "")
|
|
453
|
+
if len(clean) >= 2:
|
|
454
|
+
pair = clean[:2]
|
|
455
|
+
if pair in ("PL", "LP") and all(c in "PL" for c in clean):
|
|
456
|
+
classification = "hexatonic cycle fragment"
|
|
457
|
+
elif pair in ("PR", "RP") and all(c in "PR" for c in clean):
|
|
458
|
+
classification = "octatonic cycle fragment"
|
|
459
|
+
elif pair in ("LR", "RL") and all(c in "LR" for c in clean):
|
|
460
|
+
classification = "diatonic cycle fragment"
|
|
461
|
+
progression_info = {
|
|
462
|
+
"chords": chord_names[:8],
|
|
463
|
+
"transforms": transforms,
|
|
464
|
+
"pattern": pattern,
|
|
465
|
+
"classification": classification,
|
|
466
|
+
}
|
|
467
|
+
except Exception:
|
|
468
|
+
pass
|
|
469
|
+
|
|
470
|
+
# Populate voice_leading_info from chord groups
|
|
471
|
+
if harmony_analysis and not voice_leading_info:
|
|
472
|
+
try:
|
|
473
|
+
chord_groups_vl = theory_engine.chordify(notes)
|
|
474
|
+
if len(chord_groups_vl) >= 2:
|
|
475
|
+
all_vl_issues = []
|
|
476
|
+
for vi in range(1, min(len(chord_groups_vl), 9)):
|
|
477
|
+
prev_p = chord_groups_vl[vi - 1]["pitches"]
|
|
478
|
+
curr_p = chord_groups_vl[vi]["pitches"]
|
|
479
|
+
issues = theory_engine.check_voice_leading(prev_p, curr_p)
|
|
480
|
+
all_vl_issues.extend(issues)
|
|
481
|
+
voice_leading_info = {
|
|
482
|
+
"issues": all_vl_issues,
|
|
483
|
+
"issue_count": len(all_vl_issues),
|
|
484
|
+
"quality": "clean" if not all_vl_issues else "has_issues",
|
|
485
|
+
}
|
|
486
|
+
except Exception:
|
|
487
|
+
pass
|
|
410
488
|
|
|
411
489
|
if scale_info and harmony_analysis:
|
|
412
490
|
break
|
|
@@ -540,19 +618,23 @@ def get_section_outcomes(
|
|
|
540
618
|
section_type: filter to a specific type (intro, verse, chorus, etc.)
|
|
541
619
|
Leave empty for all types.
|
|
542
620
|
"""
|
|
543
|
-
|
|
544
|
-
|
|
621
|
+
# Fetch composition outcomes directly from TechniqueStore
|
|
545
622
|
try:
|
|
546
|
-
|
|
547
|
-
"
|
|
548
|
-
|
|
549
|
-
"sort_by": "updated_at",
|
|
550
|
-
})
|
|
551
|
-
techniques = memory_result.get("techniques", [])
|
|
623
|
+
techniques = _memory_store.list_techniques(
|
|
624
|
+
type_filter="composition_outcome", sort_by="updated_at", limit=limit,
|
|
625
|
+
)
|
|
552
626
|
except Exception:
|
|
553
627
|
techniques = []
|
|
554
628
|
|
|
555
|
-
outcomes = [
|
|
629
|
+
outcomes = []
|
|
630
|
+
for t in techniques:
|
|
631
|
+
try:
|
|
632
|
+
full = _memory_store.get(t["id"])
|
|
633
|
+
payload = full.get("payload", {})
|
|
634
|
+
if isinstance(payload, dict):
|
|
635
|
+
outcomes.append(payload)
|
|
636
|
+
except Exception:
|
|
637
|
+
pass
|
|
556
638
|
|
|
557
639
|
result = engine.analyze_section_outcomes(outcomes)
|
|
558
640
|
|
|
@@ -145,11 +145,25 @@ def _postflight_loaded_device(ctx: Context, result: dict) -> dict:
|
|
|
145
145
|
if match is None:
|
|
146
146
|
match = devices[-1]
|
|
147
147
|
|
|
148
|
+
# get_track_info returns device summaries without a parameters list,
|
|
149
|
+
# so use the 'parameter_count' field if present, otherwise fetch
|
|
150
|
+
# the actual device info for an accurate count.
|
|
151
|
+
param_count = match.get("parameter_count", len(match.get("parameters", [])))
|
|
152
|
+
if param_count == 0 and match.get("index") is not None:
|
|
153
|
+
try:
|
|
154
|
+
full_info = _get_ableton(ctx).send_command("get_device_info", {
|
|
155
|
+
"track_index": int(track_index),
|
|
156
|
+
"device_index": int(match["index"]),
|
|
157
|
+
})
|
|
158
|
+
param_count = full_info.get("parameter_count", 0)
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
148
162
|
device_info = _annotate_device_info({
|
|
149
163
|
"name": match.get("name"),
|
|
150
164
|
"class_name": match.get("class_name"),
|
|
151
165
|
"is_active": match.get("is_active"),
|
|
152
|
-
"parameter_count":
|
|
166
|
+
"parameter_count": param_count,
|
|
153
167
|
})
|
|
154
168
|
|
|
155
169
|
merged = dict(annotated)
|
|
@@ -62,7 +62,9 @@ def _safe_output_path(directory: Path, filename: str) -> Path:
|
|
|
62
62
|
if not safe_name:
|
|
63
63
|
raise ValueError(f"Invalid filename: {filename!r}")
|
|
64
64
|
out = (directory / safe_name).resolve()
|
|
65
|
-
|
|
65
|
+
# Use os.sep suffix to prevent prefix collisions (e.g., /foo/bar vs /foo/barbaz)
|
|
66
|
+
parent = str(directory.resolve()) + os.sep
|
|
67
|
+
if not (str(out) + os.sep).startswith(parent):
|
|
66
68
|
raise ValueError(f"Filename escapes output directory: {filename!r}")
|
|
67
69
|
return out
|
|
68
70
|
|
|
@@ -15,8 +15,11 @@ from typing import Optional
|
|
|
15
15
|
from fastmcp import Context
|
|
16
16
|
|
|
17
17
|
from ..server import mcp
|
|
18
|
+
from ..memory.technique_store import TechniqueStore
|
|
18
19
|
from . import _research_engine as research_engine
|
|
19
20
|
|
|
21
|
+
_memory_store = TechniqueStore()
|
|
22
|
+
|
|
20
23
|
|
|
21
24
|
def _get_ableton(ctx: Context):
|
|
22
25
|
return ctx.lifespan_context["ableton"]
|
|
@@ -50,37 +53,33 @@ def research_technique(
|
|
|
50
53
|
# 1. Analyze query to predict relevant devices
|
|
51
54
|
query_info = research_engine.analyze_query(query)
|
|
52
55
|
|
|
53
|
-
# 2. Search device atlas for relevant devices
|
|
56
|
+
# 2. Search device atlas for relevant devices (Fix 2: correct params)
|
|
54
57
|
device_atlas_results = []
|
|
55
58
|
for device_name in query_info.get("likely_devices", [])[:5]:
|
|
56
59
|
try:
|
|
57
|
-
ref = ableton.send_command("search_browser", {
|
|
60
|
+
ref = ableton.send_command("search_browser", {
|
|
61
|
+
"path": "instruments",
|
|
62
|
+
"name_filter": device_name,
|
|
63
|
+
})
|
|
58
64
|
if ref and not ref.get("error"):
|
|
59
65
|
device_atlas_results.append(ref)
|
|
60
66
|
except Exception:
|
|
61
67
|
pass
|
|
62
68
|
|
|
63
|
-
# 3. Search memory for related techniques
|
|
69
|
+
# 3. Search memory for related techniques (direct TechniqueStore)
|
|
64
70
|
memory_results = []
|
|
65
71
|
try:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
"limit": 10,
|
|
70
|
-
"sort_by": "updated_at",
|
|
71
|
-
})
|
|
72
|
-
memory_results.extend(mem.get("techniques", []))
|
|
72
|
+
memory_results.extend(
|
|
73
|
+
_memory_store.list_techniques(type_filter="technique_card", sort_by="updated_at", limit=10)
|
|
74
|
+
)
|
|
73
75
|
except Exception:
|
|
74
76
|
pass
|
|
75
77
|
|
|
76
78
|
try:
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"sort_by": "updated_at",
|
|
82
|
-
})
|
|
83
|
-
memory_results.extend(mem.get("techniques", []))
|
|
79
|
+
# "research" is not a valid type in TechniqueStore — search broadly
|
|
80
|
+
memory_results.extend(
|
|
81
|
+
_memory_store.search(query=query, limit=5)
|
|
82
|
+
)
|
|
84
83
|
except Exception:
|
|
85
84
|
pass
|
|
86
85
|
|
|
@@ -131,19 +130,24 @@ def get_emotional_arc(ctx: Context) -> dict:
|
|
|
131
130
|
}
|
|
132
131
|
|
|
133
132
|
# Try to build harmony fields for richer analysis
|
|
133
|
+
# Use theory engine directly instead of TCP call to MCP tool
|
|
134
|
+
from . import _theory_engine as theory_engine
|
|
135
|
+
|
|
134
136
|
harmony_fields = []
|
|
135
137
|
for i, section in enumerate(sections):
|
|
136
138
|
hf = engine.HarmonyField(section_id=section.section_id)
|
|
137
|
-
# Try to get harmony data
|
|
139
|
+
# Try to get harmony data by fetching notes then running engine
|
|
138
140
|
for t_idx in section.tracks_active[:3]:
|
|
139
141
|
try:
|
|
140
|
-
|
|
142
|
+
result = ableton.send_command("get_notes", {
|
|
141
143
|
"track_index": t_idx, "clip_index": i,
|
|
142
144
|
})
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
hf.
|
|
145
|
+
notes = result.get("notes", [])
|
|
146
|
+
if notes:
|
|
147
|
+
detected = theory_engine.detect_key(notes, mode_detection=True)
|
|
148
|
+
hf.key = detected.get("tonic_name", "")
|
|
149
|
+
hf.mode = detected.get("mode", "")
|
|
150
|
+
hf.confidence = detected.get("confidence", 0.0)
|
|
147
151
|
break
|
|
148
152
|
except Exception:
|
|
149
153
|
continue
|
|
@@ -193,16 +197,12 @@ def get_style_tactics(
|
|
|
193
197
|
if not artist_or_genre or not artist_or_genre.strip():
|
|
194
198
|
return {"error": "artist_or_genre cannot be empty"}
|
|
195
199
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
# Search user memory for saved tactics
|
|
200
|
+
# Search user memory for saved tactics (direct TechniqueStore)
|
|
199
201
|
memory_tactics = []
|
|
200
202
|
try:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
})
|
|
205
|
-
memory_tactics = mem.get("techniques", [])
|
|
203
|
+
memory_tactics = _memory_store.search(
|
|
204
|
+
query=artist_or_genre, limit=10,
|
|
205
|
+
)
|
|
206
206
|
except Exception:
|
|
207
207
|
pass
|
|
208
208
|
|
|
@@ -167,7 +167,7 @@ def stop_track_clips(ctx: Context, track_index: int) -> dict:
|
|
|
167
167
|
@mcp.tool()
|
|
168
168
|
def set_group_fold(ctx: Context, track_index: int, folded: bool) -> dict:
|
|
169
169
|
"""Fold or unfold a group track to show/hide its children."""
|
|
170
|
-
_validate_track_index(track_index)
|
|
170
|
+
_validate_track_index(track_index, allow_return=False)
|
|
171
171
|
return _get_ableton(ctx).send_command("set_group_fold", {
|
|
172
172
|
"track_index": track_index,
|
|
173
173
|
"folded": folded,
|
|
@@ -176,10 +176,10 @@ def set_group_fold(ctx: Context, track_index: int, folded: bool) -> dict:
|
|
|
176
176
|
|
|
177
177
|
@mcp.tool()
|
|
178
178
|
def set_track_input_monitoring(ctx: Context, track_index: int, state: int) -> dict:
|
|
179
|
-
"""Set input monitoring (0=
|
|
179
|
+
"""Set input monitoring (0=Off, 1=In, 2=Auto). Only for regular tracks, not return tracks."""
|
|
180
180
|
_validate_track_index(track_index)
|
|
181
181
|
if state not in (0, 1, 2):
|
|
182
|
-
raise ValueError("Monitoring state must be 0=
|
|
182
|
+
raise ValueError("Monitoring state must be 0=Off, 1=In, or 2=Auto")
|
|
183
183
|
return _get_ableton(ctx).send_command("set_track_input_monitoring", {
|
|
184
184
|
"track_index": track_index,
|
|
185
185
|
"state": state,
|
|
@@ -16,30 +16,64 @@ from .critics import build_translation_report, run_all_translation_critics
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def _fetch_translation_data(ctx: Context) -> dict:
|
|
19
|
-
"""Fetch mix snapshot data needed for translation analysis.
|
|
19
|
+
"""Fetch mix snapshot data needed for translation analysis.
|
|
20
|
+
|
|
21
|
+
Builds snapshot from real available data:
|
|
22
|
+
- Spectrum from SpectralCache (direct access, not TCP)
|
|
23
|
+
- Stereo width estimated from track pan values
|
|
24
|
+
- Foreground detection from role inference
|
|
25
|
+
"""
|
|
20
26
|
ableton = ctx.lifespan_context["ableton"]
|
|
21
27
|
|
|
22
|
-
# Get
|
|
23
|
-
|
|
28
|
+
# Get spectral data directly from SpectralCache
|
|
29
|
+
spectrum_bands = {}
|
|
24
30
|
try:
|
|
25
|
-
|
|
31
|
+
spectral = ctx.lifespan_context.get("spectral")
|
|
32
|
+
if spectral and spectral.is_connected:
|
|
33
|
+
spec_data = spectral.get("spectrum")
|
|
34
|
+
if spec_data and isinstance(spec_data["value"], dict):
|
|
35
|
+
spectrum_bands = spec_data["value"]
|
|
26
36
|
except Exception:
|
|
27
37
|
pass
|
|
28
38
|
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
# Estimate stereo width from track pans via session info
|
|
40
|
+
stereo_width = 0.0
|
|
41
|
+
center_strength = 0.5
|
|
42
|
+
has_foreground = True
|
|
43
|
+
foreground_masked = False
|
|
44
|
+
try:
|
|
45
|
+
session_info = ableton.send_command("get_session_info", {})
|
|
46
|
+
tracks = session_info.get("tracks", [])
|
|
47
|
+
if tracks:
|
|
48
|
+
# Pan may be at top level or nested under mixer.panning
|
|
49
|
+
def _get_pan(t: dict) -> float:
|
|
50
|
+
mixer = t.get("mixer")
|
|
51
|
+
if isinstance(mixer, dict):
|
|
52
|
+
return abs(mixer.get("panning", 0.0))
|
|
53
|
+
return abs(t.get("pan", 0.0))
|
|
54
|
+
pan_values = [_get_pan(t) for t in tracks if not t.get("muted", False)]
|
|
55
|
+
if pan_values:
|
|
56
|
+
# Wider pans = more stereo width
|
|
57
|
+
stereo_width = min(1.0, sum(pan_values) / max(len(pan_values), 1))
|
|
58
|
+
# Center strength = proportion of tracks near center
|
|
59
|
+
center_count = sum(1 for p in pan_values if p < 0.15)
|
|
60
|
+
center_strength = center_count / max(len(pan_values), 1)
|
|
61
|
+
|
|
62
|
+
# Simple foreground detection: at least one unmuted, non-quiet track
|
|
63
|
+
has_foreground = any(not t.get("muted", False) for t in tracks)
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
32
66
|
|
|
33
67
|
return {
|
|
34
|
-
"stereo_width":
|
|
35
|
-
"center_strength":
|
|
36
|
-
"sub_energy":
|
|
37
|
-
"low_energy":
|
|
38
|
-
"low_mid_energy":
|
|
39
|
-
"high_energy":
|
|
40
|
-
"presence_energy":
|
|
41
|
-
"has_foreground":
|
|
42
|
-
"foreground_masked":
|
|
68
|
+
"stereo_width": stereo_width,
|
|
69
|
+
"center_strength": center_strength,
|
|
70
|
+
"sub_energy": spectrum_bands.get("sub", 0.0),
|
|
71
|
+
"low_energy": spectrum_bands.get("low", 0.0),
|
|
72
|
+
"low_mid_energy": spectrum_bands.get("low_mid", 0.0),
|
|
73
|
+
"high_energy": spectrum_bands.get("high", 0.0),
|
|
74
|
+
"presence_energy": spectrum_bands.get("presence", 0.0),
|
|
75
|
+
"has_foreground": has_foreground,
|
|
76
|
+
"foreground_masked": foreground_masked,
|
|
43
77
|
}
|
|
44
78
|
|
|
45
79
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.16",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
5
|
"description": "Agentic production system for Ableton Live 12 — 236 tools, 32 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
6
6
|
"author": "Pilot Studio",
|
|
@@ -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.16"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from .server import LivePilotServer
|
|
@@ -352,6 +352,12 @@ def modify_arrangement_notes(song, params):
|
|
|
352
352
|
note.velocity = float(mod["velocity"])
|
|
353
353
|
if "probability" in mod:
|
|
354
354
|
note.probability = float(mod["probability"])
|
|
355
|
+
if "mute" in mod:
|
|
356
|
+
note.mute = bool(mod["mute"])
|
|
357
|
+
if "velocity_deviation" in mod:
|
|
358
|
+
note.velocity_deviation = float(mod["velocity_deviation"])
|
|
359
|
+
if "release_velocity" in mod:
|
|
360
|
+
note.release_velocity = float(mod["release_velocity"])
|
|
355
361
|
modified_count += 1
|
|
356
362
|
|
|
357
363
|
song.begin_undo_step()
|
|
@@ -557,7 +563,9 @@ def transpose_arrangement_notes(song, params):
|
|
|
557
563
|
clip = arr_clips[clip_index]
|
|
558
564
|
|
|
559
565
|
from_time = float(params.get("from_time", 0.0))
|
|
560
|
-
|
|
566
|
+
# Default span covers from from_time to end of clip, not the full clip length
|
|
567
|
+
default_span = max(0.0, clip.length - from_time) if clip.length > 0 else 32768.0
|
|
568
|
+
time_span = float(params.get("time_span", default_span))
|
|
561
569
|
|
|
562
570
|
all_notes = clip.get_notes_extended(0, 128, from_time, time_span)
|
|
563
571
|
|