livepilot 1.16.0 → 1.17.0
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 +344 -5
- package/README.md +16 -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/atlas/__init__.py +85 -0
- package/mcp_server/atlas/device_atlas.json +3183 -382
- package/mcp_server/atlas/device_techniques_index.json +1510 -0
- package/mcp_server/atlas/enrichments/__init__.py +1 -0
- package/mcp_server/atlas/enrichments/audio_effects/amp.yaml +112 -0
- package/mcp_server/atlas/enrichments/audio_effects/audio_effect_rack.yaml +77 -0
- package/mcp_server/atlas/enrichments/audio_effects/cabinet.yaml +81 -0
- package/mcp_server/atlas/enrichments/audio_effects/corpus.yaml +128 -0
- package/mcp_server/atlas/enrichments/audio_effects/envelope_follower.yaml +99 -0
- package/mcp_server/atlas/enrichments/audio_effects/external_audio_effect.yaml +64 -0
- package/mcp_server/atlas/enrichments/audio_effects/looper.yaml +85 -0
- package/mcp_server/atlas/enrichments/audio_effects/pitch_hack.yaml +61 -0
- package/mcp_server/atlas/enrichments/audio_effects/pitchloop89.yaml +111 -0
- package/mcp_server/atlas/enrichments/audio_effects/re_enveloper.yaml +51 -0
- package/mcp_server/atlas/enrichments/audio_effects/resonators.yaml +121 -0
- package/mcp_server/atlas/enrichments/audio_effects/snipper.yaml +53 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_blur.yaml +64 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectrum.yaml +61 -0
- package/mcp_server/atlas/enrichments/audio_effects/tuner.yaml +43 -0
- package/mcp_server/atlas/enrichments/audio_effects/utility.yaml +118 -0
- package/mcp_server/atlas/enrichments/audio_effects/vocoder.yaml +94 -0
- package/mcp_server/atlas/enrichments/instruments/analog.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/bass.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/bell_tower.yaml +75 -0
- package/mcp_server/atlas/enrichments/instruments/collision.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/drift.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/drum_rack.yaml +142 -0
- package/mcp_server/atlas/enrichments/instruments/electric.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/emit.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/granulator_iii.yaml +124 -0
- package/mcp_server/atlas/enrichments/instruments/harmonic_drone_generator.yaml +83 -0
- package/mcp_server/atlas/enrichments/instruments/impulse.yaml +47 -0
- package/mcp_server/atlas/enrichments/instruments/meld.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/operator.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/poli.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/sampler.yaml +12 -0
- package/mcp_server/atlas/enrichments/instruments/simpler.yaml +15 -0
- package/mcp_server/atlas/enrichments/instruments/sting_iftah.yaml +44 -0
- package/mcp_server/atlas/enrichments/instruments/tension.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +11 -0
- package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +11 -0
- package/mcp_server/atlas/enrichments/midi_effects/expressive_chords.yaml +38 -0
- package/mcp_server/atlas/enrichments/midi_effects/filler.yaml +49 -0
- package/mcp_server/atlas/enrichments/midi_effects/microtuner.yaml +83 -0
- package/mcp_server/atlas/enrichments/midi_effects/patterns_iftah.yaml +38 -0
- package/mcp_server/atlas/enrichments/midi_effects/phase_pattern.yaml +51 -0
- package/mcp_server/atlas/enrichments/midi_effects/polyrhythm.yaml +46 -0
- package/mcp_server/atlas/enrichments/midi_effects/retrigger.yaml +40 -0
- package/mcp_server/atlas/enrichments/midi_effects/slice_shuffler.yaml +39 -0
- package/mcp_server/atlas/enrichments/midi_effects/sq_sequencer.yaml +39 -0
- package/mcp_server/atlas/enrichments/midi_effects/stages.yaml +38 -0
- package/mcp_server/atlas/enrichments/utility/arrangement_looper.yaml +31 -0
- package/mcp_server/atlas/enrichments/utility/cv_clock_in.yaml +25 -0
- package/mcp_server/atlas/enrichments/utility/cv_clock_out.yaml +25 -0
- package/mcp_server/atlas/enrichments/utility/cv_envelope_follower.yaml +38 -0
- package/mcp_server/atlas/enrichments/utility/cv_in.yaml +26 -0
- package/mcp_server/atlas/enrichments/utility/cv_instrument.yaml +34 -0
- package/mcp_server/atlas/enrichments/utility/cv_lfo.yaml +38 -0
- package/mcp_server/atlas/enrichments/utility/cv_shaper.yaml +35 -0
- package/mcp_server/atlas/enrichments/utility/cv_triggers.yaml +26 -0
- package/mcp_server/atlas/enrichments/utility/cv_utility.yaml +37 -0
- package/mcp_server/atlas/enrichments/utility/performer.yaml +51 -0
- package/mcp_server/atlas/enrichments/utility/prearranger.yaml +36 -0
- package/mcp_server/atlas/enrichments/utility/rotating_rhythm_generator.yaml +35 -0
- package/mcp_server/atlas/enrichments/utility/surround_panner.yaml +40 -0
- package/mcp_server/atlas/enrichments/utility/variations.yaml +40 -0
- package/mcp_server/atlas/enrichments/utility/vector_map.yaml +57 -0
- package/mcp_server/atlas/tools.py +291 -0
- package/mcp_server/m4l_bridge.py +19 -2
- package/mcp_server/sample_engine/tools.py +190 -72
- package/mcp_server/server.py +18 -6
- package/mcp_server/splice_client/client.py +90 -18
- package/mcp_server/splice_client/http_bridge.py +414 -138
- package/mcp_server/splice_client/models.py +12 -0
- package/mcp_server/tools/analyzer.py +150 -1
- package/mcp_server/tools/automation.py +168 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +216 -1
- package/server.json +3 -3
|
@@ -212,6 +212,7 @@ async def get_master_spectrum(
|
|
|
212
212
|
ctx: Context,
|
|
213
213
|
window_ms: int = 0,
|
|
214
214
|
samples: int = 0,
|
|
215
|
+
sub_detail: bool = False,
|
|
215
216
|
) -> dict:
|
|
216
217
|
"""Get 8-band frequency analysis of the master bus.
|
|
217
218
|
|
|
@@ -236,6 +237,14 @@ async def get_master_spectrum(
|
|
|
236
237
|
The sampled bands are also returned as `bands_min`, `bands_max` and
|
|
237
238
|
`bands_std` so callers can see variance within the window — useful
|
|
238
239
|
for detecting transient-heavy content vs. sustained material.
|
|
240
|
+
|
|
241
|
+
BUG-2026-04-22#15 fix — sub-band resolution:
|
|
242
|
+
Pass `sub_detail=True` to attach a `sub_detail` dict with three
|
|
243
|
+
finer buckets: `sub_deep` (20-45 Hz), `sub_mid` (45-60 Hz),
|
|
244
|
+
`sub_high` (60-80 Hz). Derived from the FluCoMa mel spectrum
|
|
245
|
+
(40 bands) rather than the 8-band cache, so it requires FluCoMa
|
|
246
|
+
to be active. When FluCoMa is unavailable, sub_detail is omitted
|
|
247
|
+
with a `sub_detail_warning` field explaining why.
|
|
239
248
|
"""
|
|
240
249
|
cache = _get_spectral(ctx)
|
|
241
250
|
_require_analyzer(cache)
|
|
@@ -291,6 +300,8 @@ async def get_master_spectrum(
|
|
|
291
300
|
key_data = cache.get("key")
|
|
292
301
|
if key_data:
|
|
293
302
|
result["detected_key"] = key_data["value"]
|
|
303
|
+
if sub_detail:
|
|
304
|
+
_attach_sub_detail(cache, result)
|
|
294
305
|
return result
|
|
295
306
|
|
|
296
307
|
# Legacy instantaneous path
|
|
@@ -303,10 +314,55 @@ async def get_master_spectrum(
|
|
|
303
314
|
key_data = cache.get("key")
|
|
304
315
|
if key_data:
|
|
305
316
|
result["detected_key"] = key_data["value"]
|
|
317
|
+
if sub_detail:
|
|
318
|
+
_attach_sub_detail(cache, result)
|
|
306
319
|
|
|
307
320
|
return result
|
|
308
321
|
|
|
309
322
|
|
|
323
|
+
def _attach_sub_detail(cache, result: dict) -> None:
|
|
324
|
+
"""Compute finer sub-band breakdown (20-45, 45-60, 60-80 Hz) from
|
|
325
|
+
FluCoMa's 40-band mel spectrum and attach to the result dict.
|
|
326
|
+
|
|
327
|
+
Mel band edges are perceptual, not linear Hz — we map approximately:
|
|
328
|
+
with a standard 40-band mel filterbank from 0-20kHz, the first
|
|
329
|
+
~4 bands cover 0-80 Hz and are distributed roughly:
|
|
330
|
+
band 0: ~0-25 Hz
|
|
331
|
+
band 1: ~25-45 Hz
|
|
332
|
+
band 2: ~45-65 Hz
|
|
333
|
+
band 3: ~65-90 Hz
|
|
334
|
+
We use these mappings as approximations; exact cutoffs depend on
|
|
335
|
+
FluCoMa's filterbank config but this is tight enough for mixing
|
|
336
|
+
decisions (the question is "is energy in the 30 Hz or 60 Hz range?").
|
|
337
|
+
"""
|
|
338
|
+
mel_snap = cache.get("mel_bands")
|
|
339
|
+
if not mel_snap or not mel_snap.get("value"):
|
|
340
|
+
result["sub_detail_warning"] = (
|
|
341
|
+
"FluCoMa mel spectrum not available — sub_detail requires "
|
|
342
|
+
"FluCoMa active on the M4L analyzer. Use check_flucoma to diagnose."
|
|
343
|
+
)
|
|
344
|
+
return
|
|
345
|
+
mel_bands = mel_snap["value"]
|
|
346
|
+
if not isinstance(mel_bands, list) or len(mel_bands) < 4:
|
|
347
|
+
result["sub_detail_warning"] = (
|
|
348
|
+
f"Mel spectrum has {len(mel_bands) if isinstance(mel_bands, list) else 0} "
|
|
349
|
+
"bands — need at least 4 for sub_detail decomposition."
|
|
350
|
+
)
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
def _mean(indices):
|
|
354
|
+
vals = [float(mel_bands[i]) for i in indices if i < len(mel_bands)]
|
|
355
|
+
return round(sum(vals) / len(vals), 4) if vals else 0.0
|
|
356
|
+
|
|
357
|
+
result["sub_detail"] = {
|
|
358
|
+
"sub_deep": _mean([0, 1]), # ~0-45 Hz (kick fundamental)
|
|
359
|
+
"sub_mid": _mean([2]), # ~45-60 Hz (808 body / kick upper)
|
|
360
|
+
"sub_high": _mean([3]), # ~60-80 Hz (bass guitar low, sub-bass crossover)
|
|
361
|
+
"age_ms": mel_snap.get("age_ms"),
|
|
362
|
+
"source": "flucoma_mel_40",
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
|
|
310
366
|
def _sanitize_pitch(pitch: Optional[dict]) -> Optional[dict]:
|
|
311
367
|
"""Validate a pitch reading from the M4L analyzer (BUG-F1).
|
|
312
368
|
|
|
@@ -751,7 +807,10 @@ async def add_drum_rack_pad(
|
|
|
751
807
|
"method": "native_12_4",
|
|
752
808
|
}
|
|
753
809
|
"""
|
|
754
|
-
|
|
810
|
+
# _simpler_post_load_hygiene is already imported at module scope
|
|
811
|
+
# (line 29). Do not re-import inline — the earlier inline form used
|
|
812
|
+
# the wrong relative path (..; should've been .) and crashed at
|
|
813
|
+
# runtime with "No module named 'mcp_server._analyzer_engine'".
|
|
755
814
|
ableton = ctx.lifespan_context["ableton"]
|
|
756
815
|
caps = _live_caps(ctx)
|
|
757
816
|
if not caps.has_replace_sample_native:
|
|
@@ -1534,6 +1593,96 @@ async def verify_device_health(
|
|
|
1534
1593
|
}
|
|
1535
1594
|
|
|
1536
1595
|
|
|
1596
|
+
@mcp.tool()
|
|
1597
|
+
async def verify_all_devices_health(
|
|
1598
|
+
ctx: Context,
|
|
1599
|
+
test_midi_note: int = 60,
|
|
1600
|
+
test_velocity: int = 100,
|
|
1601
|
+
test_duration_ms: int = 250,
|
|
1602
|
+
threshold: float = 0.005,
|
|
1603
|
+
skip_audio_tracks: bool = True,
|
|
1604
|
+
skip_empty_tracks: bool = True,
|
|
1605
|
+
) -> dict:
|
|
1606
|
+
"""Run verify_device_health across every eligible track in one call.
|
|
1607
|
+
|
|
1608
|
+
Session-wide silent-track detector. Useful right after opening a
|
|
1609
|
+
project to surface dead plugins before mixing. Serial execution —
|
|
1610
|
+
firing notes in parallel would make the meter readings ambiguous.
|
|
1611
|
+
|
|
1612
|
+
skip_audio_tracks: audio tracks have no MIDI input, skip them (default True)
|
|
1613
|
+
skip_empty_tracks: tracks without any instrument also skip (default True)
|
|
1614
|
+
|
|
1615
|
+
Returns: {
|
|
1616
|
+
"ok": bool,
|
|
1617
|
+
"tracks_tested": int,
|
|
1618
|
+
"alive": [track_index...],
|
|
1619
|
+
"dead": [{track_index, track_name, peak_level}...],
|
|
1620
|
+
"skipped": [{track_index, reason}...],
|
|
1621
|
+
}
|
|
1622
|
+
"""
|
|
1623
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
1624
|
+
try:
|
|
1625
|
+
session = ableton.send_command("get_session_info", {})
|
|
1626
|
+
except Exception as exc:
|
|
1627
|
+
return {"ok": False, "error": f"get_session_info failed: {exc}"}
|
|
1628
|
+
if not isinstance(session, dict):
|
|
1629
|
+
return {"ok": False, "error": "Unexpected get_session_info response"}
|
|
1630
|
+
|
|
1631
|
+
tracks = session.get("tracks", []) or []
|
|
1632
|
+
alive: list = []
|
|
1633
|
+
dead: list = []
|
|
1634
|
+
skipped: list = []
|
|
1635
|
+
|
|
1636
|
+
for t in tracks:
|
|
1637
|
+
tid = t.get("index")
|
|
1638
|
+
tname = t.get("name", f"Track {tid}")
|
|
1639
|
+
if tid is None:
|
|
1640
|
+
continue
|
|
1641
|
+
is_audio = bool(t.get("is_audio_track") or t.get("type") == "audio")
|
|
1642
|
+
if skip_audio_tracks and is_audio:
|
|
1643
|
+
skipped.append({"track_index": tid, "track_name": tname,
|
|
1644
|
+
"reason": "audio_track_no_midi_input"})
|
|
1645
|
+
continue
|
|
1646
|
+
devices = t.get("devices") or []
|
|
1647
|
+
if skip_empty_tracks and not devices:
|
|
1648
|
+
skipped.append({"track_index": tid, "track_name": tname,
|
|
1649
|
+
"reason": "no_devices_on_track"})
|
|
1650
|
+
continue
|
|
1651
|
+
|
|
1652
|
+
# Run the per-track health check.
|
|
1653
|
+
result = await verify_device_health(
|
|
1654
|
+
ctx, track_index=tid,
|
|
1655
|
+
test_midi_note=test_midi_note,
|
|
1656
|
+
test_velocity=test_velocity,
|
|
1657
|
+
test_duration_ms=test_duration_ms,
|
|
1658
|
+
threshold=threshold,
|
|
1659
|
+
)
|
|
1660
|
+
if not result.get("ok"):
|
|
1661
|
+
skipped.append({"track_index": tid, "track_name": tname,
|
|
1662
|
+
"reason": result.get("error", "health_check_failed")})
|
|
1663
|
+
continue
|
|
1664
|
+
if result.get("alive"):
|
|
1665
|
+
alive.append(tid)
|
|
1666
|
+
else:
|
|
1667
|
+
dead.append({
|
|
1668
|
+
"track_index": tid,
|
|
1669
|
+
"track_name": tname,
|
|
1670
|
+
"peak_level": result.get("peak_level", 0),
|
|
1671
|
+
})
|
|
1672
|
+
|
|
1673
|
+
return {
|
|
1674
|
+
"ok": True,
|
|
1675
|
+
"tracks_tested": len(alive) + len(dead),
|
|
1676
|
+
"alive": alive,
|
|
1677
|
+
"dead": dead,
|
|
1678
|
+
"skipped": skipped,
|
|
1679
|
+
"summary": (
|
|
1680
|
+
f"{len(alive)} alive, {len(dead)} dead, "
|
|
1681
|
+
f"{len(skipped)} skipped out of {len(tracks)} total tracks"
|
|
1682
|
+
),
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
|
|
1537
1686
|
@mcp.tool()
|
|
1538
1687
|
def get_momentary_loudness(ctx: Context) -> dict:
|
|
1539
1688
|
"""Get EBU R128 momentary LUFS + true peak from FluCoMa.
|
|
@@ -602,3 +602,171 @@ def analyze_for_automation(
|
|
|
602
602
|
"spectrum": spectrum,
|
|
603
603
|
"suggestions": suggestions,
|
|
604
604
|
}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ── Track-level arrangement automation workaround (T5, 2026-04-22 handoff) ─
|
|
608
|
+
#
|
|
609
|
+
# The Live LOM has a well-known gap: you cannot directly write a
|
|
610
|
+
# TRACK-level automation envelope into arrangement between clips or
|
|
611
|
+
# outside any clip. You can only write CLIP-level automation (which
|
|
612
|
+
# lives inside a specific clip). The workaround is the classic
|
|
613
|
+
# session-clip + record-to-arrangement dance:
|
|
614
|
+
#
|
|
615
|
+
# 1. Build a session clip with the automation (set_clip_automation)
|
|
616
|
+
# 2. Arm the track
|
|
617
|
+
# 3. Jump to the target beat
|
|
618
|
+
# 4. Start arrangement recording
|
|
619
|
+
# 5. Fire the session clip
|
|
620
|
+
# 6. Wait for the clip to play through at tempo
|
|
621
|
+
# 7. Stop recording
|
|
622
|
+
# 8. Stop the session clip (return control to arrangement)
|
|
623
|
+
#
|
|
624
|
+
# The recorded arrangement clip has the automation rendered as a native
|
|
625
|
+
# arrangement envelope — which IS writable by Live even when the LOM
|
|
626
|
+
# disallows direct API access.
|
|
627
|
+
|
|
628
|
+
@mcp.tool()
|
|
629
|
+
async def set_arrangement_automation_via_session_record(
|
|
630
|
+
ctx: Context,
|
|
631
|
+
track_index: int,
|
|
632
|
+
parameter_type: str,
|
|
633
|
+
points: list | str,
|
|
634
|
+
target_beat: float,
|
|
635
|
+
duration_beats: float,
|
|
636
|
+
session_clip_slot: int = 0,
|
|
637
|
+
device_index: Optional[int] = None,
|
|
638
|
+
parameter_index: Optional[int] = None,
|
|
639
|
+
send_index: Optional[int] = None,
|
|
640
|
+
cleanup_session_clip: bool = True,
|
|
641
|
+
) -> dict:
|
|
642
|
+
"""Write an arrangement automation envelope at a specific beat via session record.
|
|
643
|
+
|
|
644
|
+
Workaround for the Live LOM limitation that prevents direct writing of
|
|
645
|
+
track-level arrangement automation outside of an existing arrangement
|
|
646
|
+
clip. Creates a temporary session clip with the automation, arms the
|
|
647
|
+
track, records the clip into arrangement at target_beat, then cleans
|
|
648
|
+
up. The recorded arrangement clip has the automation baked in.
|
|
649
|
+
|
|
650
|
+
**Status: LIVE** as of 2026-04-22. Uses a two-phase protocol so Live's
|
|
651
|
+
main thread never blocks: phase 1 (start) fires the session clip into
|
|
652
|
+
arrangement record; this tool then asyncio.sleeps for the expected
|
|
653
|
+
record duration (computed from the live tempo that phase 1 returns);
|
|
654
|
+
phase 2 (complete) stops record, cleans up, and returns the new
|
|
655
|
+
arrangement clip's index + start/length. Total wall time ≈
|
|
656
|
+
duration_beats × 60/tempo + 0.5s handler overhead.
|
|
657
|
+
|
|
658
|
+
parameter_type: "device" | "volume" | "panning" | "send"
|
|
659
|
+
points: [{time, value, duration?}] — time relative to
|
|
660
|
+
the session clip's start (0.0 = first beat)
|
|
661
|
+
target_beat: where the recording should begin in the
|
|
662
|
+
arrangement timeline (beats)
|
|
663
|
+
duration_beats: how long to record (usually matches the session
|
|
664
|
+
clip's natural length, but may be longer for
|
|
665
|
+
multiple repeats)
|
|
666
|
+
session_clip_slot: which session clip slot to use (default 0 — must
|
|
667
|
+
be EMPTY or its current content will be overwritten)
|
|
668
|
+
device_index,
|
|
669
|
+
parameter_index: required for parameter_type="device"
|
|
670
|
+
send_index: required for parameter_type="send" (0=A, 1=B)
|
|
671
|
+
cleanup_session_clip: delete the temp session clip after recording
|
|
672
|
+
completes (default True)
|
|
673
|
+
|
|
674
|
+
Returns a dict with the recorded arrangement clip index + verification
|
|
675
|
+
info, or an error if any step failed. Because this orchestrates a
|
|
676
|
+
real-time recording, any of the steps can fail at runtime (track
|
|
677
|
+
not armable, session clip already running, transport not cooperating).
|
|
678
|
+
Each failure is reported with the stage it happened at.
|
|
679
|
+
"""
|
|
680
|
+
if track_index < 0:
|
|
681
|
+
raise ValueError("track_index must be >= 0 (track-level automation only supports regular tracks)")
|
|
682
|
+
if parameter_type not in ("device", "volume", "panning", "send"):
|
|
683
|
+
raise ValueError("parameter_type must be 'device', 'volume', 'panning', or 'send'")
|
|
684
|
+
if parameter_type == "device":
|
|
685
|
+
if device_index is None or parameter_index is None:
|
|
686
|
+
raise ValueError("device_index and parameter_index required for parameter_type='device'")
|
|
687
|
+
if parameter_type == "send" and send_index is None:
|
|
688
|
+
raise ValueError("send_index required for parameter_type='send'")
|
|
689
|
+
points_list = _ensure_list(points)
|
|
690
|
+
if not points_list:
|
|
691
|
+
raise ValueError("points list cannot be empty")
|
|
692
|
+
if target_beat < 0:
|
|
693
|
+
raise ValueError("target_beat must be >= 0")
|
|
694
|
+
if duration_beats <= 0:
|
|
695
|
+
raise ValueError("duration_beats must be > 0")
|
|
696
|
+
|
|
697
|
+
ableton = _get_ableton(ctx)
|
|
698
|
+
|
|
699
|
+
# Two-phase protocol — the start handler can't block Live's main thread
|
|
700
|
+
# for the full recording duration (audio dropouts, UI freezes). So:
|
|
701
|
+
# 1. start — writes session clip, arms, seeks, fires, enables record
|
|
702
|
+
# 2. MCP-side asyncio.sleep for duration + buffer
|
|
703
|
+
# 3. complete — stops record, cleans up, returns arrangement clip info
|
|
704
|
+
# The session-clip route is unchanged; the orchestration moved to us.
|
|
705
|
+
start_params: dict = {
|
|
706
|
+
"track_index": track_index,
|
|
707
|
+
"parameter_type": parameter_type,
|
|
708
|
+
"points": points_list,
|
|
709
|
+
"target_beat": target_beat,
|
|
710
|
+
"duration_beats": duration_beats,
|
|
711
|
+
"session_clip_slot": session_clip_slot,
|
|
712
|
+
}
|
|
713
|
+
if device_index is not None:
|
|
714
|
+
start_params["device_index"] = device_index
|
|
715
|
+
if parameter_index is not None:
|
|
716
|
+
start_params["parameter_index"] = parameter_index
|
|
717
|
+
if send_index is not None:
|
|
718
|
+
start_params["send_index"] = send_index
|
|
719
|
+
|
|
720
|
+
start_result = ableton.send_command(
|
|
721
|
+
"arrangement_automation_via_session_record_start",
|
|
722
|
+
start_params,
|
|
723
|
+
)
|
|
724
|
+
if not isinstance(start_result, dict) or start_result.get("status") != "recording":
|
|
725
|
+
return {
|
|
726
|
+
"ok": False,
|
|
727
|
+
"phase": "start",
|
|
728
|
+
"error": "arrangement record failed to engage",
|
|
729
|
+
"details": start_result,
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
# Compute sleep: duration_beats * 60/tempo seconds + 0.5s buffer for
|
|
733
|
+
# Live's record-arm latency. Tempo comes back from the start handler
|
|
734
|
+
# (fresh Song.tempo read) so we don't hardcode 120.
|
|
735
|
+
import asyncio
|
|
736
|
+
tempo = float(start_result.get("tempo") or 120.0)
|
|
737
|
+
if tempo <= 0:
|
|
738
|
+
tempo = 120.0
|
|
739
|
+
seconds_per_beat = 60.0 / tempo
|
|
740
|
+
sleep_sec = duration_beats * seconds_per_beat + 0.5
|
|
741
|
+
# Safety ceiling — if something is misconfigured we shouldn't sleep
|
|
742
|
+
# forever. 10 minutes is far beyond any realistic arrangement clip.
|
|
743
|
+
sleep_sec = min(sleep_sec, 600.0)
|
|
744
|
+
try:
|
|
745
|
+
await asyncio.sleep(sleep_sec)
|
|
746
|
+
except Exception as exc:
|
|
747
|
+
# Even if sleep fails we try to complete so we don't leave the
|
|
748
|
+
# track armed + recording.
|
|
749
|
+
logger.warning("sleep interrupted: %s", exc)
|
|
750
|
+
|
|
751
|
+
complete_params = {
|
|
752
|
+
"track_index": track_index,
|
|
753
|
+
"session_clip_slot": session_clip_slot,
|
|
754
|
+
"target_beat": target_beat,
|
|
755
|
+
"duration_beats": duration_beats,
|
|
756
|
+
"cleanup_session_clip": cleanup_session_clip,
|
|
757
|
+
}
|
|
758
|
+
complete_result = ableton.send_command(
|
|
759
|
+
"arrangement_automation_via_session_record_complete",
|
|
760
|
+
complete_params,
|
|
761
|
+
)
|
|
762
|
+
ok = (
|
|
763
|
+
isinstance(complete_result, dict)
|
|
764
|
+
and complete_result.get("status") == "completed"
|
|
765
|
+
)
|
|
766
|
+
return {
|
|
767
|
+
"ok": bool(ok),
|
|
768
|
+
"tempo": tempo,
|
|
769
|
+
"slept_sec": sleep_sec,
|
|
770
|
+
"start": start_result,
|
|
771
|
+
"complete": complete_result,
|
|
772
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
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 — 426 tools, 52 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
|
|
6
6
|
"author": "Pilot Studio",
|
|
7
7
|
"license": "BSL-1.1",
|
|
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.
|
|
8
|
+
__version__ = "1.17.0"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from . import router
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
"""
|
|
2
|
-
LivePilot - Arrangement domain handlers (
|
|
2
|
+
LivePilot - Arrangement domain handlers (21 commands).
|
|
3
|
+
|
|
4
|
+
As of 2026-04-22: adds the two-phase session-record workaround for
|
|
5
|
+
track-level arrangement automation (T5 handoff). Live's LOM can't
|
|
6
|
+
write track-level automation envelopes outside a clip directly —
|
|
7
|
+
this handler pair writes to a session clip, records it into
|
|
8
|
+
arrangement at a target beat, then cleans up.
|
|
3
9
|
"""
|
|
4
10
|
|
|
11
|
+
from .clip_automation import set_clip_automation as _set_clip_automation_handler
|
|
5
12
|
from .router import register
|
|
6
13
|
from .utils import get_track
|
|
7
14
|
|
|
@@ -866,3 +873,211 @@ def force_arrangement(song, params):
|
|
|
866
873
|
"is_playing": song.is_playing,
|
|
867
874
|
"loop": song.loop,
|
|
868
875
|
}
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
# ── Session-record arrangement automation (T5 workaround) ────────────
|
|
879
|
+
#
|
|
880
|
+
# Two-phase protocol so the MCP server can sleep between the record
|
|
881
|
+
# start and stop without blocking Live's main thread.
|
|
882
|
+
#
|
|
883
|
+
# Phase 1: _start — write session clip, arm, seek, record, fire
|
|
884
|
+
# Phase 2: _complete — stop record, stop transport, locate new
|
|
885
|
+
# arrangement clip, clean up session clip
|
|
886
|
+
#
|
|
887
|
+
# The MCP tool layer orchestrates: start → asyncio.sleep(duration * 60/tempo + 0.5) → complete.
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _fire_slot(slot):
|
|
891
|
+
"""Fire a clip slot robustly — Live's API changed between versions."""
|
|
892
|
+
# Newer Live: slot.fire() is the canonical way. `slot.clip.fire()`
|
|
893
|
+
# also works when a clip is present. Try slot-level first so empty
|
|
894
|
+
# slots (we may have just created a clip) fire the row reliably.
|
|
895
|
+
try:
|
|
896
|
+
slot.fire()
|
|
897
|
+
except AttributeError:
|
|
898
|
+
slot.clip.fire()
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _ensure_session_clip_length(slot, points, min_length=1.0):
|
|
902
|
+
"""Ensure slot has a session MIDI clip covering the points' time range.
|
|
903
|
+
|
|
904
|
+
Returns (clip, was_created). If the slot is empty we call
|
|
905
|
+
create_clip(length). If points extend past the existing clip
|
|
906
|
+
we leave it alone — the user may have intentional content before.
|
|
907
|
+
"""
|
|
908
|
+
if not slot.has_clip:
|
|
909
|
+
max_t = 0.0
|
|
910
|
+
for p in points or []:
|
|
911
|
+
t = float(p.get("time", 0))
|
|
912
|
+
d = float(p.get("duration", 0.125))
|
|
913
|
+
max_t = max(max_t, t + d)
|
|
914
|
+
length = max(min_length, max_t)
|
|
915
|
+
slot.create_clip(length)
|
|
916
|
+
return slot.clip, True
|
|
917
|
+
return slot.clip, False
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
@register("arrangement_automation_via_session_record_start")
|
|
921
|
+
def arrangement_automation_via_session_record_start(song, params):
|
|
922
|
+
"""Phase 1 of the T5 workaround.
|
|
923
|
+
|
|
924
|
+
Preps the record: ensures session clip exists with automation, arms
|
|
925
|
+
the track, seeks the arrangement to target_beat, enables record, and
|
|
926
|
+
fires the session clip. Returns tempo so the MCP layer can compute
|
|
927
|
+
the sleep duration before phase 2.
|
|
928
|
+
"""
|
|
929
|
+
track_index = int(params["track_index"])
|
|
930
|
+
session_clip_slot = int(params.get("session_clip_slot", 0))
|
|
931
|
+
target_beat = float(params["target_beat"])
|
|
932
|
+
parameter_type = params["parameter_type"]
|
|
933
|
+
points = params.get("points") or []
|
|
934
|
+
if not points:
|
|
935
|
+
raise ValueError("points cannot be empty")
|
|
936
|
+
|
|
937
|
+
track = get_track(song, track_index)
|
|
938
|
+
|
|
939
|
+
# Probe 1: can we arm this track?
|
|
940
|
+
if not getattr(track, "can_be_armed", False):
|
|
941
|
+
raise ValueError(
|
|
942
|
+
"track %d cannot be armed — session-record workaround requires "
|
|
943
|
+
"an armable (MIDI or audio input) track" % track_index
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
slots = list(track.clip_slots)
|
|
947
|
+
if session_clip_slot < 0 or session_clip_slot >= len(slots):
|
|
948
|
+
raise IndexError(
|
|
949
|
+
"session_clip_slot %d out of range (0..%d)"
|
|
950
|
+
% (session_clip_slot, len(slots) - 1)
|
|
951
|
+
)
|
|
952
|
+
slot = slots[session_clip_slot]
|
|
953
|
+
|
|
954
|
+
# Probe 2: create a session clip if the slot is empty; otherwise reuse
|
|
955
|
+
clip, created = _ensure_session_clip_length(slot, points)
|
|
956
|
+
|
|
957
|
+
# Probe 3: write the automation envelope via the existing handler.
|
|
958
|
+
# We need to tell set_clip_automation which clip_index to target.
|
|
959
|
+
# set_clip_automation takes clip_index = the slot index. Perfect.
|
|
960
|
+
clip_auto_params = dict(params)
|
|
961
|
+
clip_auto_params["clip_index"] = session_clip_slot
|
|
962
|
+
# Drop keys that set_clip_automation doesn't expect
|
|
963
|
+
for k in ("target_beat", "duration_beats", "session_clip_slot",
|
|
964
|
+
"cleanup_session_clip"):
|
|
965
|
+
clip_auto_params.pop(k, None)
|
|
966
|
+
write_result = _set_clip_automation_handler(song, clip_auto_params)
|
|
967
|
+
|
|
968
|
+
# Probe 4: seek the arrangement to target_beat. Must be done
|
|
969
|
+
# BEFORE enabling record, or the record starts at wherever the
|
|
970
|
+
# playhead currently is.
|
|
971
|
+
song.back_to_arranger = True
|
|
972
|
+
song.current_song_time = max(0.0, target_beat)
|
|
973
|
+
|
|
974
|
+
# Probe 5: arm the track. Some tracks need `current_monitoring_state`
|
|
975
|
+
# clarification, but arming alone should route the session clip's
|
|
976
|
+
# output into arrangement record on most setups.
|
|
977
|
+
track.arm = True
|
|
978
|
+
|
|
979
|
+
# Probe 6: enable arrangement record + start transport. Live needs
|
|
980
|
+
# the transport playing for record_mode to engage.
|
|
981
|
+
if not song.is_playing:
|
|
982
|
+
song.start_playing()
|
|
983
|
+
song.record_mode = True
|
|
984
|
+
|
|
985
|
+
# Fire the session clip — its automation plays into arrangement.
|
|
986
|
+
_fire_slot(slot)
|
|
987
|
+
|
|
988
|
+
return {
|
|
989
|
+
"status": "recording",
|
|
990
|
+
"track_index": track_index,
|
|
991
|
+
"session_clip_slot": session_clip_slot,
|
|
992
|
+
"target_beat": song.current_song_time,
|
|
993
|
+
"tempo": float(song.tempo),
|
|
994
|
+
"session_clip_created": created,
|
|
995
|
+
"automation_write": write_result,
|
|
996
|
+
"record_mode": bool(song.record_mode),
|
|
997
|
+
"is_playing": bool(song.is_playing),
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
@register("arrangement_automation_via_session_record_complete")
|
|
1002
|
+
def arrangement_automation_via_session_record_complete(song, params):
|
|
1003
|
+
"""Phase 2 of the T5 workaround.
|
|
1004
|
+
|
|
1005
|
+
Stops recording, stops transport, disarms the track, locates the
|
|
1006
|
+
newly-recorded arrangement clip, and optionally cleans up the
|
|
1007
|
+
temporary session clip used to source the automation.
|
|
1008
|
+
"""
|
|
1009
|
+
track_index = int(params["track_index"])
|
|
1010
|
+
session_clip_slot = int(params.get("session_clip_slot", 0))
|
|
1011
|
+
cleanup = bool(params.get("cleanup_session_clip", True))
|
|
1012
|
+
target_beat = float(params["target_beat"])
|
|
1013
|
+
duration_beats = float(params.get("duration_beats", 0))
|
|
1014
|
+
|
|
1015
|
+
track = get_track(song, track_index)
|
|
1016
|
+
slots = list(track.clip_slots)
|
|
1017
|
+
if session_clip_slot < 0 or session_clip_slot >= len(slots):
|
|
1018
|
+
raise IndexError(
|
|
1019
|
+
"session_clip_slot %d out of range" % session_clip_slot
|
|
1020
|
+
)
|
|
1021
|
+
slot = slots[session_clip_slot]
|
|
1022
|
+
|
|
1023
|
+
# 1. Stop recording and transport
|
|
1024
|
+
song.record_mode = False
|
|
1025
|
+
was_playing = song.is_playing
|
|
1026
|
+
if was_playing:
|
|
1027
|
+
song.stop_playing()
|
|
1028
|
+
|
|
1029
|
+
# 2. Stop the session clip if still playing
|
|
1030
|
+
try:
|
|
1031
|
+
if slot.has_clip and getattr(slot, "is_playing", False):
|
|
1032
|
+
slot.clip.stop()
|
|
1033
|
+
except Exception:
|
|
1034
|
+
pass
|
|
1035
|
+
|
|
1036
|
+
# 3. Disarm the track (leave as-was if never armed)
|
|
1037
|
+
try:
|
|
1038
|
+
track.arm = False
|
|
1039
|
+
except Exception:
|
|
1040
|
+
pass
|
|
1041
|
+
|
|
1042
|
+
# 4. Find the new arrangement clip at or near target_beat.
|
|
1043
|
+
# Tolerance: half a beat — Live may nudge start by quantization.
|
|
1044
|
+
track_clips = list(track.arrangement_clips)
|
|
1045
|
+
new_clip = None
|
|
1046
|
+
new_index = None
|
|
1047
|
+
tolerance = 0.5
|
|
1048
|
+
for i, c in enumerate(track_clips):
|
|
1049
|
+
if abs(float(c.start_time) - target_beat) < tolerance:
|
|
1050
|
+
new_clip = c
|
|
1051
|
+
new_index = i
|
|
1052
|
+
break
|
|
1053
|
+
|
|
1054
|
+
# 5. Cleanup the session clip if requested
|
|
1055
|
+
session_clip_deleted = False
|
|
1056
|
+
if cleanup and slot.has_clip:
|
|
1057
|
+
try:
|
|
1058
|
+
slot.delete_clip()
|
|
1059
|
+
session_clip_deleted = True
|
|
1060
|
+
except Exception:
|
|
1061
|
+
pass
|
|
1062
|
+
|
|
1063
|
+
result = {
|
|
1064
|
+
"status": "completed",
|
|
1065
|
+
"track_index": track_index,
|
|
1066
|
+
"arrangement_clip_found": new_clip is not None,
|
|
1067
|
+
"arrangement_clip_index": new_index,
|
|
1068
|
+
"session_clip_deleted": session_clip_deleted,
|
|
1069
|
+
"record_mode": bool(song.record_mode),
|
|
1070
|
+
"was_playing": was_playing,
|
|
1071
|
+
}
|
|
1072
|
+
if new_clip is not None:
|
|
1073
|
+
result["arrangement_clip_start"] = float(new_clip.start_time)
|
|
1074
|
+
result["arrangement_clip_length"] = float(new_clip.length)
|
|
1075
|
+
try:
|
|
1076
|
+
result["arrangement_clip_name"] = str(new_clip.name)
|
|
1077
|
+
except Exception:
|
|
1078
|
+
pass
|
|
1079
|
+
elif duration_beats > 0:
|
|
1080
|
+
# Guess based on expected range — helpful debug if the lookup missed
|
|
1081
|
+
result["expected_clip_start"] = target_beat
|
|
1082
|
+
result["expected_clip_end"] = target_beat + duration_beats
|
|
1083
|
+
return result
|
package/server.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.dreamrec/livepilot",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "426-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "https://github.com/dreamrec/LivePilot",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.17.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "livepilot",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.17.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
}
|