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.
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,4 @@
1
+ from logic2ableton.cli import main
2
+ import sys
3
+
4
+ sys.exit(main())
@@ -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