livepilot 1.9.23 → 1.10.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/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +119 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +144 -13
- package/bin/livepilot.js +87 -0
- package/installer/codex.js +147 -0
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +21 -4
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
- package/livepilot/skills/livepilot-core/references/overview.md +13 -9
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
- package/livepilot/skills/livepilot-devices/SKILL.md +16 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +19 -5
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +104 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +45 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +15 -0
- package/livepilot.mcpb +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/manifest.json +2 -2
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +357 -0
- package/mcp_server/atlas/device_atlas.json +44067 -0
- package/mcp_server/atlas/enrichments/__init__.py +111 -0
- package/mcp_server/atlas/enrichments/audio_effects/auto_filter.yaml +162 -0
- package/mcp_server/atlas/enrichments/audio_effects/beat_repeat.yaml +183 -0
- package/mcp_server/atlas/enrichments/audio_effects/channel_eq.yaml +126 -0
- package/mcp_server/atlas/enrichments/audio_effects/chorus_ensemble.yaml +149 -0
- package/mcp_server/atlas/enrichments/audio_effects/color_limiter.yaml +109 -0
- package/mcp_server/atlas/enrichments/audio_effects/compressor.yaml +159 -0
- package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb.yaml +143 -0
- package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb_pro.yaml +178 -0
- package/mcp_server/atlas/enrichments/audio_effects/delay.yaml +151 -0
- package/mcp_server/atlas/enrichments/audio_effects/drum_buss.yaml +142 -0
- package/mcp_server/atlas/enrichments/audio_effects/dynamic_tube.yaml +147 -0
- package/mcp_server/atlas/enrichments/audio_effects/echo.yaml +167 -0
- package/mcp_server/atlas/enrichments/audio_effects/eq_eight.yaml +148 -0
- package/mcp_server/atlas/enrichments/audio_effects/eq_three.yaml +121 -0
- package/mcp_server/atlas/enrichments/audio_effects/erosion.yaml +103 -0
- package/mcp_server/atlas/enrichments/audio_effects/filter_delay.yaml +173 -0
- package/mcp_server/atlas/enrichments/audio_effects/gate.yaml +130 -0
- package/mcp_server/atlas/enrichments/audio_effects/gated_delay.yaml +133 -0
- package/mcp_server/atlas/enrichments/audio_effects/glue_compressor.yaml +142 -0
- package/mcp_server/atlas/enrichments/audio_effects/grain_delay.yaml +141 -0
- package/mcp_server/atlas/enrichments/audio_effects/hybrid_reverb.yaml +160 -0
- package/mcp_server/atlas/enrichments/audio_effects/limiter.yaml +97 -0
- package/mcp_server/atlas/enrichments/audio_effects/multiband_dynamics.yaml +174 -0
- package/mcp_server/atlas/enrichments/audio_effects/overdrive.yaml +119 -0
- package/mcp_server/atlas/enrichments/audio_effects/pedal.yaml +145 -0
- package/mcp_server/atlas/enrichments/audio_effects/phaser_flanger.yaml +161 -0
- package/mcp_server/atlas/enrichments/audio_effects/redux.yaml +114 -0
- package/mcp_server/atlas/enrichments/audio_effects/reverb.yaml +190 -0
- package/mcp_server/atlas/enrichments/audio_effects/roar.yaml +159 -0
- package/mcp_server/atlas/enrichments/audio_effects/saturator.yaml +146 -0
- package/mcp_server/atlas/enrichments/audio_effects/shifter.yaml +154 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_resonator.yaml +141 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_time.yaml +164 -0
- package/mcp_server/atlas/enrichments/audio_effects/vector_delay.yaml +140 -0
- package/mcp_server/atlas/enrichments/audio_effects/vinyl_distortion.yaml +141 -0
- package/mcp_server/atlas/enrichments/instruments/analog.yaml +222 -0
- package/mcp_server/atlas/enrichments/instruments/bass.yaml +202 -0
- package/mcp_server/atlas/enrichments/instruments/collision.yaml +150 -0
- package/mcp_server/atlas/enrichments/instruments/drift.yaml +167 -0
- package/mcp_server/atlas/enrichments/instruments/electric.yaml +137 -0
- package/mcp_server/atlas/enrichments/instruments/emit.yaml +163 -0
- package/mcp_server/atlas/enrichments/instruments/meld.yaml +164 -0
- package/mcp_server/atlas/enrichments/instruments/operator.yaml +197 -0
- package/mcp_server/atlas/enrichments/instruments/poli.yaml +192 -0
- package/mcp_server/atlas/enrichments/instruments/sampler.yaml +218 -0
- package/mcp_server/atlas/enrichments/instruments/simpler.yaml +217 -0
- package/mcp_server/atlas/enrichments/instruments/tension.yaml +156 -0
- package/mcp_server/atlas/enrichments/instruments/tree_tone.yaml +162 -0
- package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +165 -0
- package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +166 -0
- package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +162 -0
- package/mcp_server/atlas/enrichments/midi_effects/arpeggiator.yaml +156 -0
- package/mcp_server/atlas/enrichments/midi_effects/bouncy_notes.yaml +93 -0
- package/mcp_server/atlas/enrichments/midi_effects/chord.yaml +147 -0
- package/mcp_server/atlas/enrichments/midi_effects/melodic_steps.yaml +97 -0
- package/mcp_server/atlas/enrichments/midi_effects/note_echo.yaml +108 -0
- package/mcp_server/atlas/enrichments/midi_effects/note_length.yaml +97 -0
- package/mcp_server/atlas/enrichments/midi_effects/pitch.yaml +76 -0
- package/mcp_server/atlas/enrichments/midi_effects/random.yaml +117 -0
- package/mcp_server/atlas/enrichments/midi_effects/rhythmic_steps.yaml +103 -0
- package/mcp_server/atlas/enrichments/midi_effects/scale.yaml +83 -0
- package/mcp_server/atlas/enrichments/midi_effects/step_arp.yaml +112 -0
- package/mcp_server/atlas/enrichments/midi_effects/velocity.yaml +119 -0
- package/mcp_server/atlas/enrichments/utility/amp.yaml +159 -0
- package/mcp_server/atlas/enrichments/utility/cabinet.yaml +109 -0
- package/mcp_server/atlas/enrichments/utility/corpus.yaml +150 -0
- package/mcp_server/atlas/enrichments/utility/resonators.yaml +131 -0
- package/mcp_server/atlas/enrichments/utility/spectrum.yaml +63 -0
- package/mcp_server/atlas/enrichments/utility/tuner.yaml +51 -0
- package/mcp_server/atlas/enrichments/utility/utility.yaml +136 -0
- package/mcp_server/atlas/enrichments/utility/vocoder.yaml +160 -0
- package/mcp_server/atlas/scanner.py +236 -0
- package/mcp_server/atlas/tools.py +224 -0
- package/mcp_server/composer/__init__.py +1 -0
- package/mcp_server/composer/engine.py +452 -0
- package/mcp_server/composer/layer_planner.py +427 -0
- package/mcp_server/composer/prompt_parser.py +329 -0
- package/mcp_server/composer/tools.py +201 -0
- package/mcp_server/connection.py +53 -8
- package/mcp_server/corpus/__init__.py +377 -0
- package/mcp_server/device_forge/__init__.py +1 -0
- package/mcp_server/device_forge/builder.py +377 -0
- package/mcp_server/device_forge/models.py +142 -0
- package/mcp_server/device_forge/templates.py +483 -0
- package/mcp_server/device_forge/tools.py +162 -0
- package/mcp_server/hook_hunter/analyzer.py +23 -0
- package/mcp_server/hook_hunter/models.py +1 -0
- package/mcp_server/hook_hunter/tools.py +4 -2
- package/mcp_server/m4l_bridge.py +1 -0
- package/mcp_server/memory/taste_graph.py +68 -1
- package/mcp_server/memory/tools.py +15 -4
- package/mcp_server/musical_intelligence/detectors.py +14 -1
- package/mcp_server/musical_intelligence/tools.py +11 -8
- package/mcp_server/persistence/__init__.py +1 -0
- package/mcp_server/persistence/base_store.py +82 -0
- package/mcp_server/persistence/project_store.py +106 -0
- package/mcp_server/persistence/taste_store.py +122 -0
- package/mcp_server/preview_studio/models.py +1 -0
- package/mcp_server/preview_studio/tools.py +56 -13
- package/mcp_server/runtime/capability.py +66 -0
- package/mcp_server/runtime/capability_probe.py +137 -0
- package/mcp_server/runtime/execution_router.py +143 -0
- package/mcp_server/runtime/live_version.py +102 -0
- package/mcp_server/runtime/remote_commands.py +87 -0
- package/mcp_server/runtime/tools.py +18 -4
- package/mcp_server/sample_engine/__init__.py +1 -0
- package/mcp_server/sample_engine/analyzer.py +216 -0
- package/mcp_server/sample_engine/critics.py +390 -0
- package/mcp_server/sample_engine/models.py +193 -0
- package/mcp_server/sample_engine/moves.py +127 -0
- package/mcp_server/sample_engine/planner.py +186 -0
- package/mcp_server/sample_engine/sources.py +540 -0
- package/mcp_server/sample_engine/techniques.py +908 -0
- package/mcp_server/sample_engine/tools.py +442 -0
- package/mcp_server/semantic_moves/__init__.py +3 -0
- package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
- package/mcp_server/semantic_moves/mix_moves.py +41 -41
- package/mcp_server/semantic_moves/performance_moves.py +13 -13
- package/mcp_server/semantic_moves/sample_compilers.py +372 -0
- package/mcp_server/semantic_moves/sound_design_moves.py +15 -15
- package/mcp_server/semantic_moves/tools.py +18 -17
- package/mcp_server/semantic_moves/transition_moves.py +16 -16
- package/mcp_server/server.py +51 -0
- package/mcp_server/services/__init__.py +1 -0
- package/mcp_server/services/motif_service.py +67 -0
- package/mcp_server/session_continuity/tracker.py +29 -1
- package/mcp_server/song_brain/builder.py +28 -1
- package/mcp_server/song_brain/models.py +4 -0
- package/mcp_server/song_brain/tools.py +20 -2
- package/mcp_server/sound_design/critics.py +89 -1
- package/mcp_server/splice_client/__init__.py +1 -0
- package/mcp_server/splice_client/client.py +347 -0
- package/mcp_server/splice_client/models.py +96 -0
- package/mcp_server/splice_client/protos/__init__.py +1 -0
- package/mcp_server/splice_client/protos/app_pb2.py +319 -0
- package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
- package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
- package/mcp_server/tools/arrangement.py +69 -0
- package/mcp_server/tools/automation.py +15 -2
- package/mcp_server/tools/devices.py +117 -6
- package/mcp_server/tools/notes.py +37 -4
- package/mcp_server/wonder_mode/diagnosis.py +5 -0
- package/mcp_server/wonder_mode/engine.py +85 -1
- package/mcp_server/wonder_mode/tools.py +6 -1
- package/package.json +12 -2
- package/remote_script/LivePilot/__init__.py +8 -1
- package/remote_script/LivePilot/arrangement.py +114 -0
- package/remote_script/LivePilot/browser.py +56 -1
- package/remote_script/LivePilot/devices.py +236 -6
- package/remote_script/LivePilot/mixing.py +8 -3
- package/remote_script/LivePilot/server.py +5 -1
- package/remote_script/LivePilot/transport.py +3 -0
- package/remote_script/LivePilot/version_detect.py +78 -0
- package/scripts/sync_metadata.py +132 -0
|
@@ -143,6 +143,48 @@ def create_arrangement_clip(
|
|
|
143
143
|
return _get_ableton(ctx).send_command("create_arrangement_clip", params)
|
|
144
144
|
|
|
145
145
|
|
|
146
|
+
@mcp.tool()
|
|
147
|
+
def create_native_arrangement_clip(
|
|
148
|
+
ctx: Context,
|
|
149
|
+
track_index: int,
|
|
150
|
+
start_time: float,
|
|
151
|
+
length: float,
|
|
152
|
+
name: str = "",
|
|
153
|
+
color_index: Optional[int] = None,
|
|
154
|
+
) -> dict:
|
|
155
|
+
"""Create an empty MIDI clip directly in Arrangement View (Live 12.1.10+).
|
|
156
|
+
|
|
157
|
+
Unlike create_arrangement_clip (which duplicates a session clip), this creates
|
|
158
|
+
a native arrangement clip with full automation envelope support — volume rides,
|
|
159
|
+
filter sweeps, send automation all work natively.
|
|
160
|
+
|
|
161
|
+
Requires Live 12.1.10+. Falls back with a clear error on older versions.
|
|
162
|
+
|
|
163
|
+
track_index: 0+ for regular MIDI tracks
|
|
164
|
+
start_time: beat position (0.0 = song start, 4.0 = bar 2 in 4/4)
|
|
165
|
+
length: clip length in beats
|
|
166
|
+
name: optional clip display name
|
|
167
|
+
color_index: optional 0-69 Ableton color
|
|
168
|
+
"""
|
|
169
|
+
_validate_track_index(track_index)
|
|
170
|
+
if start_time < 0:
|
|
171
|
+
raise ValueError("start_time must be >= 0")
|
|
172
|
+
if length <= 0:
|
|
173
|
+
raise ValueError("length must be > 0")
|
|
174
|
+
|
|
175
|
+
params = {
|
|
176
|
+
"track_index": track_index,
|
|
177
|
+
"start_time": start_time,
|
|
178
|
+
"length": length,
|
|
179
|
+
}
|
|
180
|
+
if name:
|
|
181
|
+
params["name"] = name
|
|
182
|
+
if color_index is not None:
|
|
183
|
+
params["color_index"] = color_index
|
|
184
|
+
|
|
185
|
+
return _get_ableton(ctx).send_command("create_native_arrangement_clip", params)
|
|
186
|
+
|
|
187
|
+
|
|
146
188
|
@mcp.tool()
|
|
147
189
|
def add_arrangement_notes(
|
|
148
190
|
ctx: Context,
|
|
@@ -288,6 +330,33 @@ def back_to_arranger(ctx: Context) -> dict:
|
|
|
288
330
|
return _get_ableton(ctx).send_command("back_to_arranger")
|
|
289
331
|
|
|
290
332
|
|
|
333
|
+
@mcp.tool()
|
|
334
|
+
def force_arrangement(
|
|
335
|
+
ctx: Context,
|
|
336
|
+
beat_time: float = 0,
|
|
337
|
+
loop_start: float = 0,
|
|
338
|
+
loop_length: float = 0,
|
|
339
|
+
play: bool = True,
|
|
340
|
+
) -> dict:
|
|
341
|
+
"""Force ALL tracks to follow the arrangement and start playback.
|
|
342
|
+
|
|
343
|
+
Atomically: stops all session clips, releases every track from
|
|
344
|
+
session override, sets back-to-arranger, jumps to position, and
|
|
345
|
+
starts playing. This is the "play my arrangement from the top"
|
|
346
|
+
command.
|
|
347
|
+
|
|
348
|
+
beat_time: position to start from (default 0 = beginning)
|
|
349
|
+
loop_start: loop region start in beats (default 0)
|
|
350
|
+
loop_length: loop region length in beats (0 = no loop change)
|
|
351
|
+
play: whether to start playback (default True)
|
|
352
|
+
"""
|
|
353
|
+
params: dict = {"beat_time": beat_time, "play": play}
|
|
354
|
+
if loop_length > 0:
|
|
355
|
+
params["loop_start"] = loop_start
|
|
356
|
+
params["loop_length"] = loop_length
|
|
357
|
+
return _get_ableton(ctx).send_command("force_arrangement", params)
|
|
358
|
+
|
|
359
|
+
|
|
291
360
|
@mcp.tool()
|
|
292
361
|
def get_arrangement_notes(
|
|
293
362
|
ctx: Context,
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
from typing import Any, Optional
|
|
11
11
|
|
|
12
12
|
from fastmcp import Context
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
13
14
|
|
|
14
15
|
from ..curves import generate_curve, generate_from_recipe, list_recipes
|
|
15
16
|
from ..server import mcp
|
|
@@ -27,10 +28,22 @@ def _ensure_list(v: Any) -> list:
|
|
|
27
28
|
except json.JSONDecodeError as exc:
|
|
28
29
|
raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
|
|
29
30
|
if isinstance(v, list):
|
|
30
|
-
|
|
31
|
+
normalized = []
|
|
32
|
+
for item in v:
|
|
33
|
+
if isinstance(item, BaseModel):
|
|
34
|
+
normalized.append(item.model_dump(exclude_none=True))
|
|
35
|
+
else:
|
|
36
|
+
normalized.append(item)
|
|
37
|
+
return normalized
|
|
31
38
|
return [v]
|
|
32
39
|
|
|
33
40
|
|
|
41
|
+
class AutomationPoint(BaseModel):
|
|
42
|
+
time: float
|
|
43
|
+
value: float
|
|
44
|
+
duration: Optional[float] = Field(default=None, ge=0.0)
|
|
45
|
+
|
|
46
|
+
|
|
34
47
|
@mcp.tool()
|
|
35
48
|
def get_clip_automation(
|
|
36
49
|
ctx: Context,
|
|
@@ -62,7 +75,7 @@ def set_clip_automation(
|
|
|
62
75
|
track_index: int,
|
|
63
76
|
clip_index: int,
|
|
64
77
|
parameter_type: str,
|
|
65
|
-
points:
|
|
78
|
+
points: list[AutomationPoint] | str,
|
|
66
79
|
device_index: Optional[int] = None,
|
|
67
80
|
parameter_index: Optional[int] = None,
|
|
68
81
|
send_index: Optional[int] = None,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Device MCP tools — parameters, racks, browser loading, plugin deep control.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
16 tools matching the Remote Script devices domain + M4L bridge.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
@@ -359,14 +359,15 @@ def find_and_load_device(ctx: Context, track_index: int, device_name: str) -> di
|
|
|
359
359
|
if not device_name.strip():
|
|
360
360
|
raise ValueError("device_name cannot be empty")
|
|
361
361
|
|
|
362
|
-
# Guardrail: bare Drum Rack produces silence
|
|
362
|
+
# Guardrail: bare Drum Rack produces silence unless building programmatically (12.3+)
|
|
363
363
|
if device_name.strip().lower() == "drum rack":
|
|
364
364
|
raise ValueError(
|
|
365
365
|
"Loading a bare 'Drum Rack' creates an empty rack that produces silence. "
|
|
366
|
-
"
|
|
367
|
-
"(e.g., '808 Core Kit'), then load
|
|
368
|
-
"
|
|
369
|
-
"
|
|
366
|
+
"Options: (1) search_browser(path='drums') to find a kit preset "
|
|
367
|
+
"(e.g., '808 Core Kit'), then load with load_browser_item(). "
|
|
368
|
+
"(2) On Live 12.3+: use insert_device('Drum Rack') + insert_rack_chain "
|
|
369
|
+
"+ set_drum_chain_note to build a kit programmatically. "
|
|
370
|
+
"(3) DS drum synths (DS Kick, DS Snare, DS HH) are self-contained."
|
|
370
371
|
)
|
|
371
372
|
|
|
372
373
|
result = _get_ableton(ctx).send_command("find_and_load_device", {
|
|
@@ -376,6 +377,116 @@ def find_and_load_device(ctx: Context, track_index: int, device_name: str) -> di
|
|
|
376
377
|
return _postflight_loaded_device(ctx, result)
|
|
377
378
|
|
|
378
379
|
|
|
380
|
+
@mcp.tool()
|
|
381
|
+
def insert_device(
|
|
382
|
+
ctx: Context,
|
|
383
|
+
track_index: int,
|
|
384
|
+
device_name: str,
|
|
385
|
+
position: int = -1,
|
|
386
|
+
device_index: Optional[int] = None,
|
|
387
|
+
chain_index: Optional[int] = None,
|
|
388
|
+
) -> dict:
|
|
389
|
+
"""Insert a native Live device by name — 10x faster than browser search (Live 12.3+).
|
|
390
|
+
|
|
391
|
+
Only works for native devices (Reverb, Compressor, EQ Eight, Drift, etc.).
|
|
392
|
+
For plugins, M4L devices, or presets, use find_and_load_device or load_browser_item.
|
|
393
|
+
|
|
394
|
+
track_index: 0+ for regular tracks, -1/-2 for returns, -1000 for master
|
|
395
|
+
device_name: exact device name (e.g. 'Reverb', 'Auto Filter', 'Wavetable')
|
|
396
|
+
position: device chain position (0 = first, -1 = end of chain)
|
|
397
|
+
device_index: required when inserting into a rack chain (identifies the rack)
|
|
398
|
+
chain_index: insert into this chain of a rack device (for building drum kits)
|
|
399
|
+
|
|
400
|
+
Drum Rack construction workflow (12.3+):
|
|
401
|
+
1. insert_device(track_index, 'Drum Rack') — create empty rack
|
|
402
|
+
2. insert_rack_chain(track_index, device_index) — add chains
|
|
403
|
+
3. set_drum_chain_note(chain_index, note=36) — assign C1 (kick)
|
|
404
|
+
4. insert_device(track_index, 'Simpler', — add instrument
|
|
405
|
+
device_index=rack_idx, chain_index=0) into chain
|
|
406
|
+
|
|
407
|
+
On Live < 12.3: returns an error suggesting find_and_load_device instead.
|
|
408
|
+
"""
|
|
409
|
+
_validate_track_index(track_index)
|
|
410
|
+
if not device_name.strip():
|
|
411
|
+
raise ValueError("device_name cannot be empty")
|
|
412
|
+
|
|
413
|
+
params = {
|
|
414
|
+
"track_index": track_index,
|
|
415
|
+
"device_name": device_name,
|
|
416
|
+
"position": position,
|
|
417
|
+
}
|
|
418
|
+
if device_index is not None:
|
|
419
|
+
params["device_index"] = device_index
|
|
420
|
+
if chain_index is not None:
|
|
421
|
+
if device_index is None:
|
|
422
|
+
raise ValueError("device_index is required when chain_index is provided")
|
|
423
|
+
_validate_device_index(device_index)
|
|
424
|
+
_validate_chain_index(chain_index)
|
|
425
|
+
params["chain_index"] = chain_index
|
|
426
|
+
|
|
427
|
+
result = _get_ableton(ctx).send_command("insert_device", params)
|
|
428
|
+
return _postflight_loaded_device(ctx, result)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@mcp.tool()
|
|
432
|
+
def insert_rack_chain(
|
|
433
|
+
ctx: Context,
|
|
434
|
+
track_index: int,
|
|
435
|
+
device_index: int,
|
|
436
|
+
position: int = -1,
|
|
437
|
+
) -> dict:
|
|
438
|
+
"""Insert a new chain into a Rack device — Instrument Rack, Audio Effect Rack, or Drum Rack (Live 12.3+).
|
|
439
|
+
|
|
440
|
+
Use with insert_device + set_drum_chain_note to build Drum Racks from scratch:
|
|
441
|
+
1. insert_device(track, 'Drum Rack') to create the rack
|
|
442
|
+
2. insert_rack_chain(track, rack_device_index) to add chains
|
|
443
|
+
3. set_drum_chain_note(chain_index=0, note=36) to assign C1 (kick)
|
|
444
|
+
4. insert_device(track, 'Simpler', device_index=rack, chain_index=0) into chain
|
|
445
|
+
|
|
446
|
+
track_index: track containing the rack
|
|
447
|
+
device_index: rack device index on the track
|
|
448
|
+
position: chain position (-1 = append to end)
|
|
449
|
+
"""
|
|
450
|
+
_validate_track_index(track_index)
|
|
451
|
+
_validate_device_index(device_index)
|
|
452
|
+
|
|
453
|
+
return _get_ableton(ctx).send_command("insert_rack_chain", {
|
|
454
|
+
"track_index": track_index,
|
|
455
|
+
"device_index": device_index,
|
|
456
|
+
"position": position,
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@mcp.tool()
|
|
461
|
+
def set_drum_chain_note(
|
|
462
|
+
ctx: Context,
|
|
463
|
+
track_index: int,
|
|
464
|
+
device_index: int,
|
|
465
|
+
chain_index: int,
|
|
466
|
+
note: int,
|
|
467
|
+
) -> dict:
|
|
468
|
+
"""Set which MIDI note triggers a Drum Rack chain (Live 12.3+).
|
|
469
|
+
|
|
470
|
+
Standard drum mapping:
|
|
471
|
+
C1 (36) = Kick, D1 (38) = Snare, F#1 (42) = Closed HH,
|
|
472
|
+
A#1 (46) = Open HH, C#2 (49) = Crash, D#2 (51) = Ride
|
|
473
|
+
|
|
474
|
+
note: MIDI note 0-127, or -1 for 'All Notes'
|
|
475
|
+
"""
|
|
476
|
+
_validate_track_index(track_index)
|
|
477
|
+
_validate_device_index(device_index)
|
|
478
|
+
_validate_chain_index(chain_index)
|
|
479
|
+
if note < -1 or note > 127:
|
|
480
|
+
raise ValueError("note must be -1 (All Notes) or 0-127")
|
|
481
|
+
|
|
482
|
+
return _get_ableton(ctx).send_command("set_drum_chain_note", {
|
|
483
|
+
"track_index": track_index,
|
|
484
|
+
"device_index": device_index,
|
|
485
|
+
"chain_index": chain_index,
|
|
486
|
+
"note": note,
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
|
|
379
490
|
@mcp.tool()
|
|
380
491
|
def set_simpler_playback_mode(
|
|
381
492
|
ctx: Context,
|
|
@@ -9,6 +9,7 @@ import json
|
|
|
9
9
|
from typing import Any, Optional
|
|
10
10
|
|
|
11
11
|
from fastmcp import Context
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
12
13
|
|
|
13
14
|
from ..server import mcp
|
|
14
15
|
|
|
@@ -25,9 +26,36 @@ def _ensure_list(value: Any) -> list:
|
|
|
25
26
|
return json.loads(value)
|
|
26
27
|
except json.JSONDecodeError as exc:
|
|
27
28
|
raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
|
|
29
|
+
if isinstance(value, list):
|
|
30
|
+
normalized = []
|
|
31
|
+
for item in value:
|
|
32
|
+
if isinstance(item, BaseModel):
|
|
33
|
+
normalized.append(item.model_dump(exclude_none=True))
|
|
34
|
+
else:
|
|
35
|
+
normalized.append(item)
|
|
36
|
+
return normalized
|
|
28
37
|
return value
|
|
29
38
|
|
|
30
39
|
|
|
40
|
+
class NoteSpec(BaseModel):
|
|
41
|
+
pitch: int = Field(ge=0, le=127)
|
|
42
|
+
start_time: float
|
|
43
|
+
duration: float = Field(gt=0)
|
|
44
|
+
velocity: Optional[float] = Field(default=None, ge=0.0, le=127.0)
|
|
45
|
+
probability: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
|
46
|
+
velocity_deviation: Optional[float] = Field(default=None, ge=-127.0, le=127.0)
|
|
47
|
+
release_velocity: Optional[float] = Field(default=None, ge=0.0, le=127.0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NoteModification(BaseModel):
|
|
51
|
+
note_id: int
|
|
52
|
+
pitch: Optional[int] = Field(default=None, ge=0, le=127)
|
|
53
|
+
start_time: Optional[float] = None
|
|
54
|
+
duration: Optional[float] = Field(default=None, gt=0)
|
|
55
|
+
velocity: Optional[float] = Field(default=None, ge=0.0, le=127.0)
|
|
56
|
+
probability: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
|
57
|
+
|
|
58
|
+
|
|
31
59
|
def _validate_track_index(track_index: int):
|
|
32
60
|
"""Validate track index. Must be >= 0 for regular tracks."""
|
|
33
61
|
if track_index < 0:
|
|
@@ -78,7 +106,7 @@ def _validate_note(note: dict):
|
|
|
78
106
|
|
|
79
107
|
|
|
80
108
|
@mcp.tool()
|
|
81
|
-
def add_notes(ctx: Context, track_index: int, clip_index: int, notes:
|
|
109
|
+
def add_notes(ctx: Context, track_index: int, clip_index: int, notes: list[NoteSpec] | str) -> dict:
|
|
82
110
|
"""Add MIDI notes to a clip. notes is a JSON array: [{pitch, start_time, duration, velocity?, probability?, velocity_deviation?, release_velocity?}]."""
|
|
83
111
|
_validate_track_index(track_index)
|
|
84
112
|
_validate_clip_index(clip_index)
|
|
@@ -157,7 +185,7 @@ def remove_notes(
|
|
|
157
185
|
|
|
158
186
|
|
|
159
187
|
@mcp.tool()
|
|
160
|
-
def remove_notes_by_id(ctx: Context, track_index: int, clip_index: int, note_ids:
|
|
188
|
+
def remove_notes_by_id(ctx: Context, track_index: int, clip_index: int, note_ids: list[int] | str) -> dict:
|
|
161
189
|
"""Remove specific MIDI notes by their IDs (JSON array of ints). Use undo to revert."""
|
|
162
190
|
_validate_track_index(track_index)
|
|
163
191
|
_validate_clip_index(clip_index)
|
|
@@ -172,7 +200,12 @@ def remove_notes_by_id(ctx: Context, track_index: int, clip_index: int, note_ids
|
|
|
172
200
|
|
|
173
201
|
|
|
174
202
|
@mcp.tool()
|
|
175
|
-
def modify_notes(
|
|
203
|
+
def modify_notes(
|
|
204
|
+
ctx: Context,
|
|
205
|
+
track_index: int,
|
|
206
|
+
clip_index: int,
|
|
207
|
+
modifications: list[NoteModification] | str,
|
|
208
|
+
) -> dict:
|
|
176
209
|
"""Modify existing MIDI notes by ID. modifications is a JSON array: [{note_id, pitch?, start_time?, duration?, velocity?, probability?}]."""
|
|
177
210
|
_validate_track_index(track_index)
|
|
178
211
|
_validate_clip_index(clip_index)
|
|
@@ -202,7 +235,7 @@ def duplicate_notes(
|
|
|
202
235
|
ctx: Context,
|
|
203
236
|
track_index: int,
|
|
204
237
|
clip_index: int,
|
|
205
|
-
note_ids:
|
|
238
|
+
note_ids: list[int] | str,
|
|
206
239
|
time_offset: float = 0.0,
|
|
207
240
|
) -> dict:
|
|
208
241
|
"""Duplicate specific notes by ID (JSON array of ints), with optional time offset (in beats)."""
|
|
@@ -23,6 +23,11 @@ _DOMAIN_MAP: dict[str, list[str]] = {
|
|
|
23
23
|
"too_safe_to_progress": ["sound_design", "transition"],
|
|
24
24
|
"section_missing": ["arrangement", "transition"],
|
|
25
25
|
"transition_not_earned": ["transition", "arrangement"],
|
|
26
|
+
# Sample-domain patterns (session-state analysis, not action-history)
|
|
27
|
+
"no_organic_texture": ["sample", "sound_design"],
|
|
28
|
+
"stale_drums": ["sample", "arrangement"],
|
|
29
|
+
"vocal_processing_monotony": ["sample", "sound_design"],
|
|
30
|
+
"dense_but_static": ["sample", "mix"],
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
_STUCKNESS_THRESHOLD = 0.2 # Below this, treat as user_request
|
|
@@ -390,8 +390,86 @@ def _all_same_family(variants: list[dict]) -> bool:
|
|
|
390
390
|
return len(families) <= 1 and len(variants) > 1
|
|
391
391
|
|
|
392
392
|
|
|
393
|
-
# ──
|
|
393
|
+
# ── Corpus intelligence enrichment ──────────────────────────────
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _get_corpus_hints(request_text: str, diagnosis: dict | None) -> dict | None:
|
|
397
|
+
"""Query the corpus for creative hints relevant to the request.
|
|
398
|
+
|
|
399
|
+
Returns a dict with emotional_recipe, genre_chain, automation_density,
|
|
400
|
+
and technique_suggestions — or None if corpus is unavailable.
|
|
401
|
+
"""
|
|
402
|
+
try:
|
|
403
|
+
from ..corpus import get_corpus
|
|
404
|
+
except ImportError:
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
corpus = get_corpus()
|
|
408
|
+
if not corpus.emotional_recipes and not corpus.genre_chains:
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
hints: dict = {}
|
|
412
|
+
request_lower = request_text.lower()
|
|
413
|
+
|
|
414
|
+
# Check for emotional keywords
|
|
415
|
+
_EMOTION_KEYWORDS = {
|
|
416
|
+
"warm": "warmth & comfort", "cold": "tension & anxiety",
|
|
417
|
+
"dark": "melancholy", "bright": "euphoria",
|
|
418
|
+
"aggressive": "danger", "soft": "warmth & comfort",
|
|
419
|
+
"anxious": "tension & anxiety", "nostalgic": "nostalgia",
|
|
420
|
+
"vast": "vastness", "ethereal": "vastness",
|
|
421
|
+
"sad": "melancholy", "happy": "euphoria",
|
|
422
|
+
"tension": "tension & anxiety", "release": "euphoria",
|
|
423
|
+
}
|
|
424
|
+
for keyword, emotion_key in _EMOTION_KEYWORDS.items():
|
|
425
|
+
if keyword in request_lower:
|
|
426
|
+
recipe = corpus.suggest_for_emotion(emotion_key)
|
|
427
|
+
if recipe:
|
|
428
|
+
hints["emotional_recipe"] = {
|
|
429
|
+
"emotion": recipe.emotion,
|
|
430
|
+
"technique_count": len(recipe.techniques),
|
|
431
|
+
"first_techniques": [t[:100] for t in recipe.techniques[:3]],
|
|
432
|
+
}
|
|
433
|
+
break
|
|
434
|
+
|
|
435
|
+
# Check for genre keywords
|
|
436
|
+
_GENRE_KEYWORDS = ["dub", "techno", "minimal", "ambient", "idm", "trap",
|
|
437
|
+
"sophie", "arca", "house", "trance", "drum and bass"]
|
|
438
|
+
for genre in _GENRE_KEYWORDS:
|
|
439
|
+
if genre in request_lower:
|
|
440
|
+
chain = corpus.get_genre_chain(genre)
|
|
441
|
+
if chain:
|
|
442
|
+
hints["genre_chain"] = {
|
|
443
|
+
"genre": chain.genre,
|
|
444
|
+
"devices": chain.devices[:5],
|
|
445
|
+
"description": chain.description[:120],
|
|
446
|
+
}
|
|
447
|
+
break
|
|
448
|
+
|
|
449
|
+
# Check for physical model keywords
|
|
450
|
+
_MATERIAL_KEYWORDS = ["water", "metal", "glass", "breath", "fire", "electric"]
|
|
451
|
+
for material in _MATERIAL_KEYWORDS:
|
|
452
|
+
if material in request_lower:
|
|
453
|
+
model = corpus.suggest_for_material(material)
|
|
454
|
+
if model:
|
|
455
|
+
hints["physical_model"] = {
|
|
456
|
+
"material": model.material,
|
|
457
|
+
"devices": model.devices[:4],
|
|
458
|
+
}
|
|
459
|
+
break
|
|
394
460
|
|
|
461
|
+
# Automation density from diagnosis section type
|
|
462
|
+
if diagnosis:
|
|
463
|
+
problem_class = diagnosis.get("problem_class", "")
|
|
464
|
+
if "static" in problem_class or "flat" in problem_class:
|
|
465
|
+
hints["automation_density"] = corpus.get_automation_density_for_section("peak")
|
|
466
|
+
elif "breakdown" in problem_class:
|
|
467
|
+
hints["automation_density"] = corpus.get_automation_density_for_section("breakdown")
|
|
468
|
+
|
|
469
|
+
return hints if hints else None
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ── Pipeline orchestrator ────────────────────────────────────────
|
|
395
473
|
|
|
396
474
|
|
|
397
475
|
def generate_wonder_variants(
|
|
@@ -414,6 +492,9 @@ def generate_wonder_variants(
|
|
|
414
492
|
labels = ["safe", "strong", "unexpected"]
|
|
415
493
|
variants = []
|
|
416
494
|
|
|
495
|
+
# Load corpus intelligence for variant enrichment
|
|
496
|
+
corpus_hints = _get_corpus_hints(request_text, diagnosis)
|
|
497
|
+
|
|
417
498
|
# Build executable variants from distinct moves
|
|
418
499
|
for i, move in enumerate(distinct):
|
|
419
500
|
label = labels[i]
|
|
@@ -429,6 +510,9 @@ def generate_wonder_variants(
|
|
|
429
510
|
# Score taste on envelope-adjusted move for consistency with targets_snapshot
|
|
430
511
|
v["taste_fit"] = compute_taste_fit(move_with_envelope, taste_graph)
|
|
431
512
|
v["distinctness_reason"] = _explain_distinctness(move, distinct, i)
|
|
513
|
+
# Enrich with corpus knowledge
|
|
514
|
+
if corpus_hints:
|
|
515
|
+
v["corpus_hints"] = corpus_hints
|
|
432
516
|
variants.append(v)
|
|
433
517
|
|
|
434
518
|
executable_count = len(variants)
|
|
@@ -29,9 +29,14 @@ def _get_taste_graph(ctx: Context):
|
|
|
29
29
|
from ..memory.taste_graph import build_taste_graph
|
|
30
30
|
from ..memory.taste_memory import TasteMemoryStore
|
|
31
31
|
from ..memory.anti_memory import AntiMemoryStore
|
|
32
|
+
from ..persistence.taste_store import PersistentTasteStore
|
|
32
33
|
taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
|
|
33
34
|
anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
|
|
34
|
-
|
|
35
|
+
persistent = ctx.lifespan_context.setdefault("persistent_taste", PersistentTasteStore())
|
|
36
|
+
return build_taste_graph(
|
|
37
|
+
taste_store=taste_store, anti_store=anti_store,
|
|
38
|
+
persistent_store=persistent,
|
|
39
|
+
)
|
|
35
40
|
except Exception:
|
|
36
41
|
pass
|
|
37
42
|
return None
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.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 — 316 tools, 43 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",
|
|
@@ -17,6 +17,16 @@
|
|
|
17
17
|
"bugs": {
|
|
18
18
|
"url": "https://github.com/dreamrec/LivePilot/issues"
|
|
19
19
|
},
|
|
20
|
+
"funding": [
|
|
21
|
+
{
|
|
22
|
+
"type": "patreon",
|
|
23
|
+
"url": "https://www.patreon.com/c/dreamrec"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"type": "github",
|
|
27
|
+
"url": "https://github.com/sponsors/dreamrec"
|
|
28
|
+
}
|
|
29
|
+
],
|
|
20
30
|
"keywords": [
|
|
21
31
|
"mcp",
|
|
22
32
|
"mcp-server",
|
|
@@ -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.10.0"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from .server import LivePilotServer
|
|
@@ -20,6 +20,7 @@ from . import browser # noqa: F401 — registers browser handlers
|
|
|
20
20
|
from . import arrangement # noqa: F401 — registers arrangement handlers
|
|
21
21
|
from . import diagnostics # noqa: F401 — registers diagnostics handler
|
|
22
22
|
from . import clip_automation # noqa: F401 — registers clip automation handlers
|
|
23
|
+
from . import version_detect # noqa: F401 — version detection
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
def create_instance(c_instance):
|
|
@@ -36,6 +37,12 @@ class LivePilot(ControlSurface):
|
|
|
36
37
|
self._server.start()
|
|
37
38
|
self.log_message("LivePilot v%s starting..." % __version__)
|
|
38
39
|
self.show_message("LivePilot v%s starting..." % __version__)
|
|
40
|
+
v = version_detect.version_string()
|
|
41
|
+
self.log_message("LivePilot detected Ableton Live %s" % v)
|
|
42
|
+
features = version_detect.get_api_features()
|
|
43
|
+
enabled = [k for k, flag in features.items() if flag]
|
|
44
|
+
if enabled:
|
|
45
|
+
self.log_message(" Enabled features: %s" % ", ".join(enabled))
|
|
39
46
|
|
|
40
47
|
def disconnect(self):
|
|
41
48
|
"""Called by Ableton when the script is unloaded."""
|
|
@@ -169,6 +169,70 @@ def create_arrangement_clip(song, params):
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
|
|
172
|
+
@register("create_native_arrangement_clip")
|
|
173
|
+
def create_native_arrangement_clip(song, params):
|
|
174
|
+
"""Create an empty MIDI clip in arrangement using the native 12.1.10+ API.
|
|
175
|
+
|
|
176
|
+
Unlike create_arrangement_clip (which duplicates a session clip),
|
|
177
|
+
this creates a true native clip with full automation envelope support.
|
|
178
|
+
|
|
179
|
+
Required: track_index, start_time, length
|
|
180
|
+
Optional: name, color_index
|
|
181
|
+
"""
|
|
182
|
+
from .version_detect import has_feature
|
|
183
|
+
|
|
184
|
+
if not has_feature("create_midi_clip_arrangement"):
|
|
185
|
+
raise RuntimeError(
|
|
186
|
+
"create_native_arrangement_clip requires Live 12.1.10+. "
|
|
187
|
+
"Use create_arrangement_clip (session clip duplication) instead."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
track_index = int(params["track_index"])
|
|
191
|
+
start_time = float(params["start_time"])
|
|
192
|
+
length = float(params["length"])
|
|
193
|
+
if length <= 0:
|
|
194
|
+
raise ValueError("length must be > 0")
|
|
195
|
+
if start_time < 0:
|
|
196
|
+
raise ValueError("start_time must be >= 0")
|
|
197
|
+
|
|
198
|
+
track = get_track(song, track_index)
|
|
199
|
+
if not track.has_midi_input:
|
|
200
|
+
raise ValueError(
|
|
201
|
+
"Track %d is not a MIDI track — create_native_arrangement_clip "
|
|
202
|
+
"only works on MIDI tracks" % track_index
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
song.begin_undo_step()
|
|
206
|
+
try:
|
|
207
|
+
clip = track.create_midi_clip(start_time, length)
|
|
208
|
+
|
|
209
|
+
name = params.get("name")
|
|
210
|
+
if name:
|
|
211
|
+
clip.name = str(name)
|
|
212
|
+
color_index = params.get("color_index")
|
|
213
|
+
if color_index is not None:
|
|
214
|
+
clip.color_index = int(color_index)
|
|
215
|
+
finally:
|
|
216
|
+
song.end_undo_step()
|
|
217
|
+
|
|
218
|
+
# Find the clip index in arrangement_clips
|
|
219
|
+
clip_index = None
|
|
220
|
+
for i, c in enumerate(track.arrangement_clips):
|
|
221
|
+
if abs(c.start_time - start_time) < 0.01:
|
|
222
|
+
clip_index = i
|
|
223
|
+
break
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
"track_index": track_index,
|
|
227
|
+
"clip_index": clip_index,
|
|
228
|
+
"start_time": start_time,
|
|
229
|
+
"length": length,
|
|
230
|
+
"name": clip.name,
|
|
231
|
+
"has_envelope_support": True,
|
|
232
|
+
"native": True,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
172
236
|
@register("add_arrangement_notes")
|
|
173
237
|
def add_arrangement_notes(song, params):
|
|
174
238
|
"""Add MIDI notes to an arrangement clip (by index in arrangement_clips)."""
|
|
@@ -713,3 +777,53 @@ def back_to_arranger(song, params):
|
|
|
713
777
|
"""Switch playback from session clips back to the arrangement timeline."""
|
|
714
778
|
song.back_to_arranger = True
|
|
715
779
|
return {"back_to_arranger": True}
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
@register("force_arrangement")
|
|
783
|
+
def force_arrangement(song, params):
|
|
784
|
+
"""Force ALL tracks to follow the arrangement timeline.
|
|
785
|
+
|
|
786
|
+
Stops all session clips, releases every track from session override,
|
|
787
|
+
sets back_to_arranger, and optionally jumps to a start position.
|
|
788
|
+
|
|
789
|
+
This is the atomic "play the arrangement from the top" command.
|
|
790
|
+
"""
|
|
791
|
+
# 1. Stop playback
|
|
792
|
+
was_playing = song.is_playing
|
|
793
|
+
if was_playing:
|
|
794
|
+
song.stop_playing()
|
|
795
|
+
|
|
796
|
+
# 2. Stop playing clip slots individually to release session overrides
|
|
797
|
+
# (track.stop_all_clips() throws STATE_ERROR when tracks have no clips)
|
|
798
|
+
for track in list(song.tracks) + list(song.return_tracks):
|
|
799
|
+
try:
|
|
800
|
+
for slot in track.clip_slots:
|
|
801
|
+
if slot.has_clip and slot.is_playing:
|
|
802
|
+
slot.clip.stop()
|
|
803
|
+
except Exception:
|
|
804
|
+
pass
|
|
805
|
+
|
|
806
|
+
# 3. Global back-to-arranger
|
|
807
|
+
song.back_to_arranger = True
|
|
808
|
+
|
|
809
|
+
# 4. Jump to position (default: start)
|
|
810
|
+
beat_time = float(params.get("beat_time", 0))
|
|
811
|
+
song.current_song_time = max(0, beat_time)
|
|
812
|
+
|
|
813
|
+
# 5. Set loop if requested
|
|
814
|
+
if "loop_length" in params:
|
|
815
|
+
song.loop_start = float(params.get("loop_start", 0))
|
|
816
|
+
song.loop_length = float(params["loop_length"])
|
|
817
|
+
song.loop = True
|
|
818
|
+
|
|
819
|
+
# 6. Start playback if requested (default: yes)
|
|
820
|
+
play = params.get("play", True)
|
|
821
|
+
if play:
|
|
822
|
+
song.start_playing()
|
|
823
|
+
|
|
824
|
+
return {
|
|
825
|
+
"arrangement_active": True,
|
|
826
|
+
"position": song.current_song_time,
|
|
827
|
+
"is_playing": song.is_playing,
|
|
828
|
+
"loop": song.loop,
|
|
829
|
+
}
|