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.
- livesession_mcp-2.0.0/LICENSE +22 -0
- livesession_mcp-2.0.0/MCP_Server/__init__.py +6 -0
- livesession_mcp-2.0.0/MCP_Server/als.py +513 -0
- livesession_mcp-2.0.0/MCP_Server/bundled_ableton_remote_script/AbletonMCP_init.py +3401 -0
- livesession_mcp-2.0.0/MCP_Server/bundled_ableton_remote_script/AbletonMCP_studio_ext.py +580 -0
- livesession_mcp-2.0.0/MCP_Server/bundled_ableton_remote_script/__init__.py +0 -0
- livesession_mcp-2.0.0/MCP_Server/cli.py +259 -0
- livesession_mcp-2.0.0/MCP_Server/compose_tools.py +98 -0
- livesession_mcp-2.0.0/MCP_Server/composer.py +397 -0
- livesession_mcp-2.0.0/MCP_Server/dataset/__init__.py +36 -0
- livesession_mcp-2.0.0/MCP_Server/dataset/consent.py +345 -0
- livesession_mcp-2.0.0/MCP_Server/dataset/hierarchy.py +158 -0
- livesession_mcp-2.0.0/MCP_Server/dataset/passive_poller.py +127 -0
- livesession_mcp-2.0.0/MCP_Server/dataset/recorder.py +814 -0
- livesession_mcp-2.0.0/MCP_Server/dataset/schema.py +282 -0
- livesession_mcp-2.0.0/MCP_Server/dataset/snapshot.py +129 -0
- livesession_mcp-2.0.0/MCP_Server/dataset/supabase_client.py +93 -0
- livesession_mcp-2.0.0/MCP_Server/dataset/trajectory_decorator.py +485 -0
- livesession_mcp-2.0.0/MCP_Server/ears.py +377 -0
- livesession_mcp-2.0.0/MCP_Server/extended_tools.py +360 -0
- livesession_mcp-2.0.0/MCP_Server/files_tools.py +120 -0
- livesession_mcp-2.0.0/MCP_Server/listen_tools.py +71 -0
- livesession_mcp-2.0.0/MCP_Server/more_tools.py +376 -0
- livesession_mcp-2.0.0/MCP_Server/platform_ui.py +451 -0
- livesession_mcp-2.0.0/MCP_Server/plugin_catalog.py +321 -0
- livesession_mcp-2.0.0/MCP_Server/plugin_process_worker.py +38 -0
- livesession_mcp-2.0.0/MCP_Server/plugin_scan_worker.py +69 -0
- livesession_mcp-2.0.0/MCP_Server/power_tools.py +230 -0
- livesession_mcp-2.0.0/MCP_Server/prompts.py +85 -0
- livesession_mcp-2.0.0/MCP_Server/remote_script_install.py +347 -0
- livesession_mcp-2.0.0/MCP_Server/safety.py +81 -0
- livesession_mcp-2.0.0/MCP_Server/script_handshake.py +126 -0
- livesession_mcp-2.0.0/MCP_Server/server.py +1597 -0
- livesession_mcp-2.0.0/MCP_Server/settings.py +115 -0
- livesession_mcp-2.0.0/MCP_Server/studio_tools.py +566 -0
- livesession_mcp-2.0.0/MCP_Server/telemetry.py +434 -0
- livesession_mcp-2.0.0/MCP_Server/telemetry_decorator.py +287 -0
- livesession_mcp-2.0.0/MCP_Server/theory.py +264 -0
- livesession_mcp-2.0.0/MCP_Server/toolsets.py +153 -0
- livesession_mcp-2.0.0/MCP_Server/workflows.py +412 -0
- livesession_mcp-2.0.0/PKG-INFO +181 -0
- livesession_mcp-2.0.0/README.md +133 -0
- livesession_mcp-2.0.0/livesession_mcp.egg-info/PKG-INFO +181 -0
- livesession_mcp-2.0.0/livesession_mcp.egg-info/SOURCES.txt +55 -0
- livesession_mcp-2.0.0/livesession_mcp.egg-info/dependency_links.txt +1 -0
- livesession_mcp-2.0.0/livesession_mcp.egg-info/entry_points.txt +4 -0
- livesession_mcp-2.0.0/livesession_mcp.egg-info/requires.txt +28 -0
- livesession_mcp-2.0.0/livesession_mcp.egg-info/top_level.txt +1 -0
- livesession_mcp-2.0.0/pyproject.toml +81 -0
- livesession_mcp-2.0.0/setup.cfg +4 -0
- livesession_mcp-2.0.0/tests/test_als.py +150 -0
- livesession_mcp-2.0.0/tests/test_clip_notes.py +212 -0
- livesession_mcp-2.0.0/tests/test_dataset_scrubbing.py +173 -0
- livesession_mcp-2.0.0/tests/test_product.py +177 -0
- livesession_mcp-2.0.0/tests/test_remote_script_bridge.py +236 -0
- livesession_mcp-2.0.0/tests/test_server_with_mock_live.py +93 -0
- 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,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", "")}
|