logic2ableton 2.3.0__tar.gz → 2.3.2__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 (54) hide show
  1. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/PKG-INFO +17 -1
  2. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/README.md +16 -0
  3. logic2ableton-2.3.2/logic2ableton/__init__.py +1 -0
  4. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/ableton_generator.py +29 -23
  5. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/cli.py +34 -12
  6. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/logic_parser.py +116 -29
  7. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/logic_project_data.py +1 -1
  8. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/protools_import.py +94 -33
  9. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/protools_parser.py +7 -3
  10. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/protools_transfer.py +25 -9
  11. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton.egg-info/PKG-INFO +17 -1
  12. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton.egg-info/SOURCES.txt +1 -0
  13. logic2ableton-2.3.2/tests/test_arrangement_regressions.py +180 -0
  14. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_cli.py +19 -0
  15. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_cli_protools.py +126 -0
  16. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_protools_parser.py +68 -0
  17. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_review_regressions.py +2 -1
  18. logic2ableton-2.3.0/logic2ableton/__init__.py +0 -1
  19. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/LICENSE +0 -0
  20. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/__main__.py +0 -0
  21. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/ableton_metadata.py +0 -0
  22. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/ableton_parser.py +0 -0
  23. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/audio.py +0 -0
  24. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/data/DefaultLiveSet.als +0 -0
  25. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/logic_transfer.py +0 -0
  26. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/models.py +0 -0
  27. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/paths.py +0 -0
  28. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/plugin_database.py +0 -0
  29. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/plugin_matcher.py +0 -0
  30. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/report.py +0 -0
  31. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/smf.py +0 -0
  32. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/timeline.py +0 -0
  33. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/vst3_scanner.py +0 -0
  34. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton.egg-info/dependency_links.txt +0 -0
  35. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton.egg-info/entry_points.txt +0 -0
  36. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton.egg-info/top_level.txt +0 -0
  37. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/pyproject.toml +0 -0
  38. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/setup.cfg +0 -0
  39. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_ableton_generator.py +0 -0
  40. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_ableton_parser.py +0 -0
  41. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_cli_reverse.py +0 -0
  42. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_forward_midi.py +0 -0
  43. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_logic_parser.py +0 -0
  44. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_logic_project_data.py +0 -0
  45. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_logic_transfer.py +0 -0
  46. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_midi_transfer.py +0 -0
  47. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_models.py +0 -0
  48. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_native_midi.py +0 -0
  49. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_plugin_database.py +0 -0
  50. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_plugin_matcher.py +0 -0
  51. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_protools_transfer.py +0 -0
  52. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_report.py +0 -0
  53. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_timeline.py +0 -0
  54. {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_vst3_scanner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logic2ableton
3
- Version: 2.3.0
3
+ Version: 2.3.2
4
4
  Summary: Session transfer between Logic Pro, Ableton Live, and Pro Tools - six conversion lanes with evidence-first reports
5
5
  Author: evilander
6
6
  License-Expression: MIT
@@ -123,6 +123,8 @@ The product goal is **speed with evidence**: every run emits a report showing ex
123
123
  - MIDI notes transfer as native MIDI tracks (and `.mid` exports), but the software instruments, their settings, and MIDI effects are not recreated — reload instruments in Ableton
124
124
  - Region placement, loops, track names and markers come from the project's own arrangement data. That decoding was worked out on Logic 10.6 and Logic 11 saves; the report says "Region positions: read from the Logic arrangement" when it applies. Saves it cannot read fall back to audio timestamps, `MIDI 1`, `MIDI 2`, ... track names, and a warning
125
125
  - Regions that start before bar 1 are moved to bar 1 (audio is trimmed by the same amount) because a Live arrangement cannot start earlier
126
+ - Looped audio regions are written as repeated clips, spaced at the project tempo. If the tempo changes under a looped audio region, check the repeats; the report lists every region it unrolled
127
+ - Logic tracks that share a name are kept apart by numbering the later ones (`Guitar`, `Guitar (2)`), and the report says which were renamed
126
128
  - Logic's tempo track is not decoded (only the project tempo is); supply tempo changes with `--timeline`
127
129
  - `--smpte-start` only matters for the timestamp fallback; it must then match the project's own synchronization setting (default `01:00:00:00`), and the report lists any files placed at bar 1 because their timestamp precedes it
128
130
  - Automation is not recreated
@@ -314,6 +316,20 @@ logic2ableton "/path/to/MySong.logicx" --output ./output --json-progress
314
316
  ableton2logic "/path/to/MySet.als" --output ./output --json-progress
315
317
  ```
316
318
 
319
+ Progress events report audio counts (`tracks`, `clips`, `audio_files`) separately
320
+ from `midi_tracks` and `midi_notes`. Previews include recovered MIDI content.
321
+ For Ableton output, the completion event's `midi_tracks` counts native tracks in
322
+ the set; `midi_files` separately counts exported `.mid` sidecars.
323
+
324
+ A failed run ends with an `error` event whose `failure_stage` names the step that
325
+ failed (for example `timeline`, `parsing` or `generating`) and whose `error`
326
+ holds the reason. The saved report keeps any analysis already done and ends
327
+ with the same stage and reason.
328
+
329
+ Pro Tools previews check referenced audio on disk. The report lists found,
330
+ missing, and skipped sources, and `compatibility_warnings` names missing media
331
+ before conversion begins. These notes also appear in the desktop preview.
332
+
317
333
  ## CLI Options
318
334
 
319
335
  Every lane's input argument accepts one or more paths. Pass several to convert
@@ -99,6 +99,8 @@ The product goal is **speed with evidence**: every run emits a report showing ex
99
99
  - MIDI notes transfer as native MIDI tracks (and `.mid` exports), but the software instruments, their settings, and MIDI effects are not recreated — reload instruments in Ableton
100
100
  - Region placement, loops, track names and markers come from the project's own arrangement data. That decoding was worked out on Logic 10.6 and Logic 11 saves; the report says "Region positions: read from the Logic arrangement" when it applies. Saves it cannot read fall back to audio timestamps, `MIDI 1`, `MIDI 2`, ... track names, and a warning
101
101
  - Regions that start before bar 1 are moved to bar 1 (audio is trimmed by the same amount) because a Live arrangement cannot start earlier
102
+ - Looped audio regions are written as repeated clips, spaced at the project tempo. If the tempo changes under a looped audio region, check the repeats; the report lists every region it unrolled
103
+ - Logic tracks that share a name are kept apart by numbering the later ones (`Guitar`, `Guitar (2)`), and the report says which were renamed
102
104
  - Logic's tempo track is not decoded (only the project tempo is); supply tempo changes with `--timeline`
103
105
  - `--smpte-start` only matters for the timestamp fallback; it must then match the project's own synchronization setting (default `01:00:00:00`), and the report lists any files placed at bar 1 because their timestamp precedes it
104
106
  - Automation is not recreated
@@ -290,6 +292,20 @@ logic2ableton "/path/to/MySong.logicx" --output ./output --json-progress
290
292
  ableton2logic "/path/to/MySet.als" --output ./output --json-progress
291
293
  ```
292
294
 
295
+ Progress events report audio counts (`tracks`, `clips`, `audio_files`) separately
296
+ from `midi_tracks` and `midi_notes`. Previews include recovered MIDI content.
297
+ For Ableton output, the completion event's `midi_tracks` counts native tracks in
298
+ the set; `midi_files` separately counts exported `.mid` sidecars.
299
+
300
+ A failed run ends with an `error` event whose `failure_stage` names the step that
301
+ failed (for example `timeline`, `parsing` or `generating`) and whose `error`
302
+ holds the reason. The saved report keeps any analysis already done and ends
303
+ with the same stage and reason.
304
+
305
+ Pro Tools previews check referenced audio on disk. The report lists found,
306
+ missing, and skipped sources, and `compatibility_warnings` names missing media
307
+ before conversion begins. These notes also appear in the desktop preview.
308
+
293
309
  ## CLI Options
294
310
 
295
311
  Every lane's input argument accepts one or more paths. Pass several to convert
@@ -0,0 +1 @@
1
+ __version__ = "2.3.2"
@@ -314,33 +314,39 @@ def _make_audio_clip_xml(
314
314
  _val(ts_remote, "Denominator", str(time_sig_denominator))
315
315
  _val(ts_remote, "Time", "0")
316
316
 
317
- # WarpMarkers — start and end always; warped clips that cross a tempo
318
- # breakpoint get an extra marker at each one so Live's playback speed
319
- # tracks the song's actual tempo curve instead of one flat ratio for
320
- # the whole file. With no timeline, this reduces to the plain
321
- # single-tempo formula (no map lookups) so output is unchanged.
317
+ # WarpMarkers map source seconds to clip-content beats — start and end
318
+ # always; warped clips that cross a tempo breakpoint get an extra marker at
319
+ # each one so Live's playback speed tracks the song's actual tempo curve
320
+ # instead of one flat ratio for the whole file. With no timeline, this
321
+ # reduces to the plain single-tempo formula (no map lookups) so output is
322
+ # unchanged.
323
+ #
324
+ # Under a tempo map the played slice starts at source second offset_secs,
325
+ # which sits at the clip's arrangement position. Source audio before the
326
+ # slice never plays and keeps the flat base-tempo ratio, so LoopStart
327
+ # (offset_beats, computed at the base tempo) still lands on offset_secs;
328
+ # from there on, content beats follow the tempo map from start_beats.
329
+ marker_points: list[tuple[float, float]] = [(0.0, 0.0)]
322
330
  if tempo_map.events:
323
- end_abs_beat = tempo_map.seconds_to_beats(start_seconds + duration_secs)
324
- warp_end_beat_time = end_abs_beat - start_beats
325
- breakpoints = tempo_map.breakpoints_between(start_beats, end_abs_beat) if is_warped else []
331
+ end_abs_beat = tempo_map.seconds_to_beats(start_seconds + duration_secs - offset_secs)
332
+ if offset_secs > 0:
333
+ marker_points.append((offset_secs, offset_beats))
334
+ if is_warped:
335
+ for event in tempo_map.breakpoints_between(start_beats, end_abs_beat):
336
+ marker_points.append((
337
+ offset_secs + tempo_map.beats_to_seconds(event.beat) - start_seconds,
338
+ offset_beats + event.beat - start_beats,
339
+ ))
340
+ marker_points.append((duration_secs, offset_beats + end_abs_beat - start_beats))
326
341
  else:
327
- warp_end_beat_time = duration_secs * tempo_map.base_tempo / 60
328
- breakpoints = []
342
+ marker_points.append((duration_secs, duration_secs * tempo_map.base_tempo / 60))
329
343
 
330
344
  warp_markers = ET.SubElement(clip, "WarpMarkers")
331
- wm_start = ET.SubElement(warp_markers, "WarpMarker")
332
- wm_start.set("Id", str(allocator.next()))
333
- wm_start.set("SecTime", "0")
334
- wm_start.set("BeatTime", "0")
335
- for event in breakpoints:
336
- wm_mid = ET.SubElement(warp_markers, "WarpMarker")
337
- wm_mid.set("Id", str(allocator.next()))
338
- wm_mid.set("SecTime", str(tempo_map.beats_to_seconds(event.beat) - start_seconds))
339
- wm_mid.set("BeatTime", str(event.beat - start_beats))
340
- wm_end = ET.SubElement(warp_markers, "WarpMarker")
341
- wm_end.set("Id", str(allocator.next()))
342
- wm_end.set("SecTime", str(duration_secs))
343
- wm_end.set("BeatTime", str(warp_end_beat_time))
345
+ for sec_time, beat_time in marker_points:
346
+ marker = ET.SubElement(warp_markers, "WarpMarker")
347
+ marker.set("Id", str(allocator.next()))
348
+ marker.set("SecTime", "0" if sec_time == 0 else str(sec_time))
349
+ marker.set("BeatTime", "0" if beat_time == 0 else str(beat_time))
344
350
 
345
351
  # WarpMode: 0 = Beats (default for arrangement clips)
346
352
  _val(clip, "WarpMode", "0")
@@ -25,6 +25,7 @@ from logic2ableton.protools_import import (
25
25
  build_protools_import_report,
26
26
  protools_to_ableton_project,
27
27
  protools_to_logic_project,
28
+ resolve_protools_media,
28
29
  )
29
30
  from logic2ableton.protools_parser import parse_protools_session
30
31
  from logic2ableton.protools_transfer import (
@@ -277,12 +278,21 @@ def _emit_failure(
277
278
  report_path = _report_path(
278
279
  output_dir, input_path, project_name=project_name, suffix=report_suffix, unique=unique
279
280
  )
280
- report_text = report or _build_failure_report(mode, input_path, stage, error)
281
+ if report:
282
+ # A failure after analysis keeps the analysis report, but the saved
283
+ # report must still say what failed and why.
284
+ report_text = (
285
+ f"{report.rstrip()}\n\nCONVERSION FAILED\n Stage: {stage}\n Error: {error}\n"
286
+ )
287
+ else:
288
+ report_text = _build_failure_report(mode, input_path, stage, error)
281
289
  _, report_note = _persist_report_with_note(report_path, report_text)
282
290
  message = f"Failed during {stage}: {error}.{report_note}"
283
291
  payload: dict[str, object] = {
284
292
  "direction": mode,
285
293
  "input": str(input_path),
294
+ "failure_stage": stage,
295
+ "error": error,
286
296
  "report": report_text,
287
297
  "report_path": str(report_path),
288
298
  "artifact_path": str(report_path),
@@ -671,6 +681,7 @@ def _run_forward(args: argparse.Namespace) -> int:
671
681
  compatibility_warnings=project.compatibility_warnings,
672
682
  )
673
683
  if jp:
684
+ midi_track_count = sum(1 for track in project.midi_tracks if track.note_count > 0)
674
685
  _emit(
675
686
  "complete",
676
687
  1.0,
@@ -683,6 +694,8 @@ def _run_forward(args: argparse.Namespace) -> int:
683
694
  tracks=len(project.track_names),
684
695
  audio_files=len(project.audio_files),
685
696
  plugins=len(project.plugins),
697
+ midi_tracks=midi_track_count,
698
+ midi_notes=project.total_midi_notes,
686
699
  compatibility_warnings=project.compatibility_warnings,
687
700
  )
688
701
  else:
@@ -739,7 +752,8 @@ def _run_forward(args: argparse.Namespace) -> int:
739
752
  tracks=len(project.track_names),
740
753
  clips=clip_count,
741
754
  audio_files=audio_count,
742
- midi_tracks=midi_files,
755
+ midi_tracks=sum(1 for track in project.midi_tracks if track.note_count > 0),
756
+ midi_files=midi_files,
743
757
  midi_notes=project.total_midi_notes,
744
758
  compatibility_warnings=project.compatibility_warnings,
745
759
  **({"warning": warning} if warning else {}),
@@ -1016,7 +1030,16 @@ def _run_protools_import(args: argparse.Namespace, mode: str) -> int:
1016
1030
  )
1017
1031
 
1018
1032
  try:
1019
- report = build_protools_import_report(session, destination=destination, tempo=tempo)
1033
+ # Share resolved media and counts between preview and conversion.
1034
+ media_preflight = resolve_protools_media(session)
1035
+ project = (
1036
+ protools_to_logic_project(session, tempo=args.tempo, preflight=media_preflight)
1037
+ if to_ableton else
1038
+ protools_to_ableton_project(session, tempo=args.tempo, preflight=media_preflight)
1039
+ )
1040
+ report = build_protools_import_report(
1041
+ session, destination=destination, tempo=tempo, preflight=media_preflight,
1042
+ )
1020
1043
  except Exception as exc:
1021
1044
  return _emit_failure(
1022
1045
  mode=mode,
@@ -1067,12 +1090,12 @@ def _run_protools_import(args: argparse.Namespace, mode: str) -> int:
1067
1090
  artifact_path=str(report_path),
1068
1091
  report=report,
1069
1092
  report_path=str(report_path),
1070
- tracks=len(session.tracks),
1071
- clips=session.total_regions,
1072
- audio_files=len(session.audio_files),
1073
- midi_tracks=len(session.midi_tracks),
1074
- midi_notes=session.total_midi_notes,
1075
- compatibility_warnings=session.compatibility_warnings,
1093
+ tracks=len(project.track_names) if to_ableton else len(project.audio_tracks),
1094
+ clips=len(project.audio_files) if to_ableton else len(project.clips),
1095
+ audio_files=len(media_preflight.referenced_files),
1096
+ midi_tracks=sum(1 for track in project.midi_tracks if track.note_count > 0),
1097
+ midi_notes=project.total_midi_notes,
1098
+ compatibility_warnings=project.compatibility_warnings,
1076
1099
  )
1077
1100
  else:
1078
1101
  print(f"\nReport: {report_path}")
@@ -1084,7 +1107,6 @@ def _run_protools_import(args: argparse.Namespace, mode: str) -> int:
1084
1107
  print(f"\nGenerating {destination} output in {output_dir}...")
1085
1108
 
1086
1109
  if to_ableton:
1087
- project = protools_to_logic_project(session, tempo=args.tempo)
1088
1110
  template_path = Path(args.template) if args.template else None
1089
1111
  try:
1090
1112
  als_path = generate_als(
@@ -1127,7 +1149,8 @@ def _run_protools_import(args: argparse.Namespace, mode: str) -> int:
1127
1149
  tracks=len(project.track_names),
1128
1150
  clips=clip_count,
1129
1151
  audio_files=audio_count,
1130
- midi_tracks=midi_files,
1152
+ midi_tracks=sum(1 for track in project.midi_tracks if track.note_count > 0),
1153
+ midi_files=midi_files,
1131
1154
  midi_notes=project.total_midi_notes,
1132
1155
  compatibility_warnings=project.compatibility_warnings,
1133
1156
  **({"warning": warning} if warning else {}),
@@ -1144,7 +1167,6 @@ def _run_protools_import(args: argparse.Namespace, mode: str) -> int:
1144
1167
  print("\nDone!")
1145
1168
  return 0
1146
1169
 
1147
- project = protools_to_ableton_project(session, tempo=args.tempo)
1148
1170
  try:
1149
1171
  transfer = generate_logic_transfer(project, output_dir, copy_audio=not args.no_copy)
1150
1172
  except Exception as exc:
@@ -912,10 +912,55 @@ def _unroll_regions(regions: list[LogicMidiRegion]) -> list[LogicMidiNote]:
912
912
  return notes
913
913
 
914
914
 
915
+ def _unique_track_names(arrangement: LogicArrangement, warnings: list[str]) -> dict[int, str]:
916
+ """Display name per Logic track id, numbering tracks that share a name.
917
+
918
+ Outputs group clips by track name, so two Logic tracks both called
919
+ "Guitar" would otherwise merge into one track. The first track (by lane,
920
+ then position) keeps the plain name; later ones become "Guitar (2)", ...
921
+ """
922
+ first_seen: dict[int, tuple[int, int]] = {}
923
+ for placement in arrangement.regions:
924
+ # Only tracks that produce output compete for a name.
925
+ if placement.muted:
926
+ continue
927
+ if placement.kind == "midi":
928
+ sequence = arrangement.sequences.get(placement.sequence_id)
929
+ if sequence is None or not sequence.notes:
930
+ continue
931
+ elif placement.audio_file_id not in arrangement.audio_files:
932
+ continue
933
+ key = (placement.lane, placement.tick)
934
+ if placement.track_id not in first_seen or key < first_seen[placement.track_id]:
935
+ first_seen[placement.track_id] = key
936
+ names: dict[int, str] = {}
937
+ used: set[str] = set()
938
+ renamed: list[str] = []
939
+ for track_id in sorted(first_seen, key=lambda tid: (first_seen[tid], tid)):
940
+ base = arrangement.track_name(track_id)
941
+ name = base
942
+ number = 2
943
+ while name.casefold() in used:
944
+ name = f"{base} ({number})"
945
+ number += 1
946
+ used.add(name.casefold())
947
+ names[track_id] = name
948
+ if name != base:
949
+ renamed.append(name)
950
+ if renamed:
951
+ examples = ", ".join(renamed[:5]) + (", ..." if len(renamed) > 5 else "")
952
+ warnings.append(
953
+ f"{len(renamed)} Logic track(s) share a name with another track and were numbered "
954
+ f"to keep them separate: {examples}"
955
+ )
956
+ return names
957
+
958
+
915
959
  def _arrangement_midi_tracks(
916
960
  arrangement: LogicArrangement,
917
961
  *,
918
962
  bar_beats: float,
963
+ track_names_by_id: dict[int, str],
919
964
  warnings: list[str],
920
965
  ) -> list[LogicMidiTrack]:
921
966
  """One LogicMidiTrack per Logic track that has audible MIDI regions."""
@@ -929,7 +974,7 @@ def _arrangement_midi_tracks(
929
974
  for track_id, placements in sorted(
930
975
  by_track.items(), key=lambda item: (min(p.lane for p in item[1]), min(p.tick for p in item[1]))
931
976
  ):
932
- track_name = arrangement.track_name(track_id)
977
+ track_name = track_names_by_id.get(track_id) or arrangement.track_name(track_id)
933
978
  regions: list[LogicMidiRegion] = []
934
979
  for placement in sorted(placements, key=lambda p: p.tick):
935
980
  sequence = arrangement.sequences.get(placement.sequence_id)
@@ -982,14 +1027,16 @@ def _arrangement_audio_refs(
982
1027
  *,
983
1028
  tempo: float,
984
1029
  bar_beats: float,
1030
+ track_names_by_id: dict[int, str],
985
1031
  warnings: list[str],
986
1032
  ) -> tuple[list[AudioFileRef], list[str]]:
987
- """Audio clips as Logic placed them: one ref per region, on the Logic track's name."""
1033
+ """Audio clips as Logic placed them: one ref per region, one per repetition when looped."""
988
1034
  by_name = {ref.filename.casefold(): ref for ref in discovered}
989
1035
  refs: list[AudioFileRef] = []
990
1036
  lanes: dict[str, int] = {}
991
1037
  missing: list[str] = []
992
1038
  muted: list[str] = []
1039
+ looped: list[str] = []
993
1040
  for placement in sorted(arrangement.regions, key=lambda p: (p.tick, p.lane)):
994
1041
  if placement.kind != "audio":
995
1042
  continue
@@ -997,43 +1044,75 @@ def _arrangement_audio_refs(
997
1044
  if not filename:
998
1045
  continue
999
1046
  source = by_name.get(filename.casefold())
1000
- track_name = arrangement.track_name(placement.track_id)
1047
+ track_name = track_names_by_id.get(placement.track_id) or arrangement.track_name(placement.track_id)
1001
1048
  if source is None:
1002
1049
  missing.append(filename)
1003
1050
  continue
1004
1051
  region = arrangement.audio_regions.get((placement.audio_file_id, placement.audio_region_index))
1005
1052
  region_name = region.name if region and region.name else None
1053
+ label = region_name or filename
1006
1054
  if placement.muted:
1007
- muted.append(f"{track_name}: {region_name or filename}")
1055
+ muted.append(f"{track_name}: {label}")
1008
1056
  continue
1009
1057
  file_rate = _get_audio_sample_rate(source.file_path)
1058
+ samples_per_beat = 60.0 / tempo * file_rate
1010
1059
  start_beats = arrangement.region_beats(placement.tick, bar_beats)
1011
1060
  content_offset = region.content_offset if region else 0
1012
1061
  content_length = region.content_length if region else None
1013
- if start_beats < 0:
1014
- # Live's arrangement starts at bar 1: keep the audio in sync by
1015
- # trimming the part that would sit before it.
1016
- trimmed = round(-start_beats * 60.0 / tempo * file_rate)
1017
- content_offset += trimmed
1018
- if content_length is not None:
1019
- content_length = max(0, content_length - trimmed)
1020
- if content_length == 0:
1021
- warnings.append(
1022
- f"Audio region '{region_name or filename}' on '{track_name}' ends before bar 1 "
1023
- "and was skipped."
1062
+
1063
+ # (start beats, source offset, source length) for each pass the region plays.
1064
+ passes: list[tuple[float, int, int | None]] = [(start_beats, content_offset, content_length)]
1065
+ if placement.looped and content_length:
1066
+ # Logic repeats the region end to end across the loop span and cuts
1067
+ # the last pass at the span's end. Passes are spaced at the project
1068
+ # tempo, which is exact unless the tempo changes under the loop.
1069
+ span_beats = placement.loop_span / PPQ
1070
+ length_beats = content_length / samples_per_beat
1071
+ if span_beats > length_beats * (1 + 1e-9):
1072
+ passes = []
1073
+ repetition = 0
1074
+ while repetition * length_beats < span_beats - 1e-9:
1075
+ remaining_beats = span_beats - repetition * length_beats
1076
+ length = (
1077
+ content_length if remaining_beats >= length_beats
1078
+ else round(remaining_beats * samples_per_beat)
1024
1079
  )
1025
- continue
1026
- start_beats = 0.0
1027
- refs.append(replace(
1028
- source,
1029
- track_name=track_name,
1030
- start_position_samples=max(0, round(start_beats * 60.0 / tempo * file_rate)),
1031
- content_offset_samples=content_offset,
1032
- content_duration_samples=content_length,
1033
- clip_name=region_name,
1034
- start_beats=start_beats,
1035
- ))
1036
- lanes.setdefault(track_name, placement.lane)
1080
+ passes.append((start_beats + repetition * length_beats, content_offset, length))
1081
+ repetition += 1
1082
+ looped.append(f"{track_name}: {label} x{len(passes)}")
1083
+
1084
+ for pass_start, pass_offset, pass_length in passes:
1085
+ if pass_start < 0:
1086
+ # The Live arrangement starts at bar 1: keep the audio in sync
1087
+ # by trimming the part that would sit before it.
1088
+ trimmed = round(-pass_start * samples_per_beat)
1089
+ pass_offset += trimmed
1090
+ if pass_length is not None:
1091
+ pass_length -= trimmed
1092
+ if pass_length <= 0:
1093
+ warnings.append(
1094
+ f"Audio region '{label}' on '{track_name}' ends before bar 1 and was skipped."
1095
+ )
1096
+ continue
1097
+ pass_start = 0.0
1098
+ if pass_length is not None and pass_length <= 0:
1099
+ continue
1100
+ refs.append(replace(
1101
+ source,
1102
+ track_name=track_name,
1103
+ start_position_samples=max(0, round(pass_start * samples_per_beat)),
1104
+ content_offset_samples=pass_offset,
1105
+ content_duration_samples=pass_length,
1106
+ clip_name=region_name,
1107
+ start_beats=pass_start,
1108
+ ))
1109
+ lanes.setdefault(track_name, placement.lane)
1110
+ if looped:
1111
+ examples = ", ".join(looped[:5]) + (", ..." if len(looped) > 5 else "")
1112
+ warnings.append(
1113
+ f"{len(looped)} looped audio region(s) were written as repeated clips spaced at the project "
1114
+ f"tempo; check them if the song changes tempo under the loop: {examples}"
1115
+ )
1037
1116
  if missing:
1038
1117
  unique = sorted(set(missing))
1039
1118
  examples = ", ".join(unique[:5]) + (", ..." if len(unique) > 5 else "")
@@ -1118,9 +1197,17 @@ def parse_logic_project(
1118
1197
  # The project's own arrangement says where every region sits, how it
1119
1198
  # loops and which track owns it, so audio timestamps and the SMPTE
1120
1199
  # start are not needed for placement.
1121
- midi_tracks = _arrangement_midi_tracks(arrangement, bar_beats=bar_beats, warnings=midi_warnings)
1200
+ track_names_by_id = _unique_track_names(arrangement, midi_warnings)
1201
+ midi_tracks = _arrangement_midi_tracks(
1202
+ arrangement, bar_beats=bar_beats, track_names_by_id=track_names_by_id, warnings=midi_warnings,
1203
+ )
1122
1204
  audio_files, track_names = _arrangement_audio_refs(
1123
- arrangement, audio_files, tempo=meta["tempo"], bar_beats=bar_beats, warnings=midi_warnings,
1205
+ arrangement,
1206
+ audio_files,
1207
+ tempo=meta["tempo"],
1208
+ bar_beats=bar_beats,
1209
+ track_names_by_id=track_names_by_id,
1210
+ warnings=midi_warnings,
1124
1211
  )
1125
1212
  regions = {ref.filename: ref.start_position_samples for ref in audio_files}
1126
1213
  timeline = _arrangement_timeline(arrangement)
@@ -445,6 +445,6 @@ def decode_project_data(data: bytes, *, beats_per_bar: float = 4.0) -> LogicArra
445
445
  arrangement.project_start_bar = _project_start_bar(
446
446
  data, arrangement.format_version, int(round(beats_per_bar * PPQ)) or PPQ * 4,
447
447
  )
448
- if len(data) >= 174 and _u32(data, 170) == _u32(data, 174) and 0 < _u32(data, 170) < 10_000_000:
448
+ if len(data) >= 178 and _u32(data, 170) == _u32(data, 174) and 0 < _u32(data, 170) < 10_000_000:
449
449
  arrangement.tempo_bpm = _u32(data, 170) / 10_000
450
450
  return arrangement