livesession-mcp 2.0.0__tar.gz

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.
Files changed (57) hide show
  1. livesession_mcp-2.0.0/LICENSE +22 -0
  2. livesession_mcp-2.0.0/MCP_Server/__init__.py +6 -0
  3. livesession_mcp-2.0.0/MCP_Server/als.py +513 -0
  4. livesession_mcp-2.0.0/MCP_Server/bundled_ableton_remote_script/AbletonMCP_init.py +3401 -0
  5. livesession_mcp-2.0.0/MCP_Server/bundled_ableton_remote_script/AbletonMCP_studio_ext.py +580 -0
  6. livesession_mcp-2.0.0/MCP_Server/bundled_ableton_remote_script/__init__.py +0 -0
  7. livesession_mcp-2.0.0/MCP_Server/cli.py +259 -0
  8. livesession_mcp-2.0.0/MCP_Server/compose_tools.py +98 -0
  9. livesession_mcp-2.0.0/MCP_Server/composer.py +397 -0
  10. livesession_mcp-2.0.0/MCP_Server/dataset/__init__.py +36 -0
  11. livesession_mcp-2.0.0/MCP_Server/dataset/consent.py +345 -0
  12. livesession_mcp-2.0.0/MCP_Server/dataset/hierarchy.py +158 -0
  13. livesession_mcp-2.0.0/MCP_Server/dataset/passive_poller.py +127 -0
  14. livesession_mcp-2.0.0/MCP_Server/dataset/recorder.py +814 -0
  15. livesession_mcp-2.0.0/MCP_Server/dataset/schema.py +282 -0
  16. livesession_mcp-2.0.0/MCP_Server/dataset/snapshot.py +129 -0
  17. livesession_mcp-2.0.0/MCP_Server/dataset/supabase_client.py +93 -0
  18. livesession_mcp-2.0.0/MCP_Server/dataset/trajectory_decorator.py +485 -0
  19. livesession_mcp-2.0.0/MCP_Server/ears.py +377 -0
  20. livesession_mcp-2.0.0/MCP_Server/extended_tools.py +360 -0
  21. livesession_mcp-2.0.0/MCP_Server/files_tools.py +120 -0
  22. livesession_mcp-2.0.0/MCP_Server/listen_tools.py +71 -0
  23. livesession_mcp-2.0.0/MCP_Server/more_tools.py +376 -0
  24. livesession_mcp-2.0.0/MCP_Server/platform_ui.py +451 -0
  25. livesession_mcp-2.0.0/MCP_Server/plugin_catalog.py +321 -0
  26. livesession_mcp-2.0.0/MCP_Server/plugin_process_worker.py +38 -0
  27. livesession_mcp-2.0.0/MCP_Server/plugin_scan_worker.py +69 -0
  28. livesession_mcp-2.0.0/MCP_Server/power_tools.py +230 -0
  29. livesession_mcp-2.0.0/MCP_Server/prompts.py +85 -0
  30. livesession_mcp-2.0.0/MCP_Server/remote_script_install.py +347 -0
  31. livesession_mcp-2.0.0/MCP_Server/safety.py +81 -0
  32. livesession_mcp-2.0.0/MCP_Server/script_handshake.py +126 -0
  33. livesession_mcp-2.0.0/MCP_Server/server.py +1597 -0
  34. livesession_mcp-2.0.0/MCP_Server/settings.py +115 -0
  35. livesession_mcp-2.0.0/MCP_Server/studio_tools.py +566 -0
  36. livesession_mcp-2.0.0/MCP_Server/telemetry.py +434 -0
  37. livesession_mcp-2.0.0/MCP_Server/telemetry_decorator.py +287 -0
  38. livesession_mcp-2.0.0/MCP_Server/theory.py +264 -0
  39. livesession_mcp-2.0.0/MCP_Server/toolsets.py +153 -0
  40. livesession_mcp-2.0.0/MCP_Server/workflows.py +412 -0
  41. livesession_mcp-2.0.0/PKG-INFO +181 -0
  42. livesession_mcp-2.0.0/README.md +133 -0
  43. livesession_mcp-2.0.0/livesession_mcp.egg-info/PKG-INFO +181 -0
  44. livesession_mcp-2.0.0/livesession_mcp.egg-info/SOURCES.txt +55 -0
  45. livesession_mcp-2.0.0/livesession_mcp.egg-info/dependency_links.txt +1 -0
  46. livesession_mcp-2.0.0/livesession_mcp.egg-info/entry_points.txt +4 -0
  47. livesession_mcp-2.0.0/livesession_mcp.egg-info/requires.txt +28 -0
  48. livesession_mcp-2.0.0/livesession_mcp.egg-info/top_level.txt +1 -0
  49. livesession_mcp-2.0.0/pyproject.toml +81 -0
  50. livesession_mcp-2.0.0/setup.cfg +4 -0
  51. livesession_mcp-2.0.0/tests/test_als.py +150 -0
  52. livesession_mcp-2.0.0/tests/test_clip_notes.py +212 -0
  53. livesession_mcp-2.0.0/tests/test_dataset_scrubbing.py +173 -0
  54. livesession_mcp-2.0.0/tests/test_product.py +177 -0
  55. livesession_mcp-2.0.0/tests/test_remote_script_bridge.py +236 -0
  56. livesession_mcp-2.0.0/tests/test_server_with_mock_live.py +93 -0
  57. livesession_mcp-2.0.0/tests/test_theory_composer.py +108 -0
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Siddharth Ahuja (original AbletonMCP)
4
+ Copyright (c) 2026 Uri Rosen (LiveSession MCP)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ """Ableton Live integration through the Model Context Protocol."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ # Expose key classes and functions for easier imports
6
+ from .server import AbletonConnection, get_ableton_connection
@@ -0,0 +1,513 @@
1
+ """Read Ableton Live Sets (.als) and device presets (.adg/.adv) without Live running.
2
+
3
+ These files are gzip-compressed XML. Everything here is read-only: summary, lint, diff between two
4
+ versions, MIDI extraction (with a small Standard MIDI File writer) and preset inspection.
5
+ Works with sets from Live 10-12 (MasterTrack/MainTrack, old and new scene layouts).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import gzip
11
+ import math
12
+ import re
13
+ import struct
14
+ import xml.etree.ElementTree as ET
15
+ from pathlib import Path
16
+ from typing import Any, Dict, Iterable, List, Optional
17
+
18
+ TRACK_TAGS = ("MidiTrack", "AudioTrack", "GroupTrack", "ReturnTrack")
19
+ NOTE_NAMES = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"]
20
+ # Live's own devices that only ship with Suite (an .adg/.adv using them won't load in Standard/Intro).
21
+ SUITE_ONLY = {"Operator", "InstrumentVector", "UltraAnalog", "Collision", "StringStudio", "LoungeLizard",
22
+ "Meld", "Roar", "MultiSampler", "InstrumentMeld", "Granulator", "Corpus", "Vocoder", "Spectral",
23
+ "Shifter", "HybridReverb", "Transmute", "Resonator"}
24
+ MAX_FOR_LIVE_PREFIX = "Mx"
25
+
26
+
27
+ # ── loading ───────────────────────────────────────────────────────────────────
28
+
29
+ def load(path: str | Path) -> ET.Element:
30
+ """The root <Ableton> element of an .als/.adg/.adv (gzip or plain XML)."""
31
+ data = Path(path).read_bytes()
32
+ if data[:2] == b"\x1f\x8b":
33
+ data = gzip.decompress(data)
34
+ root = ET.fromstring(data)
35
+ if root.tag != "Ableton":
36
+ raise ValueError(f"{path} is not an Ableton document (root <{root.tag}>)")
37
+ return root
38
+
39
+
40
+ def _v(el: Optional[ET.Element], path: str, default: Any = None) -> Any:
41
+ if el is None:
42
+ return default
43
+ found = el.find(path)
44
+ return found.get("Value", default) if found is not None else default
45
+
46
+
47
+ def _f(el: Optional[ET.Element], path: str, default: float = 0.0) -> float:
48
+ try:
49
+ return float(_v(el, path, default))
50
+ except (TypeError, ValueError):
51
+ return default
52
+
53
+
54
+ def _db(gain: float) -> float:
55
+ return round(20 * math.log10(gain), 1) if gain > 0 else -math.inf
56
+
57
+
58
+ def _time_signature(code: float) -> str:
59
+ """Live stores the global time signature as (numerator - 1) + 99 * log2(denominator)."""
60
+ code = int(code)
61
+ denominator = 2 ** (code // 99)
62
+ return f"{code % 99 + 1}/{denominator}"
63
+
64
+
65
+ # ── devices ───────────────────────────────────────────────────────────────────
66
+
67
+ def device_name(dev: ET.Element) -> str:
68
+ if dev.tag in ("PluginDevice", "AuPluginDevice"):
69
+ for p in ("PluginDesc/Vst3PluginInfo/Name", "PluginDesc/VstPluginInfo/PlugName",
70
+ "PluginDesc/AuPluginInfo/Name", "PluginDesc/AuPluginInfo/ComponentName"):
71
+ name = _v(dev, p)
72
+ if name:
73
+ return name
74
+ return "Plug-in"
75
+ user = _v(dev, "UserName")
76
+ return user or DISPLAY_NAMES.get(dev.tag, dev.tag)
77
+
78
+
79
+ # Live's internal device tags -> the names Live shows.
80
+ DISPLAY_NAMES = {
81
+ "OriginalSimpler": "Simpler", "MultiSampler": "Sampler", "InstrumentVector": "Wavetable",
82
+ "UltraAnalog": "Analog", "LoungeLizard": "Electric", "StringStudio": "Tension", "InstrumentImpulse": "Impulse",
83
+ "InstrumentGroupDevice": "Instrument Rack", "AudioEffectGroupDevice": "Audio Effect Rack",
84
+ "MidiEffectGroupDevice": "MIDI Effect Rack", "DrumGroupDevice": "Drum Rack", "AutoFilter2": "Auto Filter",
85
+ "AutoFilter": "Auto Filter", "Eq8": "EQ Eight", "Compressor2": "Compressor", "GlueCompressor": "Glue Compressor",
86
+ "MultibandDynamics": "Multiband Dynamics", "StereoGain": "Utility", "FilterDelay": "Filter Delay",
87
+ "PingPongDelay": "Ping Pong Delay", "GrainDelay": "Grain Delay", "HybridReverb": "Hybrid Reverb",
88
+ "AutoPan": "Auto Pan", "Tuner": "Tuner", "Limiter": "Limiter", "Saturator": "Saturator", "Redux2": "Redux",
89
+ "Vinyl": "Vinyl Distortion", "DrumBuss": "Drum Buss", "ChannelEq": "Channel EQ", "Echo": "Echo",
90
+ "InstrumentMeld": "Meld", "Drift": "Drift", "MidiArpeggiator": "Arpeggiator", "MidiChord": "Chord",
91
+ "MidiScale": "Scale", "MidiNoteLength": "Note Length", "MidiPitcher": "Pitch", "MidiRandom": "Random",
92
+ "MidiVelocity": "Velocity", "Collision": "Collision", "Operator": "Operator", "Vocoder": "Vocoder",
93
+ }
94
+
95
+
96
+ def device_info(dev: ET.Element) -> Dict[str, Any]:
97
+ info: Dict[str, Any] = {"name": device_name(dev), "type": dev.tag, "on": _v(dev, "On/Manual", "true") == "true"}
98
+ if dev.tag in ("PluginDevice", "AuPluginDevice"):
99
+ info["plugin"] = True
100
+ info["format"] = ("VST3" if dev.find("PluginDesc/Vst3PluginInfo") is not None else
101
+ "AU" if dev.find("PluginDesc/AuPluginInfo") is not None else "VST2")
102
+ if any(_v(dev, p) == "true" for p in ("PluginDesc/Vst3PluginInfo/IsPlaceholderDevice",
103
+ "PluginDesc/VstPluginInfo/IsPlaceholderDevice")):
104
+ info["missing_when_saved"] = True
105
+ if dev.tag.startswith(MAX_FOR_LIVE_PREFIX):
106
+ info["max_for_live"] = True
107
+ chains = dev.findall("Branches/*/DeviceChain/MidiToAudioDeviceChain/Devices") + \
108
+ dev.findall("Branches/*/DeviceChain/AudioToAudioDeviceChain/Devices") + \
109
+ dev.findall("Branches/*/DeviceChain/MidiToMidiDeviceChain/Devices")
110
+ inner = [device_info(d) for devs in chains for d in devs]
111
+ if inner:
112
+ info["contains"] = inner
113
+ return info
114
+
115
+
116
+ def _devices(track: ET.Element) -> List[ET.Element]:
117
+ devs = track.find("DeviceChain/DeviceChain/Devices")
118
+ return list(devs) if devs is not None else []
119
+
120
+
121
+ def _walk_devices(infos: Iterable[Dict[str, Any]]) -> Iterable[Dict[str, Any]]:
122
+ for d in infos:
123
+ yield d
124
+ yield from _walk_devices(d.get("contains", []))
125
+
126
+
127
+ # ── clips ─────────────────────────────────────────────────────────────────────
128
+
129
+ def _notes(clip: ET.Element) -> List[Dict[str, Any]]:
130
+ notes = []
131
+ for kt in clip.findall("Notes/KeyTracks/KeyTrack"):
132
+ pitch = int(_v(kt, "MidiKey", 60))
133
+ for ev in kt.findall("Notes/MidiNoteEvent"):
134
+ notes.append({"pitch": pitch, "start_time": float(ev.get("Time", 0)),
135
+ "duration": float(ev.get("Duration", 0.25)), "velocity": int(float(ev.get("Velocity", 100))),
136
+ "mute": ev.get("IsEnabled", "true") == "false",
137
+ "probability": float(ev.get("Probability", 1))})
138
+ notes.sort(key=lambda n: (n["start_time"], n["pitch"]))
139
+ return notes
140
+
141
+
142
+ def _clip_info(clip: ET.Element, where: str, slot: Optional[int] = None) -> Dict[str, Any]:
143
+ start = _f(clip, "CurrentStart")
144
+ end = _f(clip, "CurrentEnd", start)
145
+ info: Dict[str, Any] = {"name": _v(clip, "Name", ""), "kind": "midi" if clip.tag == "MidiClip" else "audio",
146
+ "where": where, "start": start if where == "arrangement" else None,
147
+ "length": round(end - start, 4) if where == "arrangement" else
148
+ round(_f(clip, "Loop/LoopEnd") - _f(clip, "Loop/LoopStart"), 4),
149
+ "loop_start": _f(clip, "Loop/LoopStart"), "loop_end": _f(clip, "Loop/LoopEnd"),
150
+ "muted": _v(clip, "Disabled", "false") == "true"}
151
+ if slot is not None:
152
+ info["slot"] = slot
153
+ if clip.tag == "MidiClip":
154
+ info["notes"] = len(clip.findall("Notes/KeyTracks/KeyTrack/Notes/MidiNoteEvent"))
155
+ else:
156
+ info["sample"] = sample_path(clip.find("SampleRef/FileRef"))
157
+ return info
158
+
159
+
160
+ def _session_clips(track: ET.Element) -> List[ET.Element]:
161
+ out = []
162
+ for slot in track.findall("DeviceChain/MainSequencer/ClipSlotList/ClipSlot"):
163
+ clip = slot.find("ClipSlot/Value/*")
164
+ out.append(clip)
165
+ return out
166
+
167
+
168
+ def _arrangement_clips(track: ET.Element) -> List[ET.Element]:
169
+ return track.findall("DeviceChain/MainSequencer//ArrangerAutomation/Events/*")
170
+
171
+
172
+ def sample_path(fileref: Optional[ET.Element]) -> Optional[str]:
173
+ if fileref is None:
174
+ return None
175
+ return _v(fileref, "Path") or _v(fileref, "RelativePath")
176
+
177
+
178
+ # ── summary ───────────────────────────────────────────────────────────────────
179
+
180
+ def _track_kind(tag: str) -> str:
181
+ return {"MidiTrack": "midi", "AudioTrack": "audio", "GroupTrack": "group", "ReturnTrack": "return"}[tag]
182
+
183
+
184
+ def track_info(track: ET.Element, groups: Dict[str, str]) -> Dict[str, Any]:
185
+ mixer = track.find("DeviceChain/Mixer")
186
+ devices = [device_info(d) for d in _devices(track)]
187
+ session = [(i, c) for i, c in enumerate(_session_clips(track)) if c is not None]
188
+ arrangement = _arrangement_clips(track)
189
+ info = {
190
+ "name": _v(track, "Name/EffectiveName", ""),
191
+ "kind": _track_kind(track.tag),
192
+ "id": track.get("Id"),
193
+ "group": groups.get(_v(track, "TrackGroupId", "-1")),
194
+ "volume_db": _db(_f(mixer, "Volume/Manual", 1.0)),
195
+ "pan": round(_f(mixer, "Pan/Manual"), 2),
196
+ "muted": _v(mixer, "Speaker/Manual", "true") == "false",
197
+ "solo": _v(mixer, "SoloSink", "false") == "true",
198
+ "frozen": _v(track, "Freeze", "false") == "true",
199
+ "output": _v(track, "DeviceChain/AudioOutputRouting/Target", ""),
200
+ "sends_db": [_db(_f(s, "Send/Manual")) for s in mixer.findall("Sends/TrackSendHolder")] if mixer is not None else [],
201
+ "devices": devices,
202
+ "session_clips": [_clip_info(c, "session", i) for i, c in session],
203
+ "arrangement_clips": [_clip_info(c, "arrangement") for c in arrangement],
204
+ }
205
+ return info
206
+
207
+
208
+ def _live_set(root: ET.Element) -> ET.Element:
209
+ ls = root.find("LiveSet")
210
+ if ls is None:
211
+ raise ValueError("Not a Live Set (no <LiveSet>)")
212
+ return ls
213
+
214
+
215
+ def _main_track(ls: ET.Element) -> Optional[ET.Element]:
216
+ return ls.find("MainTrack") if ls.find("MainTrack") is not None else ls.find("MasterTrack")
217
+
218
+
219
+ def summary(root: ET.Element, path: str | Path = "") -> Dict[str, Any]:
220
+ ls = _live_set(root)
221
+ tracks_el = [t for t in ls.find("Tracks") if t.tag in TRACK_TAGS]
222
+ groups = {t.get("Id"): _v(t, "Name/EffectiveName", "") for t in tracks_el if t.tag == "GroupTrack"}
223
+ tracks = [track_info(t, groups) for t in tracks_el if t.tag != "ReturnTrack"]
224
+ returns = [track_info(t, groups) for t in tracks_el if t.tag == "ReturnTrack"]
225
+ main = _main_track(ls)
226
+ scenes = [_v(s, "Name", "") for s in ls.findall("Scenes/Scene")] or \
227
+ [_v(s, "Value", "") for s in ls.findall("SceneNames/Scene")]
228
+ root_note = _v(ls, "ScaleInformation/RootNote")
229
+ ends = [c["start"] + c["length"] for t in tracks for c in t["arrangement_clips"]]
230
+ plugins = sorted({d["name"] for t in tracks + returns for d in _walk_devices(t["devices"]) if d.get("plugin")})
231
+ return {
232
+ "file": str(path),
233
+ "created_with": root.get("Creator", ""),
234
+ "tempo": _f(main, "DeviceChain/Mixer/Tempo/Manual", 120.0),
235
+ "time_signature": _time_signature(_f(main, "DeviceChain/Mixer/TimeSignature/Manual", 201)),
236
+ "scale": (f"{NOTE_NAMES[int(root_note) % 12]} {_v(ls, 'ScaleInformation/Name', '')}".strip()
237
+ if root_note is not None else None),
238
+ "arrangement_length_beats": round(max(ends), 2) if ends else 0.0,
239
+ "locators": [{"name": _v(l, "Name", ""), "time": _f(l, "Time")} for l in ls.findall("Locators/Locators/Locator")],
240
+ "scenes": [s for s in scenes],
241
+ "tracks": tracks,
242
+ "returns": returns,
243
+ "main": {"volume_db": _db(_f(main, "DeviceChain/Mixer/Volume/Manual", 1.0)),
244
+ "devices": [device_info(d) for d in _devices(main)] if main is not None else []},
245
+ "plugins": plugins,
246
+ "counts": {"tracks": len(tracks), "returns": len(returns),
247
+ "midi_clips": sum(c["kind"] == "midi" for t in tracks
248
+ for c in t["session_clips"] + t["arrangement_clips"]),
249
+ "audio_clips": sum(c["kind"] == "audio" for t in tracks
250
+ for c in t["session_clips"] + t["arrangement_clips"])},
251
+ }
252
+
253
+
254
+ def compact(summary_: Dict[str, Any]) -> Dict[str, Any]:
255
+ """A shorter summary for chat: device names instead of full device trees, clip counts."""
256
+ def short(t: Dict[str, Any]) -> Dict[str, Any]:
257
+ return {"name": t["name"], "kind": t["kind"], "group": t["group"], "volume_db": t["volume_db"],
258
+ "muted": t["muted"], "frozen": t["frozen"],
259
+ "devices": [d["name"] + ("" if d["on"] else " (off)") for d in t["devices"]],
260
+ "session_clips": len(t["session_clips"]), "arrangement_clips": len(t["arrangement_clips"])}
261
+ out = {k: v for k, v in summary_.items() if k not in ("tracks", "returns", "main")}
262
+ out["tracks"] = [short(t) for t in summary_["tracks"]]
263
+ out["returns"] = [short(t) for t in summary_["returns"]]
264
+ out["main"] = {"volume_db": summary_["main"]["volume_db"],
265
+ "devices": [d["name"] for d in summary_["main"]["devices"]]}
266
+ return out
267
+
268
+
269
+ # ── lint ──────────────────────────────────────────────────────────────────────
270
+
271
+ _DEFAULT_NAME = re.compile(r"^\d+[- ](MIDI|Audio|Group)$|^(MIDI|Audio)( \d+)?$", re.I)
272
+
273
+
274
+ def lint(root: ET.Element, path: str | Path = "",
275
+ installed_plugins: Optional[Iterable[str]] = None) -> List[Dict[str, str]]:
276
+ """Problems worth fixing before sharing or mixing, most serious first."""
277
+ s = summary(root, path)
278
+ project = Path(path).parent if path else None
279
+ issues: List[Dict[str, str]] = []
280
+
281
+ def add(severity: str, where: str, message: str) -> None:
282
+ issues.append({"severity": severity, "where": where, "message": message})
283
+
284
+ # Missing and uncollected samples (other FileRefs are preset history, e.g. where a factory kit was authored)
285
+ for fr in _live_set(root).findall(".//SampleRef/FileRef"):
286
+ p = _v(fr, "Path")
287
+ rel = _v(fr, "RelativePath")
288
+ if not p and not rel:
289
+ continue
290
+ exists = bool(p and Path(p).exists()) or bool(project and rel and (project / rel).exists())
291
+ if not exists:
292
+ add("error", "files", f"Missing file: {p or rel}")
293
+ elif (project and p and not _v(fr, "LivePackName") and "/resources/" not in p.replace("\\", "/").lower()
294
+ and not str(Path(p)).lower().startswith(str(project).lower())):
295
+ add("warning", "files", f"File outside the project folder (use File > Collect All and Save): {p}")
296
+
297
+ installed = {n.lower() for n in installed_plugins} if installed_plugins else None
298
+ names = [t["name"] for t in s["tracks"]]
299
+ for t in s["tracks"] + s["returns"]:
300
+ where = f"track '{t['name']}'" if t["kind"] != "return" else f"return '{t['name']}'"
301
+ for d in _walk_devices(t["devices"]):
302
+ if d.get("missing_when_saved"):
303
+ add("error", where, f"Plug-in '{d['name']}' was missing when the set was saved")
304
+ elif d.get("plugin") and installed is not None and d["name"].lower() not in installed:
305
+ add("error", where, f"Plug-in '{d['name']}' is not installed on this computer")
306
+ if not d["on"]:
307
+ add("info", where, f"Device '{d['name']}' is switched off")
308
+ if t["output"].endswith("/None"):
309
+ add("warning", where, "Output is routed to nowhere: it won't be heard or exported")
310
+ if t["volume_db"] > 6:
311
+ add("warning", where, f"Fader at {t['volume_db']:+.1f} dB: gain-stage earlier in the chain instead")
312
+ if t["kind"] in ("midi", "audio"):
313
+ has_content = t["session_clips"] or t["arrangement_clips"]
314
+ if not has_content and not t["devices"]:
315
+ add("info", where, "Empty track (no clips, no devices)")
316
+ if t["muted"] and has_content:
317
+ add("info", where, "Muted track with clips: delete it or unmute before export")
318
+ if t["kind"] == "midi" and has_content and not t["devices"]:
319
+ add("warning", where, "MIDI clips but no instrument: the track is silent")
320
+ if t["solo"]:
321
+ add("warning", where, "Track is soloed: everything else is silent on export")
322
+ if t["frozen"]:
323
+ add("info", where, "Frozen track (unfreeze to edit it)")
324
+ if _DEFAULT_NAME.match(t["name"]):
325
+ add("info", where, "Default track name: name it for collaborators")
326
+ for n in sorted({n for n in names if names.count(n) > 1}):
327
+ add("info", "tracks", f"Several tracks are named '{n}'")
328
+ if s["main"]["volume_db"] > 0:
329
+ add("warning", "main", f"Main fader above 0 dB ({s['main']['volume_db']:+.1f} dB): risk of clipping")
330
+ if not s["locators"] and s["arrangement_length_beats"] > 64:
331
+ add("info", "arrangement", "No locators: mark the song sections for faster navigation")
332
+ order = {"error": 0, "warning": 1, "info": 2}
333
+ issues.sort(key=lambda i: order[i["severity"]])
334
+ return issues
335
+
336
+
337
+ # ── diff ──────────────────────────────────────────────────────────────────────
338
+
339
+ def diff(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
340
+ """Changes from summary a (older) to summary b (newer)."""
341
+ out: Dict[str, Any] = {}
342
+ for key in ("tempo", "time_signature", "scale", "arrangement_length_beats"):
343
+ if a.get(key) != b.get(key):
344
+ out[key] = {"from": a.get(key), "to": b.get(key)}
345
+
346
+ def by_id(tracks: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
347
+ return {t["id"]: t for t in tracks}
348
+
349
+ for section in ("tracks", "returns"):
350
+ ta, tb = by_id(a[section]), by_id(b[section])
351
+ added = [tb[i]["name"] for i in tb if i not in ta]
352
+ removed = [ta[i]["name"] for i in ta if i not in tb]
353
+ changed = []
354
+ for i in ta.keys() & tb.keys():
355
+ x, y = ta[i], tb[i]
356
+ c: Dict[str, Any] = {}
357
+ if x["name"] != y["name"]:
358
+ c["renamed"] = {"from": x["name"], "to": y["name"]}
359
+ if abs((x["volume_db"] if math.isfinite(x["volume_db"]) else -200) -
360
+ (y["volume_db"] if math.isfinite(y["volume_db"]) else -200)) >= 0.1:
361
+ c["volume_db"] = {"from": x["volume_db"], "to": y["volume_db"]}
362
+ for k in ("muted", "solo", "frozen", "pan", "output", "group"):
363
+ if x[k] != y[k]:
364
+ c[k] = {"from": x[k], "to": y[k]}
365
+ dx, dy = [d["name"] for d in x["devices"]], [d["name"] for d in y["devices"]]
366
+ if dx != dy:
367
+ c["devices"] = {"from": dx, "to": dy}
368
+ for kind in ("session_clips", "arrangement_clips"):
369
+ cx = [(cl["name"], cl.get("start"), cl["length"], cl.get("notes")) for cl in x[kind]]
370
+ cy = [(cl["name"], cl.get("start"), cl["length"], cl.get("notes")) for cl in y[kind]]
371
+ if cx != cy:
372
+ c[kind] = {"from": len(cx), "to": len(cy),
373
+ "notes_from": sum(n or 0 for *_, n in cx), "notes_to": sum(n or 0 for *_, n in cy)}
374
+ if c:
375
+ changed.append({"track": y["name"], **c})
376
+ if added or removed or changed:
377
+ out[section] = {"added": added, "removed": removed, "changed": changed}
378
+ la = {(l["name"], l["time"]) for l in a["locators"]}
379
+ lb = {(l["name"], l["time"]) for l in b["locators"]}
380
+ if la != lb:
381
+ out["locators"] = {"added": sorted(lb - la, key=lambda x: x[1]), "removed": sorted(la - lb, key=lambda x: x[1])}
382
+ if a["scenes"] != b["scenes"]:
383
+ out["scenes"] = {"from": a["scenes"], "to": b["scenes"]}
384
+ pa, pb = set(a["plugins"]), set(b["plugins"])
385
+ if pa != pb:
386
+ out["plugins"] = {"added": sorted(pb - pa), "removed": sorted(pa - pb)}
387
+ return out or {"identical": True}
388
+
389
+
390
+ # ── MIDI ──────────────────────────────────────────────────────────────────────
391
+
392
+ def midi_clips(root: ET.Element, track: Optional[str] = None) -> List[Dict[str, Any]]:
393
+ """Every MIDI clip with its notes; track filters by (part of) the track name."""
394
+ ls = _live_set(root)
395
+ out = []
396
+ for t in ls.find("Tracks"):
397
+ if t.tag != "MidiTrack":
398
+ continue
399
+ name = _v(t, "Name/EffectiveName", "")
400
+ if track and track.lower() not in name.lower():
401
+ continue
402
+ for i, clip in enumerate(_session_clips(t)):
403
+ if clip is not None and clip.tag == "MidiClip":
404
+ out.append({"track": name, **_clip_info(clip, "session", i), "note_list": _notes(clip)})
405
+ for clip in _arrangement_clips(t):
406
+ if clip.tag == "MidiClip":
407
+ out.append({"track": name, **_clip_info(clip, "arrangement"), "note_list": _notes(clip)})
408
+ return out
409
+
410
+
411
+ def _vlq(n: int) -> bytes:
412
+ out = [n & 0x7F]
413
+ n >>= 7
414
+ while n:
415
+ out.append((n & 0x7F) | 0x80)
416
+ n >>= 7
417
+ return bytes(reversed(out))
418
+
419
+
420
+ def write_midi(path: str | Path, notes: List[Dict[str, Any]], tempo: float = 120.0, numerator: int = 4,
421
+ denominator: int = 4, ppq: int = 480, name: str = "") -> Path:
422
+ """A one-track Standard MIDI File (format 0) from note dicts (times in beats)."""
423
+ events = []
424
+ for n in notes:
425
+ if n.get("mute"):
426
+ continue
427
+ on = round(n["start_time"] * ppq)
428
+ off = round((n["start_time"] + n["duration"]) * ppq)
429
+ pitch, vel = int(n["pitch"]), max(1, min(127, int(n["velocity"])))
430
+ events.append((on, 1, bytes([0x90, pitch, vel])))
431
+ events.append((max(off, on + 1), 0, bytes([0x80, pitch, 0])))
432
+ events.sort(key=lambda e: (e[0], e[1]))
433
+ us = round(60_000_000 / tempo)
434
+ track = b"\x00\xff\x51\x03" + us.to_bytes(3, "big")
435
+ track += b"\x00\xff\x58\x04" + bytes([numerator, int(math.log2(denominator)), 24, 8])
436
+ if name:
437
+ raw = name.encode("utf-8")[:120]
438
+ track += b"\x00\xff\x03" + _vlq(len(raw)) + raw
439
+ last = 0
440
+ for tick, _, msg in events:
441
+ track += _vlq(tick - last) + msg
442
+ last = tick
443
+ track += b"\x00\xff\x2f\x00"
444
+ data = b"MThd" + struct.pack(">IHHH", 6, 0, 1, ppq) + b"MTrk" + struct.pack(">I", len(track)) + track
445
+ path = Path(path)
446
+ path.parent.mkdir(parents=True, exist_ok=True)
447
+ path.write_bytes(data)
448
+ return path
449
+
450
+
451
+ def estimate_key(notes: List[Dict[str, Any]]) -> Optional[str]:
452
+ """Key from MIDI notes (duration-weighted Krumhansl profiles)."""
453
+ major = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]
454
+ minor = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]
455
+ hist = [0.0] * 12
456
+ for n in notes:
457
+ if not n.get("mute"):
458
+ hist[n["pitch"] % 12] += n["duration"]
459
+ if sum(hist) == 0:
460
+ return None
461
+
462
+ def corr(x: List[float], y: List[float]) -> float:
463
+ mx, my = sum(x) / 12, sum(y) / 12
464
+ num = sum((a - mx) * (b - my) for a, b in zip(x, y))
465
+ den = math.sqrt(sum((a - mx) ** 2 for a in x) * sum((b - my) ** 2 for b in y))
466
+ return num / den if den else 0.0
467
+ best = max(((corr(hist, prof[-i:] + prof[:-i]), f"{NOTE_NAMES[i]} {mode}")
468
+ for i in range(12) for prof, mode in ((major, "major"), (minor, "minor"))))
469
+ return best[1]
470
+
471
+
472
+ # ── presets ───────────────────────────────────────────────────────────────────
473
+
474
+ def _preset_device(preset: ET.Element) -> Optional[Dict[str, Any]]:
475
+ """Device info for one preset element: a rack (GroupDevicePreset, with its chains), a device
476
+ preset (AbletonDevicePreset / PluginPreset wrapping <Device>) or a bare device (.adv)."""
477
+ if preset.tag == "GroupDevicePreset" or preset.find("Device") is not None:
478
+ dev_holder = preset.find("Device")
479
+ if dev_holder is None or not len(dev_holder):
480
+ return None
481
+ info = device_info(dev_holder[0])
482
+ chains = []
483
+ for branch in preset.findall("BranchPresets/*"):
484
+ devices = [d for d in (_preset_device(p) for p in branch.findall("DevicePresets/*")) if d]
485
+ chains.append({"name": _v(branch, "Name", ""), "devices": devices})
486
+ if chains:
487
+ info["chains"] = chains
488
+ info["contains"] = [d for c in chains for d in c["devices"]]
489
+ return info
490
+ return device_info(preset)
491
+
492
+
493
+ def preset_info(root: ET.Element) -> Dict[str, Any]:
494
+ """What an .adg (rack) or .adv (device preset) contains, and which Live edition it needs."""
495
+ top = next(iter(root), None)
496
+ if top is None:
497
+ raise ValueError("Empty preset")
498
+ info = _preset_device(top) or device_info(top)
499
+ holder = top.find("Device")
500
+ top = holder[0] if holder is not None and len(holder) else top
501
+ macros = []
502
+ for i in range(16):
503
+ name = _v(top, f"MacroDisplayNames.{i}")
504
+ if name is None:
505
+ break
506
+ macros.append({"index": i, "name": name, "value": _f(top, f"MacroControls.{i}/Manual")})
507
+ devices = list(_walk_devices([info]))
508
+ tags = {d["type"] for d in devices}
509
+ needs = "Suite" if tags & SUITE_ONLY or any(d.get("max_for_live") for d in devices) else "Standard or Suite"
510
+ return {"device": info["name"], "type": info["type"], "macros": macros, "devices": info,
511
+ "plugins": sorted({d["name"] for d in devices if d.get("plugin")}),
512
+ "max_for_live": sorted({d["name"] for d in devices if d.get("max_for_live")}),
513
+ "needs_edition": needs, "created_with": root.get("Creator", "")}