livepilot 1.18.2 → 1.19.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.
@@ -0,0 +1,138 @@
1
+ """Experiment baseline transport state — capture once, restore between branches.
2
+
3
+ v1.19 Item A: running N branches sequentially produces inconsistent
4
+ ``before_snapshot`` values because playback position, mute/solo/arm, and
5
+ playing-clip state drift across branches. ``undo()`` reverts command
6
+ history but doesn't guarantee transport state is identical at the start
7
+ of each branch's capture window.
8
+
9
+ Flow in ``run_experiment``:
10
+
11
+ 1. Before the first branch: ``capture_baseline(ableton)`` and stash on
12
+ the :class:`ExperimentSet`.
13
+ 2. Between branches (before capturing the next before_snapshot): call
14
+ ``restore_baseline(ableton, baseline)`` to stop transport, reset
15
+ mute/solo/arm, and pause briefly for meters to settle.
16
+
17
+ The module is deliberately thin — no LOM subscription, no state
18
+ monitoring. Just a snapshot dataclass + two functions.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import time
25
+ from dataclasses import dataclass, field
26
+ from typing import Optional
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @dataclass
32
+ class BaselineTransportState:
33
+ """Transport + per-track state captured before the first experiment branch.
34
+
35
+ Kept deliberately shallow: we don't try to freeze automation or scene
36
+ state. Those are out of scope (plan §2 "What NOT to do" — automation
37
+ drift is an accepted limitation).
38
+ """
39
+
40
+ is_playing: bool = False
41
+ song_time: float = 0.0
42
+ track_states: list[dict] = field(default_factory=list)
43
+ captured_at_ms: int = 0
44
+
45
+ def to_dict(self) -> dict:
46
+ return {
47
+ "is_playing": self.is_playing,
48
+ "song_time": self.song_time,
49
+ "track_states": list(self.track_states),
50
+ "captured_at_ms": self.captured_at_ms,
51
+ }
52
+
53
+
54
+ def capture_baseline(ableton) -> BaselineTransportState:
55
+ """Capture current transport + per-track state.
56
+
57
+ Uses ``get_session_info`` (single round-trip for all fields we need).
58
+ Returns a frozen-in-time snapshot; subsequent state drift doesn't
59
+ affect it.
60
+ """
61
+ info = ableton.send_command("get_session_info")
62
+ if not isinstance(info, dict):
63
+ info = {}
64
+
65
+ tracks = info.get("tracks") or []
66
+ track_states: list[dict] = []
67
+ for i, t in enumerate(tracks):
68
+ if not isinstance(t, dict):
69
+ continue
70
+ track_states.append({
71
+ "index": int(t.get("index", i)),
72
+ "mute": bool(t.get("mute", False)),
73
+ "solo": bool(t.get("solo", False)),
74
+ "arm": bool(t.get("arm", False)),
75
+ })
76
+
77
+ return BaselineTransportState(
78
+ is_playing=bool(info.get("is_playing", False)),
79
+ song_time=float(info.get("current_song_time", 0.0) or 0.0),
80
+ track_states=track_states,
81
+ captured_at_ms=int(time.time() * 1000),
82
+ )
83
+
84
+
85
+ def restore_baseline(
86
+ ableton,
87
+ baseline: BaselineTransportState,
88
+ stabilize_ms: int = 300,
89
+ ) -> None:
90
+ """Restore transport + per-track state to the captured baseline.
91
+
92
+ Sequence:
93
+ 1. ``stop_playback`` (halt transport — also stops any live clips)
94
+ 2. For each track: ``set_track_mute`` / ``set_track_solo`` /
95
+ ``set_track_arm`` (best-effort; per-track failure is logged,
96
+ not fatal — a single flaky track should never abort restore
97
+ for the rest).
98
+ 3. Sleep ``stabilize_ms`` milliseconds so meters settle before the
99
+ next ``before_snapshot`` reads them. Pass ``0`` in tests.
100
+
101
+ We deliberately do NOT seek the transport to ``baseline.song_time``.
102
+ Returning from stopped transport is enough — re-seeking a stopped
103
+ transport is equivalent to not moving, and on a playing transport it
104
+ introduces its own stutter artefact. If a future branch needs timeline
105
+ position consistency, add a ``jump_to_time`` call here.
106
+
107
+ Return-track arms are skipped — ``set_track_arm`` on a negative index
108
+ raises (return tracks aren't armable in Live).
109
+ """
110
+ try:
111
+ ableton.send_command("stop_playback")
112
+ except Exception as exc:
113
+ logger.debug("restore_baseline stop_playback failed: %s", exc)
114
+
115
+ for ts in baseline.track_states:
116
+ idx = ts.get("index", -1)
117
+ try:
118
+ ableton.send_command("set_track_mute", {
119
+ "track_index": idx, "mute": bool(ts.get("mute", False)),
120
+ })
121
+ except Exception as exc:
122
+ logger.debug("restore_baseline set_track_mute(%s) failed: %s", idx, exc)
123
+ try:
124
+ ableton.send_command("set_track_solo", {
125
+ "track_index": idx, "solo": bool(ts.get("solo", False)),
126
+ })
127
+ except Exception as exc:
128
+ logger.debug("restore_baseline set_track_solo(%s) failed: %s", idx, exc)
129
+ if idx >= 0:
130
+ try:
131
+ ableton.send_command("set_track_arm", {
132
+ "track_index": idx, "arm": bool(ts.get("arm", False)),
133
+ })
134
+ except Exception as exc:
135
+ logger.debug("restore_baseline set_track_arm(%s) failed: %s", idx, exc)
136
+
137
+ if stabilize_ms > 0:
138
+ time.sleep(stabilize_ms / 1000.0)
@@ -445,3 +445,23 @@ def discard_experiment(experiment_id: str) -> dict:
445
445
  exp.status = "discarded"
446
446
 
447
447
  return {"discarded": True, "experiment_id": experiment_id}
448
+
449
+
450
+ # ── v1.19 Item A — between-branch baseline restore ───────────────────────────
451
+
452
+
453
+ def prepare_for_next_branch(ableton, baseline, stabilize_ms: int = 300) -> None:
454
+ """Restore baseline transport state before capturing the next branch.
455
+
456
+ Called by ``run_experiment`` between branches so each branch's
457
+ ``before_snapshot`` reads from identical starting conditions. No-op
458
+ when ``baseline`` is None (first branch — the baseline was just
459
+ captured, no drift to correct).
460
+
461
+ Thin wrapper around ``baseline.restore_baseline``; exists so the
462
+ MCP tool body stays small and the wiring is testable in isolation.
463
+ """
464
+ if baseline is None:
465
+ return
466
+ from .baseline import restore_baseline
467
+ restore_baseline(ableton, baseline, stabilize_ms=stabilize_ms)
@@ -19,6 +19,7 @@ from dataclasses import dataclass, field
19
19
  from typing import Any, Optional
20
20
 
21
21
  from ..branches import BranchSeed
22
+ from .baseline import BaselineTransportState
22
23
 
23
24
 
24
25
  @dataclass
@@ -249,6 +250,10 @@ class ExperimentSet:
249
250
  status: str = "open" # open | evaluated | committed | discarded
250
251
  winner_branch_id: Optional[str] = None
251
252
  created_at_ms: int = 0
253
+ # v1.19 Item A — transport state captured before the first branch runs
254
+ # and used to restore identical starting conditions between branches.
255
+ # See mcp_server.experiment.baseline for the snapshot / restore pair.
256
+ baseline_transport: Optional[BaselineTransportState] = None
252
257
 
253
258
  @property
254
259
  def branch_count(self) -> int:
@@ -290,7 +295,7 @@ class ExperimentSet:
290
295
  return _branch_rank_key(branch)
291
296
 
292
297
  def to_dict(self) -> dict:
293
- return {
298
+ d = {
294
299
  "experiment_id": self.experiment_id,
295
300
  "request_text": self.request_text,
296
301
  "status": self.status,
@@ -302,3 +307,6 @@ class ExperimentSet:
302
307
  for b in self.ranked_branches()
303
308
  ],
304
309
  }
310
+ if self.baseline_transport is not None:
311
+ d["baseline_transport"] = self.baseline_transport.to_dict()
312
+ return d
@@ -343,11 +343,33 @@ async def run_experiment(
343
343
  # Import compiler
344
344
  from ..semantic_moves import registry, compiler
345
345
 
346
+ # v1.19 Item A — capture baseline transport state BEFORE any branch runs.
347
+ # Each branch's before_snapshot is only comparable if it starts from the
348
+ # same reference state. Without this, live testing (v1.18.0 Test 8) showed
349
+ # 3 branches produce wildly inconsistent before_snapshot.track_meters[0].level
350
+ # values — clip stopped mid-experiment between branches.
351
+ if experiment.baseline_transport is None:
352
+ from .baseline import capture_baseline
353
+ try:
354
+ experiment.baseline_transport = capture_baseline(ableton)
355
+ except Exception as exc:
356
+ logger.debug("baseline capture failed: %s", exc)
357
+ experiment.baseline_transport = None
358
+
346
359
  results = []
360
+ pending_seen = 0
347
361
  for branch in experiment.branches:
348
362
  if branch.status != "pending":
349
363
  continue
350
364
 
365
+ # Between branches (not before the first), restore the baseline so
366
+ # the next before_snapshot reads from the same reference state.
367
+ if pending_seen > 0:
368
+ engine.prepare_for_next_branch(
369
+ ableton, experiment.baseline_transport, stabilize_ms=300,
370
+ )
371
+ pending_seen += 1
372
+
351
373
  # PR3: respect a pre-existing compiled_plan on the branch (freeform /
352
374
  # synthesis / composer producers bring their own). Only compile from
353
375
  # move_id when the branch arrived without a plan — which requires a
@@ -301,6 +301,7 @@ from .stuckness_detector import tools as stuckness_tools # noqa: F401, E40
301
301
  from .wonder_mode import tools as wonder_mode_tools # noqa: F401, E402
302
302
  from .session_continuity import tools as session_cont_tools # noqa: F401, E402
303
303
  from .creative_constraints import tools as constraints_tools # noqa: F401, E402
304
+ from .creative_director import tools as creative_director_tools # noqa: F401, E402
304
305
  from .device_forge import tools as device_forge_tools # noqa: F401, E402
305
306
  from .sample_engine import tools as sample_engine_tools # noqa: F401, E402
306
307
  from .atlas import tools as atlas_tools # noqa: F401, E402
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.18.2",
3
+ "version": "1.19.0",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 427 tools, 52 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
5
+ "description": "Agentic production system for Ableton Live 12 — 429 tools, 53 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.18.2"
8
+ __version__ = "1.19.0"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
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": "427-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
4
+ "description": "429-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.18.2",
9
+ "version": "1.19.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "livepilot",
14
- "version": "1.18.2",
14
+ "version": "1.19.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }