logic2ableton 1.0.1__py3-none-any.whl
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.
- logic2ableton/__init__.py +1 -0
- logic2ableton/__main__.py +4 -0
- logic2ableton/ableton_generator.py +558 -0
- logic2ableton/cli.py +174 -0
- logic2ableton/data/DefaultLiveSet.als +0 -0
- logic2ableton/logic_parser.py +404 -0
- logic2ableton/models.py +90 -0
- logic2ableton/plugin_database.py +307 -0
- logic2ableton/plugin_matcher.py +77 -0
- logic2ableton/report.py +97 -0
- logic2ableton/vst3_scanner.py +231 -0
- logic2ableton-1.0.1.dist-info/METADATA +146 -0
- logic2ableton-1.0.1.dist-info/RECORD +17 -0
- logic2ableton-1.0.1.dist-info/WHEEL +5 -0
- logic2ableton-1.0.1.dist-info/entry_points.txt +2 -0
- logic2ableton-1.0.1.dist-info/licenses/LICENSE +21 -0
- logic2ableton-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""Generate a valid gzipped XML Ableton Live Set (.als) file from a LogicProject.
|
|
2
|
+
|
|
3
|
+
Strategy: Load the real DefaultLiveSet.als template from Ableton's installation,
|
|
4
|
+
strip its default tracks, inject our audio tracks with clips, and set tempo/time sig.
|
|
5
|
+
This guarantees structural correctness since we start from a valid file.
|
|
6
|
+
|
|
7
|
+
Critical: All Id attributes in the XML must be globally unique. When cloning
|
|
8
|
+
template tracks, every Id is reassigned using a global counter.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import gzip
|
|
13
|
+
import io
|
|
14
|
+
import shutil
|
|
15
|
+
import struct
|
|
16
|
+
import sys
|
|
17
|
+
import wave
|
|
18
|
+
import xml.etree.ElementTree as ET
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from logic2ableton.models import AudioFileRef, LogicProject, TrackMixerState
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_EDITIONS = ["Suite", "Trial", "Standard", "Intro", "Lite"]
|
|
25
|
+
|
|
26
|
+
_WIN_TEMPLATE_PATHS = [
|
|
27
|
+
Path(f"C:/ProgramData/Ableton/Live 12 {ed}/Resources/Builtin/Templates/DefaultLiveSet.als")
|
|
28
|
+
for ed in _EDITIONS
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
_MAC_TEMPLATE_PATHS = [
|
|
32
|
+
Path(f"/Applications/Ableton Live 12 {ed}.app/Contents/App-Resources/Builtin/Templates/DefaultLiveSet.als")
|
|
33
|
+
for ed in _EDITIONS
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_BUNDLED_TEMPLATE = Path(__file__).parent / "data" / "DefaultLiveSet.als"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _find_template(custom_path: Path | None = None) -> Path | None:
|
|
41
|
+
"""Find the Ableton default template on disk.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
custom_path: Explicit template path (--template CLI flag). Checked first.
|
|
45
|
+
"""
|
|
46
|
+
if custom_path is not None:
|
|
47
|
+
if custom_path.exists():
|
|
48
|
+
return custom_path
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
paths = _MAC_TEMPLATE_PATHS if sys.platform == "darwin" else _WIN_TEMPLATE_PATHS
|
|
52
|
+
for p in paths:
|
|
53
|
+
if p.exists():
|
|
54
|
+
return p
|
|
55
|
+
|
|
56
|
+
if _BUNDLED_TEMPLATE.exists():
|
|
57
|
+
return _BUNDLED_TEMPLATE
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _val(parent: ET.Element, tag: str, value) -> ET.Element:
|
|
62
|
+
"""Create a <Tag Value="value"/> child element."""
|
|
63
|
+
elem = ET.SubElement(parent, tag)
|
|
64
|
+
elem.set("Value", str(value))
|
|
65
|
+
return elem
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_audio_info(file_path: Path) -> tuple[int, int]:
|
|
69
|
+
"""Read audio file header. Returns (nframes, sample_rate).
|
|
70
|
+
|
|
71
|
+
Supports both WAV (RIFF) and AIFF (FORM/AIFF) formats.
|
|
72
|
+
"""
|
|
73
|
+
ext = file_path.suffix.lower()
|
|
74
|
+
|
|
75
|
+
# Try WAV first
|
|
76
|
+
if ext == ".wav":
|
|
77
|
+
try:
|
|
78
|
+
with wave.open(str(file_path), "rb") as w:
|
|
79
|
+
return w.getnframes(), w.getframerate()
|
|
80
|
+
except Exception:
|
|
81
|
+
return 0, 44100
|
|
82
|
+
|
|
83
|
+
# AIFF: parse COMM chunk for nframes and sample rate
|
|
84
|
+
if ext in (".aif", ".aiff"):
|
|
85
|
+
try:
|
|
86
|
+
with open(file_path, "rb") as f:
|
|
87
|
+
form = f.read(4)
|
|
88
|
+
if form != b"FORM":
|
|
89
|
+
return 0, 44100
|
|
90
|
+
file_size = struct.unpack(">I", f.read(4))[0]
|
|
91
|
+
f.read(4) # AIFF or AIFC
|
|
92
|
+
while f.tell() < file_size + 8:
|
|
93
|
+
chunk_id = f.read(4)
|
|
94
|
+
if len(chunk_id) < 4:
|
|
95
|
+
break
|
|
96
|
+
chunk_size = struct.unpack(">I", f.read(4))[0]
|
|
97
|
+
if chunk_id == b"COMM":
|
|
98
|
+
data = f.read(chunk_size)
|
|
99
|
+
nframes = struct.unpack(">I", data[2:6])[0]
|
|
100
|
+
# Decode 80-bit IEEE 754 extended float for sample rate
|
|
101
|
+
sr_bytes = data[8:18]
|
|
102
|
+
exponent = ((sr_bytes[0] & 0x7F) << 8) | sr_bytes[1]
|
|
103
|
+
mantissa = int.from_bytes(sr_bytes[2:10], "big")
|
|
104
|
+
sample_rate = mantissa * (2.0 ** (exponent - 16383 - 63))
|
|
105
|
+
return nframes, int(sample_rate)
|
|
106
|
+
else:
|
|
107
|
+
f.seek(chunk_size + (chunk_size % 2), 1)
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
return 0, 44100
|
|
111
|
+
|
|
112
|
+
return 0, 44100
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _IdAllocator:
|
|
116
|
+
"""Allocates globally unique integer IDs for Ableton XML elements."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, start: int):
|
|
119
|
+
self._next = start
|
|
120
|
+
|
|
121
|
+
def next(self) -> int:
|
|
122
|
+
val = self._next
|
|
123
|
+
self._next += 1
|
|
124
|
+
return val
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def current(self) -> int:
|
|
128
|
+
return self._next
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _reassign_ids(element: ET.Element, allocator: _IdAllocator) -> None:
|
|
132
|
+
"""Recursively reassign all Id attributes in an element tree to unique values."""
|
|
133
|
+
if "Id" in element.attrib:
|
|
134
|
+
element.set("Id", str(allocator.next()))
|
|
135
|
+
for child in element:
|
|
136
|
+
_reassign_ids(child, allocator)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _clone_track(template_track: ET.Element, allocator: _IdAllocator, name: str, color: int) -> ET.Element:
|
|
140
|
+
"""Clone a template AudioTrack with unique IDs and custom name/color."""
|
|
141
|
+
track = copy.deepcopy(template_track)
|
|
142
|
+
|
|
143
|
+
# Reassign ALL Id attributes to globally unique values
|
|
144
|
+
_reassign_ids(track, allocator)
|
|
145
|
+
|
|
146
|
+
# Set name
|
|
147
|
+
name_elem = track.find("Name")
|
|
148
|
+
if name_elem is not None:
|
|
149
|
+
eff = name_elem.find("EffectiveName")
|
|
150
|
+
if eff is not None:
|
|
151
|
+
eff.set("Value", name)
|
|
152
|
+
user = name_elem.find("UserName")
|
|
153
|
+
if user is not None:
|
|
154
|
+
user.set("Value", name)
|
|
155
|
+
|
|
156
|
+
# Set color
|
|
157
|
+
color_elem = track.find("Color")
|
|
158
|
+
if color_elem is not None:
|
|
159
|
+
color_elem.set("Value", str(color))
|
|
160
|
+
|
|
161
|
+
return track
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _set_mixer_state(track: ET.Element, mixer_state: TrackMixerState | None) -> None:
|
|
165
|
+
"""Set volume, pan, mute, and solo on an AudioTrack mixer."""
|
|
166
|
+
if mixer_state is None:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
mixer = track.find(".//DeviceChain/Mixer")
|
|
170
|
+
if mixer is None:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
vol_elem = mixer.find("Volume/Manual")
|
|
174
|
+
if vol_elem is not None:
|
|
175
|
+
vol_elem.set("Value", str(mixer_state.volume_linear))
|
|
176
|
+
|
|
177
|
+
pan_elem = mixer.find("Pan/Manual")
|
|
178
|
+
if pan_elem is not None:
|
|
179
|
+
pan_elem.set("Value", str(max(-1.0, min(1.0, mixer_state.pan))))
|
|
180
|
+
|
|
181
|
+
# Speaker/Manual is true when unmuted.
|
|
182
|
+
speaker_elem = mixer.find("Speaker/Manual")
|
|
183
|
+
if speaker_elem is not None:
|
|
184
|
+
speaker_elem.set("Value", "false" if mixer_state.is_muted else "true")
|
|
185
|
+
|
|
186
|
+
solo_elem = mixer.find("SoloSink")
|
|
187
|
+
if solo_elem is not None:
|
|
188
|
+
solo_elem.set("Value", "true" if mixer_state.is_soloed else "false")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _make_audio_clip_xml(
|
|
192
|
+
allocator: _IdAllocator,
|
|
193
|
+
ref: AudioFileRef,
|
|
194
|
+
tempo: float,
|
|
195
|
+
sample_rate: int,
|
|
196
|
+
project_folder: Path | None = None,
|
|
197
|
+
) -> ET.Element:
|
|
198
|
+
"""Create an AudioClip element for arrangement view.
|
|
199
|
+
|
|
200
|
+
Each clip is placed at its BWF-derived timeline position (start_position_samples).
|
|
201
|
+
Logic Pro embeds BWF timestamps in recordings; the position is extracted
|
|
202
|
+
during parsing and stored in AudioFileRef.start_position_samples.
|
|
203
|
+
"""
|
|
204
|
+
# Get duration and sample rate from WAV header
|
|
205
|
+
duration_samples, file_sample_rate = _get_audio_info(ref.file_path)
|
|
206
|
+
timeline_sample_rate = file_sample_rate if file_sample_rate > 0 else sample_rate
|
|
207
|
+
duration_beats = (duration_samples * tempo / (timeline_sample_rate * 60)) if duration_samples > 0 else 4.0
|
|
208
|
+
duration_secs = duration_samples / file_sample_rate if duration_samples > 0 else 2.0
|
|
209
|
+
|
|
210
|
+
# Calculate timeline position from BWF timestamp
|
|
211
|
+
start_beats = ref.start_position_samples * tempo / (timeline_sample_rate * 60)
|
|
212
|
+
|
|
213
|
+
clip = ET.Element("AudioClip")
|
|
214
|
+
clip.set("Id", str(allocator.next()))
|
|
215
|
+
clip.set("Time", str(start_beats))
|
|
216
|
+
|
|
217
|
+
_val(clip, "LomId", "0")
|
|
218
|
+
# CurrentStart/CurrentEnd are ABSOLUTE timeline positions
|
|
219
|
+
_val(clip, "CurrentStart", str(start_beats))
|
|
220
|
+
_val(clip, "CurrentEnd", str(start_beats + duration_beats))
|
|
221
|
+
|
|
222
|
+
# Loop — relative to audio content (not timeline)
|
|
223
|
+
loop = ET.SubElement(clip, "Loop")
|
|
224
|
+
_val(loop, "LoopStart", "0")
|
|
225
|
+
_val(loop, "LoopEnd", str(duration_beats))
|
|
226
|
+
_val(loop, "StartRelative", "0")
|
|
227
|
+
_val(loop, "LoopOn", "false")
|
|
228
|
+
|
|
229
|
+
# Name (stem without extension)
|
|
230
|
+
clip_name = ref.filename.rsplit(".", 1)[0]
|
|
231
|
+
_val(clip, "Name", clip_name)
|
|
232
|
+
_val(clip, "Color", "0")
|
|
233
|
+
_val(clip, "Disabled", "false")
|
|
234
|
+
_val(clip, "IsWarped", "true")
|
|
235
|
+
|
|
236
|
+
# Fades
|
|
237
|
+
fades = ET.SubElement(clip, "Fades")
|
|
238
|
+
_val(fades, "FadeInLength", "0")
|
|
239
|
+
_val(fades, "FadeOutLength", "0")
|
|
240
|
+
_val(fades, "IsDefaultFadeIn", "true")
|
|
241
|
+
_val(fades, "IsDefaultFadeOut", "true")
|
|
242
|
+
|
|
243
|
+
# TimeSignature
|
|
244
|
+
ts_outer = ET.SubElement(clip, "TimeSignature")
|
|
245
|
+
ts_list = ET.SubElement(ts_outer, "TimeSignatures")
|
|
246
|
+
ts_remote = ET.SubElement(ts_list, "RemoteableTimeSignature")
|
|
247
|
+
ts_remote.set("Id", str(allocator.next()))
|
|
248
|
+
_val(ts_remote, "Numerator", "4")
|
|
249
|
+
_val(ts_remote, "Denominator", "4")
|
|
250
|
+
_val(ts_remote, "Time", "0")
|
|
251
|
+
|
|
252
|
+
# WarpMarkers — two markers: start and end
|
|
253
|
+
warp_markers = ET.SubElement(clip, "WarpMarkers")
|
|
254
|
+
wm_start = ET.SubElement(warp_markers, "WarpMarker")
|
|
255
|
+
wm_start.set("Id", str(allocator.next()))
|
|
256
|
+
wm_start.set("SecTime", "0")
|
|
257
|
+
wm_start.set("BeatTime", "0")
|
|
258
|
+
wm_end = ET.SubElement(warp_markers, "WarpMarker")
|
|
259
|
+
wm_end.set("Id", str(allocator.next()))
|
|
260
|
+
wm_end.set("SecTime", str(duration_secs))
|
|
261
|
+
wm_end.set("BeatTime", str(duration_beats))
|
|
262
|
+
|
|
263
|
+
# WarpMode: 0 = Beats (default for arrangement clips)
|
|
264
|
+
_val(clip, "WarpMode", "0")
|
|
265
|
+
|
|
266
|
+
# SampleRef — use absolute path so Ableton can find the audio
|
|
267
|
+
sample_ref = ET.SubElement(clip, "SampleRef")
|
|
268
|
+
file_ref = ET.SubElement(sample_ref, "FileRef")
|
|
269
|
+
_val(file_ref, "RelativePathType", "1")
|
|
270
|
+
_val(file_ref, "RelativePath", f"Samples/Imported/{ref.filename}")
|
|
271
|
+
if project_folder:
|
|
272
|
+
abs_path = (project_folder / "Samples" / "Imported" / ref.filename).resolve()
|
|
273
|
+
_val(file_ref, "Path", str(abs_path).replace("\\", "/"))
|
|
274
|
+
else:
|
|
275
|
+
_val(file_ref, "Path", "")
|
|
276
|
+
_val(file_ref, "Type", "1")
|
|
277
|
+
_val(file_ref, "LivePackName", "")
|
|
278
|
+
_val(file_ref, "LivePackId", "")
|
|
279
|
+
try:
|
|
280
|
+
_val(file_ref, "OriginalFileSize", str(ref.file_path.stat().st_size))
|
|
281
|
+
except Exception:
|
|
282
|
+
_val(file_ref, "OriginalFileSize", "0")
|
|
283
|
+
_val(file_ref, "OriginalCrc", "0")
|
|
284
|
+
# LastModDate — file modification time as Unix timestamp (in SampleRef, not FileRef)
|
|
285
|
+
try:
|
|
286
|
+
_val(sample_ref, "LastModDate", str(int(ref.file_path.stat().st_mtime)))
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
ET.SubElement(sample_ref, "SourceContext")
|
|
290
|
+
_val(sample_ref, "SampleUsageHint", "0")
|
|
291
|
+
_val(sample_ref, "DefaultDuration", str(duration_samples))
|
|
292
|
+
_val(sample_ref, "DefaultSampleRate", str(file_sample_rate))
|
|
293
|
+
|
|
294
|
+
# Envelopes
|
|
295
|
+
envelopes = ET.SubElement(clip, "Envelopes")
|
|
296
|
+
ET.SubElement(envelopes, "Envelopes")
|
|
297
|
+
|
|
298
|
+
return clip
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _pick_best_clip(clips: list[AudioFileRef]) -> AudioFileRef | None:
|
|
302
|
+
"""Pick the best clip for a track from multiple overlapping takes.
|
|
303
|
+
|
|
304
|
+
Priority: comp file > bounce-in-place > latest take (highest take number).
|
|
305
|
+
"""
|
|
306
|
+
if not clips:
|
|
307
|
+
return None
|
|
308
|
+
if len(clips) == 1:
|
|
309
|
+
return clips[0]
|
|
310
|
+
|
|
311
|
+
# Prefer comp files
|
|
312
|
+
comps = [c for c in clips if c.is_comp]
|
|
313
|
+
if comps:
|
|
314
|
+
return comps[0]
|
|
315
|
+
|
|
316
|
+
# Prefer bounce-in-place files
|
|
317
|
+
bips = [c for c in clips if "_bip" in c.filename]
|
|
318
|
+
if bips:
|
|
319
|
+
return bips[0]
|
|
320
|
+
|
|
321
|
+
# Fall back to latest take (highest take number)
|
|
322
|
+
return max(clips, key=lambda c: c.take_number)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _get_clip_end_samples(ref: AudioFileRef, sample_rate: int) -> int:
|
|
326
|
+
"""Get the end position of a clip in samples."""
|
|
327
|
+
duration_samples, _ = _get_audio_info(ref.file_path)
|
|
328
|
+
return ref.start_position_samples + duration_samples
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _resolve_overlaps(clips: list[AudioFileRef], tempo: float, sample_rate: int) -> list[AudioFileRef]:
|
|
332
|
+
"""Resolve overlapping clips, keeping the best one per overlapping group.
|
|
333
|
+
|
|
334
|
+
Clips at different timeline positions are all kept. When clips overlap
|
|
335
|
+
in their actual time ranges (one starts before the other ends),
|
|
336
|
+
_pick_best_clip selects the best: comp > bounce-in-place > latest take.
|
|
337
|
+
"""
|
|
338
|
+
if not clips:
|
|
339
|
+
return []
|
|
340
|
+
if len(clips) == 1:
|
|
341
|
+
return clips
|
|
342
|
+
|
|
343
|
+
# Sort by start position
|
|
344
|
+
sorted_clips = sorted(clips, key=lambda c: c.start_position_samples)
|
|
345
|
+
|
|
346
|
+
# Group clips that overlap in time range using a sweep-line approach
|
|
347
|
+
groups: list[list[AudioFileRef]] = []
|
|
348
|
+
current_group: list[AudioFileRef] = [sorted_clips[0]]
|
|
349
|
+
group_end = _get_clip_end_samples(sorted_clips[0], sample_rate)
|
|
350
|
+
|
|
351
|
+
for clip in sorted_clips[1:]:
|
|
352
|
+
if clip.start_position_samples < group_end:
|
|
353
|
+
# Overlaps with current group
|
|
354
|
+
current_group.append(clip)
|
|
355
|
+
clip_end = _get_clip_end_samples(clip, sample_rate)
|
|
356
|
+
group_end = max(group_end, clip_end)
|
|
357
|
+
else:
|
|
358
|
+
groups.append(current_group)
|
|
359
|
+
current_group = [clip]
|
|
360
|
+
group_end = _get_clip_end_samples(clip, sample_rate)
|
|
361
|
+
groups.append(current_group)
|
|
362
|
+
|
|
363
|
+
# Pick best clip from each group
|
|
364
|
+
result = []
|
|
365
|
+
for group in groups:
|
|
366
|
+
best = _pick_best_clip(group)
|
|
367
|
+
if best is not None:
|
|
368
|
+
result.append(best)
|
|
369
|
+
return result
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _inject_clips_into_track(
|
|
373
|
+
track: ET.Element,
|
|
374
|
+
clips: list[AudioFileRef],
|
|
375
|
+
allocator: _IdAllocator,
|
|
376
|
+
tempo: float,
|
|
377
|
+
sample_rate: int,
|
|
378
|
+
project_folder: Path | None = None,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Inject AudioClip elements into a track's arrangement view.
|
|
381
|
+
|
|
382
|
+
Clips are placed at their BWF-derived timeline positions. Overlapping clips
|
|
383
|
+
at the same position are resolved (best clip selected); clips at different
|
|
384
|
+
positions are all included.
|
|
385
|
+
"""
|
|
386
|
+
if not clips:
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
# Find the Sample > ArrangerAutomation > Events path in MainSequencer
|
|
390
|
+
main_seq = track.find(".//MainSequencer")
|
|
391
|
+
if main_seq is None:
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
sample = main_seq.find("Sample")
|
|
395
|
+
if sample is None:
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
arranger = sample.find("ArrangerAutomation")
|
|
399
|
+
if arranger is None:
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
events = arranger.find("Events")
|
|
403
|
+
if events is None:
|
|
404
|
+
events = ET.SubElement(arranger, "Events")
|
|
405
|
+
|
|
406
|
+
# Clear any existing clips
|
|
407
|
+
for existing in list(events):
|
|
408
|
+
events.remove(existing)
|
|
409
|
+
|
|
410
|
+
# Resolve overlapping clips, keep all non-overlapping
|
|
411
|
+
selected = _resolve_overlaps(clips, tempo, sample_rate)
|
|
412
|
+
|
|
413
|
+
for ref in selected:
|
|
414
|
+
clip_elem = _make_audio_clip_xml(
|
|
415
|
+
allocator=allocator,
|
|
416
|
+
ref=ref,
|
|
417
|
+
tempo=tempo,
|
|
418
|
+
sample_rate=sample_rate,
|
|
419
|
+
project_folder=project_folder,
|
|
420
|
+
)
|
|
421
|
+
events.append(clip_elem)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def generate_als(
|
|
425
|
+
project: LogicProject,
|
|
426
|
+
output_dir: Path,
|
|
427
|
+
copy_audio: bool = True,
|
|
428
|
+
template_path: Path | None = None,
|
|
429
|
+
) -> Path:
|
|
430
|
+
"""Generate a gzipped XML Ableton Live Set (.als) file.
|
|
431
|
+
|
|
432
|
+
Uses the real Ableton DefaultLiveSet.als as a structural template,
|
|
433
|
+
then injects our tracks, clips, tempo, and time signature.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
project: Parsed Logic Pro project.
|
|
437
|
+
output_dir: Directory to write the Ableton project into.
|
|
438
|
+
copy_audio: If True, copy audio files to the project's Samples/Imported folder.
|
|
439
|
+
template_path: Explicit path to DefaultLiveSet.als. Auto-detected if None.
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
Path to the created .als file.
|
|
443
|
+
"""
|
|
444
|
+
output_dir = Path(output_dir)
|
|
445
|
+
project_folder = output_dir / f"{project.name} Project"
|
|
446
|
+
project_folder.mkdir(parents=True, exist_ok=True)
|
|
447
|
+
|
|
448
|
+
# Load the real Ableton template
|
|
449
|
+
resolved_template = _find_template(template_path)
|
|
450
|
+
if resolved_template is None:
|
|
451
|
+
raise FileNotFoundError(
|
|
452
|
+
"Ableton Live 12 DefaultLiveSet.als template not found. "
|
|
453
|
+
"Ensure Ableton Live 12 is installed."
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
tree = ET.parse(gzip.open(resolved_template))
|
|
457
|
+
root = tree.getroot()
|
|
458
|
+
live_set = root.find("LiveSet")
|
|
459
|
+
|
|
460
|
+
# Find the template AudioTrack to use as a structural base
|
|
461
|
+
tracks_elem = live_set.find("Tracks")
|
|
462
|
+
template_audio_track = None
|
|
463
|
+
for track in list(tracks_elem):
|
|
464
|
+
if track.tag == "AudioTrack":
|
|
465
|
+
template_audio_track = track
|
|
466
|
+
break
|
|
467
|
+
|
|
468
|
+
if template_audio_track is None:
|
|
469
|
+
raise RuntimeError("No AudioTrack found in Ableton template")
|
|
470
|
+
|
|
471
|
+
# Remove Audio and MIDI tracks but keep ReturnTracks (send bus)
|
|
472
|
+
return_tracks = []
|
|
473
|
+
for track in list(tracks_elem):
|
|
474
|
+
if track.tag == "ReturnTrack":
|
|
475
|
+
return_tracks.append(track)
|
|
476
|
+
tracks_elem.remove(track)
|
|
477
|
+
|
|
478
|
+
# Initialize ID allocator starting after all existing IDs in the template
|
|
479
|
+
next_id_elem = live_set.find("NextPointeeId")
|
|
480
|
+
start_id = int(next_id_elem.get("Value")) if next_id_elem is not None else 30000
|
|
481
|
+
allocator = _IdAllocator(start_id)
|
|
482
|
+
|
|
483
|
+
# Group audio files by track name
|
|
484
|
+
clips_by_track: dict[str, list[AudioFileRef]] = {}
|
|
485
|
+
for ref in project.audio_files:
|
|
486
|
+
clips_by_track.setdefault(ref.track_name, []).append(ref)
|
|
487
|
+
|
|
488
|
+
# Create one audio track per Logic track
|
|
489
|
+
for i, track_name in enumerate(project.track_names):
|
|
490
|
+
color = i % 16
|
|
491
|
+
track = _clone_track(template_audio_track, allocator, track_name, color)
|
|
492
|
+
|
|
493
|
+
# Inject clips into the track's arrangement view
|
|
494
|
+
track_clips = clips_by_track.get(track_name, [])
|
|
495
|
+
_inject_clips_into_track(
|
|
496
|
+
track, track_clips, allocator, project.tempo, project.sample_rate, project_folder
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
# Apply mixer values when present.
|
|
500
|
+
if project.mixer_state:
|
|
501
|
+
_set_mixer_state(track, project.mixer_state.get(track_name))
|
|
502
|
+
|
|
503
|
+
tracks_elem.append(track)
|
|
504
|
+
|
|
505
|
+
# Re-add return tracks (must come after audio tracks)
|
|
506
|
+
for rt in return_tracks:
|
|
507
|
+
tracks_elem.append(rt)
|
|
508
|
+
|
|
509
|
+
# Update NextPointeeId to be above all allocated IDs
|
|
510
|
+
if next_id_elem is not None:
|
|
511
|
+
next_id_elem.set("Value", str(allocator.current))
|
|
512
|
+
|
|
513
|
+
# Set tempo on MainTrack mixer
|
|
514
|
+
main_track = live_set.find("MainTrack")
|
|
515
|
+
if main_track is not None:
|
|
516
|
+
tempo_elem = main_track.find(".//Tempo/Manual")
|
|
517
|
+
if tempo_elem is not None:
|
|
518
|
+
tempo_elem.set("Value", str(int(project.tempo)))
|
|
519
|
+
|
|
520
|
+
# Set tempo in Transport
|
|
521
|
+
transport = live_set.find("Transport")
|
|
522
|
+
if transport is not None:
|
|
523
|
+
tempo_manual = transport.find(".//Tempo/Manual")
|
|
524
|
+
if tempo_manual is not None:
|
|
525
|
+
tempo_manual.set("Value", str(int(project.tempo)))
|
|
526
|
+
|
|
527
|
+
# Set time signature
|
|
528
|
+
ts = transport.find(".//TimeSignatures/RemoteableTimeSignature")
|
|
529
|
+
if ts is not None:
|
|
530
|
+
num = ts.find("Numerator")
|
|
531
|
+
if num is not None:
|
|
532
|
+
num.set("Value", str(project.time_sig_numerator))
|
|
533
|
+
den = ts.find("Denominator")
|
|
534
|
+
if den is not None:
|
|
535
|
+
den.set("Value", str(project.time_sig_denominator))
|
|
536
|
+
|
|
537
|
+
# Update the Creator attribute
|
|
538
|
+
root.set("Creator", "logic2ableton converter")
|
|
539
|
+
|
|
540
|
+
# Write gzipped XML
|
|
541
|
+
buffer = io.BytesIO()
|
|
542
|
+
tree.write(buffer, encoding="UTF-8", xml_declaration=True)
|
|
543
|
+
xml_bytes = buffer.getvalue()
|
|
544
|
+
|
|
545
|
+
als_path = project_folder / f"{project.name}.als"
|
|
546
|
+
with gzip.open(als_path, "wb") as f:
|
|
547
|
+
f.write(xml_bytes)
|
|
548
|
+
|
|
549
|
+
# Copy audio files if requested
|
|
550
|
+
if copy_audio:
|
|
551
|
+
samples_dir = project_folder / "Samples" / "Imported"
|
|
552
|
+
samples_dir.mkdir(parents=True, exist_ok=True)
|
|
553
|
+
for audio_ref in project.audio_files:
|
|
554
|
+
dest = samples_dir / audio_ref.filename
|
|
555
|
+
if audio_ref.file_path.exists():
|
|
556
|
+
shutil.copy2(audio_ref.file_path, dest)
|
|
557
|
+
|
|
558
|
+
return als_path
|