logic2ableton 2.3.1__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.1 → logic2ableton-2.3.2}/PKG-INFO +8 -1
  2. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/README.md +7 -0
  3. logic2ableton-2.3.2/logic2ableton/__init__.py +1 -0
  4. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/ableton_generator.py +29 -23
  5. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/cli.py +10 -1
  6. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/logic_parser.py +116 -29
  7. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/logic_project_data.py +1 -1
  8. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/protools_parser.py +7 -3
  9. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/protools_transfer.py +25 -9
  10. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton.egg-info/PKG-INFO +8 -1
  11. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton.egg-info/SOURCES.txt +1 -0
  12. logic2ableton-2.3.2/tests/test_arrangement_regressions.py +180 -0
  13. logic2ableton-2.3.1/logic2ableton/__init__.py +0 -1
  14. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/LICENSE +0 -0
  15. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/__main__.py +0 -0
  16. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/ableton_metadata.py +0 -0
  17. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/ableton_parser.py +0 -0
  18. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/audio.py +0 -0
  19. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/data/DefaultLiveSet.als +0 -0
  20. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/logic_transfer.py +0 -0
  21. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/models.py +0 -0
  22. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/paths.py +0 -0
  23. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/plugin_database.py +0 -0
  24. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/plugin_matcher.py +0 -0
  25. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/protools_import.py +0 -0
  26. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/report.py +0 -0
  27. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/smf.py +0 -0
  28. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/timeline.py +0 -0
  29. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/vst3_scanner.py +0 -0
  30. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton.egg-info/dependency_links.txt +0 -0
  31. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton.egg-info/entry_points.txt +0 -0
  32. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton.egg-info/top_level.txt +0 -0
  33. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/pyproject.toml +0 -0
  34. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/setup.cfg +0 -0
  35. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_ableton_generator.py +0 -0
  36. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_ableton_parser.py +0 -0
  37. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_cli.py +0 -0
  38. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_cli_protools.py +0 -0
  39. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_cli_reverse.py +0 -0
  40. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_forward_midi.py +0 -0
  41. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_logic_parser.py +0 -0
  42. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_logic_project_data.py +0 -0
  43. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_logic_transfer.py +0 -0
  44. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_midi_transfer.py +0 -0
  45. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_models.py +0 -0
  46. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_native_midi.py +0 -0
  47. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_plugin_database.py +0 -0
  48. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_plugin_matcher.py +0 -0
  49. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_protools_parser.py +0 -0
  50. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_protools_transfer.py +0 -0
  51. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_report.py +0 -0
  52. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_review_regressions.py +0 -0
  53. {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_timeline.py +0 -0
  54. {logic2ableton-2.3.1 → 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.1
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
@@ -319,6 +321,11 @@ from `midi_tracks` and `midi_notes`. Previews include recovered MIDI content.
319
321
  For Ableton output, the completion event's `midi_tracks` counts native tracks in
320
322
  the set; `midi_files` separately counts exported `.mid` sidecars.
321
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
+
322
329
  Pro Tools previews check referenced audio on disk. The report lists found,
323
330
  missing, and skipped sources, and `compatibility_warnings` names missing media
324
331
  before conversion begins. These notes also appear in the desktop preview.
@@ -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
@@ -295,6 +297,11 @@ from `midi_tracks` and `midi_notes`. Previews include recovered MIDI content.
295
297
  For Ableton output, the completion event's `midi_tracks` counts native tracks in
296
298
  the set; `midi_files` separately counts exported `.mid` sidecars.
297
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
+
298
305
  Pro Tools previews check referenced audio on disk. The report lists found,
299
306
  missing, and skipped sources, and `compatibility_warnings` names missing media
300
307
  before conversion begins. These notes also appear in the desktop preview.
@@ -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")
@@ -278,12 +278,21 @@ def _emit_failure(
278
278
  report_path = _report_path(
279
279
  output_dir, input_path, project_name=project_name, suffix=report_suffix, unique=unique
280
280
  )
281
- 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)
282
289
  _, report_note = _persist_report_with_note(report_path, report_text)
283
290
  message = f"Failed during {stage}: {error}.{report_note}"
284
291
  payload: dict[str, object] = {
285
292
  "direction": mode,
286
293
  "input": str(input_path),
294
+ "failure_stage": stage,
295
+ "error": error,
287
296
  "report": report_text,
288
297
  "report_path": str(report_path),
289
298
  "artifact_path": str(report_path),
@@ -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
@@ -382,8 +382,12 @@ def _parse_midi(reader: _SessionReader, blocks: list[_Block]) -> list[ProToolsMi
382
382
  k = block.offset
383
383
  end = block.offset + block.size
384
384
  while k + 35 < end:
385
- found = data.find(b"MdNLB", k, len(data))
386
- if found < 0:
385
+ # Search, header and events all stay inside this block: trailing
386
+ # bytes after one chunk must not let the scan reach the next
387
+ # block's marker, which would parse that chunk twice and shift
388
+ # every later chunk index.
389
+ found = data.find(b"MdNLB", k, end)
390
+ if found < 0 or found + 20 > end:
387
391
  break
388
392
  k = found + 11
389
393
  n_events = reader.u32(k)
@@ -391,7 +395,7 @@ def _parse_midi(reader: _SessionReader, blocks: list[_Block]) -> list[ProToolsMi
391
395
  zero_ticks = reader.u40(k)
392
396
  events: list[tuple[int, int, int, int]] = []
393
397
  for _ in range(n_events):
394
- if k + 35 > len(data):
398
+ if k + 35 > end:
395
399
  break
396
400
  pos = reader.u40(k) - zero_ticks
397
401
  note = data[k + 8]
@@ -9,7 +9,7 @@ from dataclasses import dataclass
9
9
  from pathlib import Path
10
10
  from collections.abc import Iterable
11
11
 
12
- from logic2ableton.audio import write_pcm_wav
12
+ from logic2ableton.audio import BLOCK_FRAMES, write_pcm_wav
13
13
 
14
14
  from logic2ableton.logic_transfer import (
15
15
  DecodedAudio,
@@ -102,7 +102,9 @@ def _render_protools_clip_export(
102
102
 
103
103
  def _logic_audio_export_stem(index: int, ref: AudioFileRef, *, tempo: float, sample_rate: int) -> str:
104
104
  stem = _safe_name(Path(ref.filename).stem, f"clip_{index:03d}")
105
- beats = samples_to_beats(ref.start_position_samples, tempo, sample_rate)
105
+ beats = ref.start_beats if ref.start_beats is not None else samples_to_beats(
106
+ ref.start_position_samples, tempo, sample_rate
107
+ )
106
108
  return f"{index:03d} - {stem} - {beats:09.3f} beats"
107
109
 
108
110
 
@@ -117,12 +119,12 @@ def _render_protools_logic_audio_export(
117
119
  *,
118
120
  cache: dict[Path, DecodedAudio | None],
119
121
  ) -> tuple[str, int | None, Path]:
120
- """Render one Logic-sourced audio file as a Pro Tools-ready timestamped WAV.
122
+ """Render one Logic region as a Pro Tools-ready timestamped WAV.
121
123
 
122
- Logic's own audio files already represent a single recorded region, so no
123
- clip-level slicing is needed here - only re-stamping the bext
124
- TimeReference to the file's start position, which the Logic parser has
125
- already normalized to be relative to bar 1 (no further offset applied).
124
+ Only the slice of the source the region plays is written: from
125
+ ``content_offset_samples`` for ``content_duration_samples`` (the rest of
126
+ the file when unset). The bext TimeReference is the region's arrangement
127
+ position, which the Logic parser has already made relative to bar 1.
126
128
  The destination filename is chosen from the render outcome (see
127
129
  _clip_export_name's docstring / F11, 2026-09-15 review).
128
130
  """
@@ -135,13 +137,21 @@ def _render_protools_logic_audio_export(
135
137
  shutil.copy2(ref.file_path, destination)
136
138
  return "copied-source", None, destination
137
139
 
140
+ start = max(0, min(decoded.frame_count, ref.content_offset_samples))
141
+ available = decoded.frame_count - start
142
+ count = available if ref.content_duration_samples is None else max(0, min(available, ref.content_duration_samples))
143
+
144
+ def window() -> Iterable[bytes]:
145
+ for position in range(start, start + count, BLOCK_FRAMES):
146
+ yield decoded.read_frames(position, min(BLOCK_FRAMES, start + count - position))
147
+
138
148
  destination = _stem_with_extension(destination_stem, ".wav")
139
149
  _write_pt_wav_with_bext(
140
150
  destination,
141
151
  sample_rate=decoded.frame_rate,
142
152
  channels=decoded.channels,
143
153
  sample_width=decoded.sample_width,
144
- frames=decoded.iter_frames(),
154
+ frames=window(),
145
155
  time_reference_samples=ref.start_position_samples,
146
156
  )
147
157
  return "timestamped-wav", ref.start_position_samples, destination
@@ -246,16 +256,22 @@ def _export_logic_audio(
246
256
  if export_mode != "reference-only":
247
257
  copied_audio_files += 1
248
258
  if export_mode == "copied-source":
259
+ trimmed = ref.content_offset_samples > 0 or ref.content_duration_samples is not None
249
260
  project.compatibility_warnings.append(
250
- f"Audio '{ref.filename}' was copied without a new timestamp; place it using the manifest."
261
+ f"Audio '{ref.filename}' was copied without a new timestamp"
262
+ + (" or trim" if trimmed else "")
263
+ + "; place it using the manifest."
251
264
  )
252
265
 
253
266
  manifest_files.append(
254
267
  {
255
268
  "file_index": file_index,
256
269
  "filename": ref.filename,
270
+ "clip_name": ref.clip_name or Path(ref.filename).stem,
257
271
  "export_name": export_name,
258
272
  "start_position_samples": ref.start_position_samples,
273
+ "content_offset_samples": ref.content_offset_samples,
274
+ "content_duration_samples": ref.content_duration_samples,
259
275
  "take_number": ref.take_number,
260
276
  "is_comp": ref.is_comp,
261
277
  "comp_name": ref.comp_name,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logic2ableton
3
- Version: 2.3.1
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
@@ -319,6 +321,11 @@ from `midi_tracks` and `midi_notes`. Previews include recovered MIDI content.
319
321
  For Ableton output, the completion event's `midi_tracks` counts native tracks in
320
322
  the set; `midi_files` separately counts exported `.mid` sidecars.
321
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
+
322
329
  Pro Tools previews check referenced audio on disk. The report lists found,
323
330
  missing, and skipped sources, and `compatibility_warnings` names missing media
324
331
  before conversion begins. These notes also appear in the desktop preview.
@@ -30,6 +30,7 @@ logic2ableton.egg-info/top_level.txt
30
30
  logic2ableton/data/DefaultLiveSet.als
31
31
  tests/test_ableton_generator.py
32
32
  tests/test_ableton_parser.py
33
+ tests/test_arrangement_regressions.py
33
34
  tests/test_cli.py
34
35
  tests/test_cli_protools.py
35
36
  tests/test_cli_reverse.py
@@ -0,0 +1,180 @@
1
+ """Regressions from the 2026-09-16 review: region trims, loops, track identity, bounds."""
2
+
3
+ import gzip
4
+ import json
5
+ import struct
6
+ import wave
7
+ import xml.etree.ElementTree as ET
8
+ from pathlib import Path
9
+
10
+ from logic2ableton.ableton_generator import _BUNDLED_TEMPLATE, generate_als
11
+ from logic2ableton.ableton_parser import parse_ableton_project
12
+ from logic2ableton.cli import main
13
+ from logic2ableton.logic_parser import parse_logic_project
14
+ from logic2ableton.logic_project_data import decode_project_data
15
+ from logic2ableton.protools_parser import PT_TICKS_PER_QUARTER, _deobfuscate, parse_protools_session
16
+ from logic2ableton.protools_transfer import generate_protools_transfer_from_logic
17
+ from logic2ableton.timeline import TempoEvent, Timeline
18
+ from scripts.fixture_builders import (
19
+ _PT_ZERO_TICKS,
20
+ _logic_object,
21
+ _pt_block,
22
+ _pt_midi_event,
23
+ _pt_string,
24
+ build_logic_arrangement_project_data,
25
+ build_synthetic_logicx,
26
+ build_synthetic_ptx,
27
+ )
28
+
29
+ RATE = 44_100
30
+
31
+
32
+ def _three_level_wav(path: Path) -> Path:
33
+ """Six seconds of mono PCM: two seconds each at sample values 1000, 2000, 3000."""
34
+ path.parent.mkdir(parents=True, exist_ok=True)
35
+ with wave.open(str(path), "wb") as handle:
36
+ handle.setparams((1, 2, RATE, 0, "NONE", "not compressed"))
37
+ for value in (1000, 2000, 3000):
38
+ handle.writeframes(struct.pack("<h", value) * RATE * 2)
39
+ return path
40
+
41
+
42
+ def _logic_project(tmp_path: Path, *, tracks=None, placements=None) -> Path:
43
+ """A 120 BPM Logic project whose Guitar region plays seconds 2-4 of Guitar.wav."""
44
+ data = build_logic_arrangement_project_data(
45
+ tracks=tracks or {1: "Guitar"},
46
+ sequences=[],
47
+ midi_regions=[],
48
+ audio_files={10: "Guitar.wav"},
49
+ audio_regions=[{"file": 10, "index": 0, "name": "Middle", "offset": 2 * RATE, "length": 2 * RATE}],
50
+ audio_placements=placements or [{"bar": 3, "track": 1, "file": 10, "lane": 1}],
51
+ )
52
+ logicx = build_synthetic_logicx(tmp_path / "source", project_data=data)
53
+ _three_level_wav(logicx / "Media" / "Audio Files" / "Guitar.wav")
54
+ return logicx
55
+
56
+
57
+ def test_logic_to_protools_renders_only_the_region_slice(tmp_path):
58
+ project = parse_logic_project(_logic_project(tmp_path))
59
+ result = generate_protools_transfer_from_logic(project, tmp_path / "out")
60
+
61
+ exported = next(result.package_path.glob("Audio Files/**/*.wav"))
62
+ with wave.open(str(exported)) as handle:
63
+ frames = handle.getnframes()
64
+ first = struct.unpack("<h", handle.readframes(1))[0]
65
+ assert (frames, first) == (2 * RATE, 2000)
66
+
67
+ manifest = json.loads((result.package_path / "manifest.json").read_text(encoding="utf-8"))
68
+ entry = manifest["tracks"][0]["clips"][0]
69
+ assert (entry["content_offset_samples"], entry["content_duration_samples"]) == (2 * RATE, 2 * RATE)
70
+ # bar 3 at 120 BPM is eight beats, four seconds after bar 1
71
+ assert entry["time_reference_samples"] == 4 * RATE
72
+
73
+
74
+ def test_tempo_map_keeps_the_trimmed_source_offset(tmp_path):
75
+ project = parse_logic_project(_logic_project(tmp_path))
76
+ project.timeline = Timeline(tempo_events=[TempoEvent(beat=4.0, bpm=60.0)], markers=[])
77
+ als = generate_als(project, tmp_path / "out", copy_audio=False, template_path=_BUNDLED_TEMPLATE)
78
+
79
+ clip = parse_ableton_project(als).clips[0]
80
+ assert clip.start_beats == 8.0
81
+ assert clip.source_in_seconds == 2.0
82
+
83
+
84
+ def test_tempo_breakpoint_inside_a_trimmed_clip_maps_from_the_offset(tmp_path):
85
+ project = parse_logic_project(_logic_project(tmp_path, placements=[{"bar": 1.5, "track": 1, "file": 10, "lane": 1}]))
86
+ project.timeline = Timeline(tempo_events=[TempoEvent(beat=4.0, bpm=60.0)], markers=[])
87
+ als = generate_als(project, tmp_path / "out", copy_audio=False, template_path=_BUNDLED_TEMPLATE)
88
+
89
+ root = ET.fromstring(gzip.decompress(als.read_bytes()))
90
+ markers = [(float(m.get("SecTime")), float(m.get("BeatTime"))) for m in root.findall(".//AudioClip/WarpMarkers/WarpMarker")]
91
+ # Region at beat 2 plays from source second 2 (content beat 4 at 120 BPM);
92
+ # the change to 60 BPM at beat 4 is one second later in source and two beats later in content.
93
+ assert markers[:3] == [(0.0, 0.0), (2.0, 4.0), (3.0, 6.0)]
94
+ assert parse_ableton_project(als).clips[0].source_in_seconds == 2.0
95
+
96
+
97
+ def test_looped_audio_region_becomes_repeated_clips(tmp_path):
98
+ # The region is two seconds (four beats at 120 BPM); the loop spans 14 beats.
99
+ logicx = _logic_project(tmp_path, placements=[{"bar": 1, "track": 1, "file": 10, "lane": 1, "loop_beats": 14}])
100
+ project = parse_logic_project(logicx)
101
+
102
+ clips = [(c.start_beats, c.content_offset_samples, c.content_duration_samples) for c in project.audio_files]
103
+ assert clips == [
104
+ (0.0, 2 * RATE, 2 * RATE),
105
+ (4.0, 2 * RATE, 2 * RATE),
106
+ (8.0, 2 * RATE, 2 * RATE),
107
+ (12.0, 2 * RATE, RATE),
108
+ ]
109
+ assert any("looped audio region" in warning and "x4" in warning for warning in project.compatibility_warnings)
110
+
111
+ als = generate_als(project, tmp_path / "out", copy_audio=False, template_path=_BUNDLED_TEMPLATE)
112
+ rendered = parse_ableton_project(als).clips
113
+ assert [(c.start_beats, c.end_beats) for c in rendered] == [(0.0, 4.0), (4.0, 8.0), (8.0, 12.0), (12.0, 14.0)]
114
+
115
+
116
+ def test_same_named_logic_tracks_stay_separate(tmp_path):
117
+ logicx = _logic_project(
118
+ tmp_path,
119
+ tracks={1: "Guitar", 2: "Guitar"},
120
+ placements=[
121
+ {"bar": 1, "track": 1, "file": 10, "lane": 1},
122
+ {"bar": 1, "track": 2, "file": 10, "lane": 2},
123
+ ],
124
+ )
125
+ project = parse_logic_project(logicx)
126
+ assert project.track_names == ["Guitar", "Guitar (2)"]
127
+ assert sorted(ref.track_name for ref in project.audio_files) == ["Guitar", "Guitar (2)"]
128
+ assert any("were numbered" in warning for warning in project.compatibility_warnings)
129
+
130
+ als = generate_als(project, tmp_path / "out", copy_audio=False, template_path=_BUNDLED_TEMPLATE)
131
+ root = ET.fromstring(gzip.decompress(als.read_bytes()))
132
+ assert len(root.findall(".//Tracks/AudioTrack")) == 2
133
+
134
+
135
+ def test_short_project_data_does_not_raise():
136
+ blob = _logic_object("Song", b"\x00" * 142)
137
+ assert len(blob) == 174
138
+ assert decode_project_data(blob).regions == []
139
+
140
+
141
+ def test_protools_midi_chunk_search_stays_inside_its_block(tmp_path):
142
+ ptx = build_synthetic_ptx(tmp_path, midi=False)
143
+ raw = _deobfuscate(ptx.read_bytes())
144
+ for index, pitch in enumerate((60, 64, 67)):
145
+ event = _pt_midi_event(500_000_000, pitch, PT_TICKS_PER_QUARTER, 100)
146
+ payload = b"MdNLB" + b"\x00" * 6 + struct.pack("<I", 1) + event
147
+ if index == 0:
148
+ payload += b"\x00" * 40 # unused tail bytes inside the first chunk's block
149
+ raw += _pt_block(1, 0x2000, payload)
150
+ raw += _pt_block(1, 0x2519, _pt_block(2, 0x251A, b"\x00\x00" + _pt_string("Third chunk")))
151
+ raw += _pt_block(1, 0x2634, _pt_block(2, 0x2633, _pt_block(2, 0x2628, b"\x00\x00") + struct.pack("<I", 2)))
152
+ placement = _pt_block(
153
+ 2, 0x104F,
154
+ b"\x00\x00" + struct.pack("<I", 0) + b"\x00" + (_PT_ZERO_TICKS + 4 * PT_TICKS_PER_QUARTER).to_bytes(5, "little"),
155
+ )
156
+ raw += _pt_block(1, 0x1058, b"\x00\x00" + _pt_block(2, 0x1057, _pt_block(2, 0x1056, placement)))
157
+ ptx.write_bytes(_deobfuscate(raw))
158
+
159
+ session = parse_protools_session(ptx)
160
+ assert [note.pitch for note in session.midi_tracks[0].notes] == [67]
161
+
162
+
163
+ def test_generation_failure_is_written_into_the_saved_report(tmp_path, capsys):
164
+ logicx = _logic_project(tmp_path)
165
+ bad_template = tmp_path / "invalid-template.als"
166
+ bad_template.write_bytes(gzip.compress(b"<Ableton><LiveSet><Tracks/></LiveSet></Ableton>"))
167
+
168
+ exit_code = main([
169
+ str(logicx), "--output", str(tmp_path / "out"), "--json-progress", "--template", str(bad_template),
170
+ ])
171
+ event = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
172
+
173
+ assert exit_code == 1
174
+ assert event["stage"] == "error"
175
+ assert event["failure_stage"] == "generating"
176
+ assert "No AudioTrack found" in event["error"]
177
+ assert "No AudioTrack found" in event["report"]
178
+ saved = Path(event["report_path"]).read_text(encoding="utf-8")
179
+ assert "CONVERSION FAILED" in saved and "No AudioTrack found" in saved
180
+ assert "TRACKS TRANSFERRED" in saved # the analysis report is kept
@@ -1 +0,0 @@
1
- __version__ = "2.3.1"
File without changes
File without changes