livepilot 1.8.3 → 1.9.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.
@@ -14,6 +14,7 @@ from typing import Any, Optional
14
14
 
15
15
  from fastmcp import Context
16
16
 
17
+ from ..connection import AbletonConnectionError
17
18
  from ..server import mcp
18
19
  from . import _theory_engine as theory
19
20
 
@@ -59,6 +60,27 @@ def _validate_midi_path(file_path: str) -> Path:
59
60
  return p
60
61
 
61
62
 
63
+ def _midi_notes_to_beats(pm) -> list[dict]:
64
+ """Convert pretty_midi notes to beat-position dicts using the file's own tempo map.
65
+
66
+ Uses time_to_tick/resolution to preserve the MIDI file's beat grid,
67
+ regardless of the current Ableton session tempo.
68
+ """
69
+ notes_raw = []
70
+ for inst in pm.instruments:
71
+ for n in inst.notes:
72
+ start_beat = round(pm.time_to_tick(n.start) / pm.resolution, 3)
73
+ end_beat = round(pm.time_to_tick(n.end) / pm.resolution, 3)
74
+ dur_beat = round(end_beat - start_beat, 3)
75
+ notes_raw.append({
76
+ "pitch": n.pitch,
77
+ "start_time": start_beat,
78
+ "duration": max(dur_beat, 0.001),
79
+ "velocity": n.velocity,
80
+ })
81
+ return notes_raw
82
+
83
+
62
84
  # -- Tool 1: export_clip_midi ------------------------------------------------
63
85
 
64
86
  @mcp.tool()
@@ -127,8 +149,10 @@ def import_midi_to_clip(
127
149
  ) -> dict:
128
150
  """Load a .mid file into a session clip.
129
151
 
130
- Reads MIDI, converts timing to beats using session tempo, and writes
131
- notes into the target clip slot. Creates the clip if needed.
152
+ Reads MIDI, converts timing to beats using the file's own tempo map,
153
+ and writes notes into the target clip slot. When create_clip=True
154
+ (default), creates a new clip if the slot is empty; if a clip already
155
+ exists, clears its notes before importing.
132
156
  """
133
157
  pretty_midi = _require_pretty_midi()
134
158
  ableton = _get_ableton(ctx)
@@ -136,20 +160,8 @@ def import_midi_to_clip(
136
160
  path = _validate_midi_path(file_path)
137
161
  pm = pretty_midi.PrettyMIDI(str(path))
138
162
 
139
- session = ableton.send_command("get_session_info", {})
140
- tempo = float(session.get("tempo", 120.0))
141
-
142
- notes_raw = []
143
- for inst in pm.instruments:
144
- for n in inst.notes:
145
- start_beat = round(n.start * (tempo / 60.0), 3)
146
- dur_beat = round((n.end - n.start) * (tempo / 60.0), 3)
147
- notes_raw.append({
148
- "pitch": n.pitch,
149
- "start_time": start_beat,
150
- "duration": max(dur_beat, 0.001),
151
- "velocity": n.velocity,
152
- })
163
+ # Convert using the MIDI file's own tempo map (not session tempo)
164
+ notes_raw = _midi_notes_to_beats(pm)
153
165
 
154
166
  seen = set()
155
167
  notes = []
@@ -165,11 +177,31 @@ def import_midi_to_clip(
165
177
  default=4.0)
166
178
 
167
179
  if create_clip:
168
- ableton.send_command("create_clip", {
169
- "track_index": track_index,
170
- "clip_index": clip_index,
171
- "length": round(duration_beats, 2),
172
- })
180
+ # Check if clip already exists — only create if the slot is empty
181
+ slot_has_clip = False
182
+ try:
183
+ ableton.send_command("get_clip_info", {
184
+ "track_index": track_index,
185
+ "clip_index": clip_index,
186
+ })
187
+ slot_has_clip = True
188
+ except AbletonConnectionError:
189
+ # Slot is empty — no clip to clear
190
+ pass
191
+
192
+ if slot_has_clip:
193
+ # Clip exists — clear its notes before importing
194
+ ableton.send_command("remove_notes", {
195
+ "track_index": track_index,
196
+ "clip_index": clip_index,
197
+ })
198
+ else:
199
+ # Empty slot — create a new clip
200
+ ableton.send_command("create_clip", {
201
+ "track_index": track_index,
202
+ "clip_index": clip_index,
203
+ "length": round(duration_beats, 2),
204
+ })
173
205
 
174
206
  if notes:
175
207
  ableton.send_command("add_notes", {
@@ -178,10 +210,13 @@ def import_midi_to_clip(
178
210
  "notes": notes,
179
211
  })
180
212
 
213
+ tempo_changes = pm.get_tempo_changes()
214
+ midi_tempo = float(tempo_changes[1][0]) if len(tempo_changes[1]) > 0 else 120.0
215
+
181
216
  return {
182
217
  "note_count": len(notes),
183
218
  "duration_beats": round(duration_beats, 4),
184
- "tempo_source": tempo,
219
+ "midi_tempo": midi_tempo,
185
220
  }
186
221
 
187
222
 
@@ -135,6 +135,10 @@ def remove_notes(
135
135
  """Remove all MIDI notes in a pitch/time region. Use undo to revert. Defaults remove ALL notes in the clip."""
136
136
  _validate_track_index(track_index)
137
137
  _validate_clip_index(clip_index)
138
+ if not 0 <= from_pitch <= 127:
139
+ raise ValueError("from_pitch must be 0-127")
140
+ if pitch_span < 1 or pitch_span > 128:
141
+ raise ValueError("pitch_span must be 1-128")
138
142
  params = {
139
143
  "track_index": track_index,
140
144
  "clip_index": clip_index,
@@ -1,8 +1,11 @@
1
- """Scene MCP tools — list, create, delete, duplicate, fire, rename, color, tempo.
1
+ """Scene MCP tools — list, create, delete, duplicate, fire, rename, color, tempo, matrix.
2
2
 
3
- 8 tools matching the Remote Script scenes domain.
3
+ 12 tools matching the Remote Script scenes domain.
4
4
  """
5
5
 
6
+ import json
7
+ from typing import Any, Optional
8
+
6
9
  from fastmcp import Context
7
10
 
8
11
  from ..server import mcp
@@ -87,3 +90,63 @@ def set_scene_tempo(ctx: Context, scene_index: int, tempo: float) -> dict:
87
90
  "scene_index": scene_index,
88
91
  "tempo": tempo,
89
92
  })
93
+
94
+
95
+ # ── Scene Matrix Operations ─────────────────────────────────────────────
96
+
97
+
98
+ def _ensure_list(value: Any) -> list:
99
+ if isinstance(value, str):
100
+ return json.loads(value)
101
+ return value
102
+
103
+
104
+ @mcp.tool()
105
+ def get_scene_matrix(ctx: Context) -> dict:
106
+ """Get the full session clip grid: every track x every scene.
107
+
108
+ Returns clip states (empty/stopped/playing/triggered/recording),
109
+ clip names, and colors. Use this for a bird's-eye view of the
110
+ entire session before making clip launch decisions.
111
+ """
112
+ return _get_ableton(ctx).send_command("get_scene_matrix")
113
+
114
+
115
+ @mcp.tool()
116
+ def fire_scene_clips(
117
+ ctx: Context,
118
+ scene_index: int,
119
+ track_indices: Optional[Any] = None,
120
+ ) -> dict:
121
+ """Fire a scene, optionally filtering to specific tracks.
122
+
123
+ If track_indices is omitted, fires the entire scene (all tracks).
124
+ If provided (JSON array of ints), fires only those tracks' clip slots
125
+ from the scene — useful for launching drums + bass without triggering
126
+ the lead, or building up layers gradually.
127
+ """
128
+ _validate_scene_index(scene_index)
129
+ params: dict = {"scene_index": scene_index}
130
+ if track_indices is not None:
131
+ track_indices = _ensure_list(track_indices)
132
+ for ti in track_indices:
133
+ if int(ti) < 0:
134
+ raise ValueError("track_indices must all be >= 0")
135
+ params["track_indices"] = track_indices
136
+ return _get_ableton(ctx).send_command("fire_scene_clips", params)
137
+
138
+
139
+ @mcp.tool()
140
+ def stop_all_clips(ctx: Context) -> dict:
141
+ """Stop all playing clips in the session. Panic button."""
142
+ return _get_ableton(ctx).send_command("stop_all_clips")
143
+
144
+
145
+ @mcp.tool()
146
+ def get_playing_clips(ctx: Context) -> dict:
147
+ """Get all currently playing or triggered clips.
148
+
149
+ Returns track index/name, clip index/name, and whether each clip
150
+ is actively playing or just triggered (waiting for quantization).
151
+ """
152
+ return _get_ableton(ctx).send_command("get_playing_clips")
@@ -1,6 +1,6 @@
1
- """Track MCP tools — create, delete, rename, mute, solo, arm, group fold, monitor.
1
+ """Track MCP tools — create, delete, rename, mute, solo, arm, group fold, monitor, freeze, flatten.
2
2
 
3
- 14 tools matching the Remote Script tracks domain.
3
+ 17 tools matching the Remote Script tracks domain.
4
4
  """
5
5
 
6
6
  from __future__ import annotations
@@ -173,3 +173,46 @@ def set_track_input_monitoring(ctx: Context, track_index: int, state: int) -> di
173
173
  "track_index": track_index,
174
174
  "state": state,
175
175
  })
176
+
177
+
178
+ # ── Freeze / Flatten ────────────────────────────────────────────────────
179
+
180
+
181
+ @mcp.tool()
182
+ def freeze_track(ctx: Context, track_index: int) -> dict:
183
+ """Freeze a track — render all devices to audio for CPU savings.
184
+
185
+ Freeze is async in Ableton: this initiates the render and returns
186
+ immediately. Poll get_freeze_status to check when it's done.
187
+ Freezing a track that's already frozen is a no-op.
188
+ """
189
+ _validate_track_index(track_index)
190
+ return _get_ableton(ctx).send_command("freeze_track", {
191
+ "track_index": track_index,
192
+ })
193
+
194
+
195
+ @mcp.tool()
196
+ def flatten_track(ctx: Context, track_index: int) -> dict:
197
+ """Flatten a frozen track — commit rendered audio permanently.
198
+
199
+ Destructive: replaces all devices with the rendered audio file.
200
+ The track must already be frozen. Use undo to revert.
201
+ """
202
+ _validate_track_index(track_index)
203
+ return _get_ableton(ctx).send_command("flatten_track", {
204
+ "track_index": track_index,
205
+ })
206
+
207
+
208
+ @mcp.tool()
209
+ def get_freeze_status(ctx: Context, track_index: int) -> dict:
210
+ """Check if a track is frozen.
211
+
212
+ Use after freeze_track to poll for completion, or before
213
+ flatten_track to verify the track is ready to flatten.
214
+ """
215
+ _validate_track_index(track_index)
216
+ return _get_ableton(ctx).send_command("get_freeze_status", {
217
+ "track_index": track_index,
218
+ })
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.8.3",
3
+ "version": "1.9.0",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
- "description": "Agentic production system for Ableton Live 12 — 168 tools, 17 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
5
+ "description": "Agentic production system for Ableton Live 12 — 178 tools, 17 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.8.3"
8
+ __version__ = "1.9.0"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from .server import LivePilotServer
@@ -34,7 +34,7 @@ class LivePilot(ControlSurface):
34
34
  ControlSurface.__init__(self, c_instance)
35
35
  self._server = LivePilotServer(self)
36
36
  self._server.start()
37
- self.log_message("LivePilot v1.8.1 initialized")
37
+ self.log_message("LivePilot v%s initialized" % __version__)
38
38
  self.show_message("LivePilot: Listening on port 9878")
39
39
 
40
40
  def disconnect(self):
@@ -61,6 +61,8 @@ def create_arrangement_clip(song, params):
61
61
 
62
62
  # Use loop_length as the repeat unit (defaults to source clip length)
63
63
  loop_length = float(params.get("loop_length", source_length))
64
+ if loop_length <= 0:
65
+ raise ValueError("loop_length must be > 0")
64
66
 
65
67
  name = str(params.get("name", ""))
66
68
  color_index = params.get("color_index")
@@ -86,10 +88,50 @@ def create_arrangement_clip(song, params):
86
88
  c.name = name
87
89
  if color_index is not None:
88
90
  c.color_index = int(color_index)
91
+
92
+ # When loop_length < source_length, set the internal
93
+ # loop region so only loop_length beats of content play.
94
+ # Arrangement clip timeline length is read-only in the
95
+ # LOM, but overlapping clips are handled by Ableton
96
+ # (later clips take priority), so playback is correct.
97
+ remaining = end_pos - pos
98
+ target_len = min(loop_length, remaining)
99
+ if target_len < source_length:
100
+ try:
101
+ c.looping = True
102
+ c.loop_start = 0.0
103
+ c.loop_end = target_len
104
+ except (AttributeError, RuntimeError):
105
+ pass
89
106
  break
90
107
 
91
108
  clip_count += 1
92
109
  pos += loop_length
110
+
111
+ # Trim the last clip's overshoot: if the last duplicate extends
112
+ # past end_pos, remove notes beyond the requested region and
113
+ # set loop_end so only the needed portion plays.
114
+ if clip_count > 0:
115
+ arr_clips = list(track.arrangement_clips)
116
+ for c in arr_clips:
117
+ clip_end = c.start_time + c.length
118
+ if c.start_time >= start_time and clip_end > end_pos + 0.01:
119
+ # This clip overshoots — trim its content
120
+ overshoot_start = end_pos - c.start_time
121
+ if overshoot_start > 0:
122
+ try:
123
+ c.looping = True
124
+ c.loop_start = 0.0
125
+ c.loop_end = overshoot_start
126
+ except (AttributeError, RuntimeError):
127
+ pass
128
+ # Remove notes beyond the trim point
129
+ try:
130
+ c.remove_notes_extended(
131
+ 0, 128, overshoot_start, c.length
132
+ )
133
+ except Exception:
134
+ pass
93
135
  finally:
94
136
  song.end_undo_step()
95
137
 
@@ -12,8 +12,8 @@ from .utils import get_track, get_clip
12
12
  @register("get_clip_automation")
13
13
  def get_clip_automation(song, params):
14
14
  """List automation envelopes on a session clip."""
15
- track_index = params["track_index"]
16
- clip_index = params["clip_index"]
15
+ track_index = int(params["track_index"])
16
+ clip_index = int(params["clip_index"])
17
17
 
18
18
  track = get_track(song, track_index)
19
19
  clip = get_clip(song, track_index, clip_index)
@@ -80,8 +80,8 @@ def set_clip_automation(song, params):
80
80
  parameter_type: "device", "volume", "panning", "send"
81
81
  points: [{time, value, duration?}] — time relative to clip start
82
82
  """
83
- track_index = params["track_index"]
84
- clip_index = params["clip_index"]
83
+ track_index = int(params["track_index"])
84
+ clip_index = int(params["clip_index"])
85
85
  parameter_type = params["parameter_type"]
86
86
  points = params["points"]
87
87
  device_index = params.get("device_index")
@@ -98,29 +98,23 @@ def set_clip_automation(song, params):
98
98
  parameter = track.mixer_device.panning
99
99
  elif parameter_type == "send":
100
100
  if send_index is None:
101
- return {"error": {"code": "INVALID_PARAM",
102
- "message": "send_index required for send automation"}}
101
+ raise ValueError("send_index required for send automation")
103
102
  sends = list(track.mixer_device.sends)
104
103
  if send_index < 0 or send_index >= len(sends):
105
- return {"error": {"code": "INDEX_ERROR",
106
- "message": "send_index %d out of range" % send_index}}
104
+ raise IndexError("send_index %d out of range" % send_index)
107
105
  parameter = sends[send_index]
108
106
  elif parameter_type == "device":
109
107
  if device_index is None or parameter_index is None:
110
- return {"error": {"code": "INVALID_PARAM",
111
- "message": "device_index and parameter_index required"}}
108
+ raise ValueError("device_index and parameter_index required")
112
109
  devices = list(track.devices)
113
110
  if device_index < 0 or device_index >= len(devices):
114
- return {"error": {"code": "INDEX_ERROR",
115
- "message": "device_index %d out of range" % device_index}}
111
+ raise IndexError("device_index %d out of range" % device_index)
116
112
  dev_params = list(devices[device_index].parameters)
117
113
  if parameter_index < 0 or parameter_index >= len(dev_params):
118
- return {"error": {"code": "INDEX_ERROR",
119
- "message": "parameter_index %d out of range" % parameter_index}}
114
+ raise IndexError("parameter_index %d out of range" % parameter_index)
120
115
  parameter = dev_params[parameter_index]
121
116
  else:
122
- return {"error": {"code": "INVALID_PARAM",
123
- "message": "parameter_type must be device/volume/panning/send"}}
117
+ raise ValueError("parameter_type must be device/volume/panning/send")
124
118
 
125
119
  # Get or create envelope
126
120
  song.begin_undo_step()
@@ -158,8 +152,8 @@ def clear_clip_automation(song, params):
158
152
  If parameter_type is provided, clears only that parameter's envelope.
159
153
  If omitted, clears ALL envelopes on the clip.
160
154
  """
161
- track_index = params["track_index"]
162
- clip_index = params["clip_index"]
155
+ track_index = int(params["track_index"])
156
+ clip_index = int(params["clip_index"])
163
157
  parameter_type = params.get("parameter_type")
164
158
 
165
159
  track = get_track(song, track_index)
@@ -184,31 +178,25 @@ def clear_clip_automation(song, params):
184
178
  elif parameter_type == "send":
185
179
  send_index = params.get("send_index")
186
180
  if send_index is None:
187
- return {"error": {"code": "INVALID_PARAM",
188
- "message": "send_index required for send automation"}}
181
+ raise ValueError("send_index required for send automation")
189
182
  sends = list(track.mixer_device.sends)
190
183
  if send_index < 0 or send_index >= len(sends):
191
- return {"error": {"code": "INDEX_ERROR",
192
- "message": "send_index %d out of range" % send_index}}
184
+ raise IndexError("send_index %d out of range" % send_index)
193
185
  parameter = sends[send_index]
194
186
  elif parameter_type == "device":
195
187
  device_index = params.get("device_index")
196
188
  parameter_index = params.get("parameter_index")
197
189
  if device_index is None or parameter_index is None:
198
- return {"error": {"code": "INVALID_PARAM",
199
- "message": "device_index and parameter_index required"}}
190
+ raise ValueError("device_index and parameter_index required")
200
191
  devices = list(track.devices)
201
192
  if device_index < 0 or device_index >= len(devices):
202
- return {"error": {"code": "INDEX_ERROR",
203
- "message": "device_index %d out of range" % device_index}}
193
+ raise IndexError("device_index %d out of range" % device_index)
204
194
  dev_params = list(devices[device_index].parameters)
205
195
  if parameter_index < 0 or parameter_index >= len(dev_params):
206
- return {"error": {"code": "INDEX_ERROR",
207
- "message": "parameter_index %d out of range" % parameter_index}}
196
+ raise IndexError("parameter_index %d out of range" % parameter_index)
208
197
  parameter = dev_params[parameter_index]
209
198
  else:
210
- return {"error": {"code": "INVALID_PARAM",
211
- "message": "Unknown parameter_type"}}
199
+ raise ValueError("Unknown parameter_type")
212
200
 
213
201
  clip.clear_envelope(parameter)
214
202
  finally:
@@ -1,5 +1,5 @@
1
1
  """
2
- LivePilot - Scene domain handlers (8 commands).
2
+ LivePilot - Scene domain handlers (12 commands).
3
3
  """
4
4
 
5
5
  from .router import register
@@ -97,3 +97,112 @@ def set_scene_tempo(song, params):
97
97
  "index": scene_index,
98
98
  "tempo": scene.tempo,
99
99
  }
100
+
101
+
102
+ # ── Scene Matrix Operations ─────────────────────────────────────────────
103
+
104
+
105
+ @register("get_scene_matrix")
106
+ def get_scene_matrix(song, params):
107
+ """Return the full clip grid: tracks x scenes with clip states."""
108
+ tracks = list(song.tracks)
109
+ scenes = list(song.scenes)
110
+
111
+ track_headers = []
112
+ for i, t in enumerate(tracks):
113
+ track_headers.append({"index": i, "name": t.name})
114
+
115
+ scene_headers = []
116
+ for i, s in enumerate(scenes):
117
+ scene_headers.append({
118
+ "index": i,
119
+ "name": s.name,
120
+ "tempo": s.tempo if s.tempo > 0 else None,
121
+ })
122
+
123
+ matrix = []
124
+ for si, scene in enumerate(scenes):
125
+ row = []
126
+ for ti, track in enumerate(tracks):
127
+ slots = list(track.clip_slots)
128
+ if si >= len(slots):
129
+ row.append({"state": "missing"})
130
+ continue
131
+ slot = slots[si]
132
+ cell = {"state": "empty"}
133
+ if slot.has_clip and slot.clip:
134
+ clip = slot.clip
135
+ if clip.is_recording:
136
+ cell["state"] = "recording"
137
+ elif clip.is_playing:
138
+ cell["state"] = "playing"
139
+ elif clip.is_triggered:
140
+ cell["state"] = "triggered"
141
+ else:
142
+ cell["state"] = "stopped"
143
+ cell["name"] = clip.name
144
+ cell["color_index"] = clip.color_index
145
+ row.append(cell)
146
+ matrix.append(row)
147
+
148
+ return {
149
+ "tracks": track_headers,
150
+ "scenes": scene_headers,
151
+ "matrix": matrix,
152
+ }
153
+
154
+
155
+ @register("fire_scene_clips")
156
+ def fire_scene_clips(song, params):
157
+ """Fire a scene with optional track filter."""
158
+ scene_index = int(params["scene_index"])
159
+ track_indices = params.get("track_indices")
160
+
161
+ scene = get_scene(song, scene_index)
162
+
163
+ if track_indices is None:
164
+ # Fire entire scene
165
+ scene.fire()
166
+ return {"scene_index": scene_index, "fired": "all"}
167
+
168
+ # Fire specific tracks only
169
+ tracks = list(song.tracks)
170
+ fired = []
171
+ for ti in track_indices:
172
+ ti = int(ti)
173
+ if ti < 0 or ti >= len(tracks):
174
+ raise IndexError("Track index %d out of range (0..%d)" % (ti, len(tracks) - 1))
175
+ slots = list(tracks[ti].clip_slots)
176
+ if scene_index < len(slots):
177
+ slots[scene_index].fire()
178
+ fired.append(ti)
179
+
180
+ return {"scene_index": scene_index, "fired_tracks": fired}
181
+
182
+
183
+ @register("stop_all_clips")
184
+ def stop_all_clips(song, params):
185
+ """Stop all playing clips in the session."""
186
+ song.stop_all_clips()
187
+ return {"stopped": True}
188
+
189
+
190
+ @register("get_playing_clips")
191
+ def get_playing_clips(song, params):
192
+ """Return all currently playing or triggered clips."""
193
+ tracks = list(song.tracks)
194
+ clips = []
195
+ for ti, track in enumerate(tracks):
196
+ for si, slot in enumerate(track.clip_slots):
197
+ if slot.has_clip and slot.clip:
198
+ clip = slot.clip
199
+ if clip.is_playing or clip.is_triggered:
200
+ clips.append({
201
+ "track_index": ti,
202
+ "track_name": track.name,
203
+ "clip_index": si,
204
+ "clip_name": clip.name,
205
+ "is_playing": clip.is_playing,
206
+ "is_triggered": clip.is_triggered,
207
+ })
208
+ return {"clips": clips}
@@ -39,6 +39,9 @@ WRITE_COMMANDS = frozenset([
39
39
  # scenes
40
40
  "create_scene", "delete_scene", "duplicate_scene", "fire_scene",
41
41
  "set_scene_name", "set_scene_color", "set_scene_tempo",
42
+ "fire_scene_clips", "stop_all_clips",
43
+ # tracks (freeze/flatten)
44
+ "freeze_track", "flatten_track",
42
45
  # mixing
43
46
  "set_track_volume", "set_track_pan", "set_track_send",
44
47
  "set_master_volume", "set_track_routing",
@@ -57,6 +60,11 @@ WRITE_COMMANDS = frozenset([
57
60
  "clear_clip_automation",
58
61
  ])
59
62
 
63
+ # Commands that need longer timeouts (e.g., freeze renders audio)
64
+ SLOW_WRITE_COMMANDS = frozenset([
65
+ "freeze_track",
66
+ ])
67
+
60
68
 
61
69
  class LivePilotServer(object):
62
70
  """TCP server that bridges JSON commands to Ableton's main thread.
@@ -212,9 +220,14 @@ class LivePilotServer(object):
212
220
  request_id = command.get("id", "unknown")
213
221
  cmd_type = command.get("type", "")
214
222
 
215
- # Determine timeout based on read vs write
223
+ # Determine timeout based on read vs write vs slow write
216
224
  is_write = cmd_type in WRITE_COMMANDS
217
- timeout = 15 if is_write else 10
225
+ if cmd_type in SLOW_WRITE_COMMANDS:
226
+ timeout = 35
227
+ elif is_write:
228
+ timeout = 15
229
+ else:
230
+ timeout = 10
218
231
 
219
232
  # Per-command response queue
220
233
  response_queue = queue.Queue()