logic2ableton 2.3.2__tar.gz → 2.3.4__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.2 → logic2ableton-2.3.4}/PKG-INFO +3 -1
  2. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/README.md +2 -0
  3. logic2ableton-2.3.4/logic2ableton/__init__.py +1 -0
  4. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/logic_parser.py +150 -0
  5. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/logic_project_data.py +16 -0
  6. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton.egg-info/PKG-INFO +3 -1
  7. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_arrangement_regressions.py +155 -0
  8. logic2ableton-2.3.2/logic2ableton/__init__.py +0 -1
  9. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/LICENSE +0 -0
  10. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/__main__.py +0 -0
  11. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/ableton_generator.py +0 -0
  12. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/ableton_metadata.py +0 -0
  13. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/ableton_parser.py +0 -0
  14. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/audio.py +0 -0
  15. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/cli.py +0 -0
  16. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/data/DefaultLiveSet.als +0 -0
  17. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/logic_transfer.py +0 -0
  18. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/models.py +0 -0
  19. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/paths.py +0 -0
  20. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/plugin_database.py +0 -0
  21. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/plugin_matcher.py +0 -0
  22. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/protools_import.py +0 -0
  23. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/protools_parser.py +0 -0
  24. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/protools_transfer.py +0 -0
  25. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/report.py +0 -0
  26. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/smf.py +0 -0
  27. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/timeline.py +0 -0
  28. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/vst3_scanner.py +0 -0
  29. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton.egg-info/SOURCES.txt +0 -0
  30. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton.egg-info/dependency_links.txt +0 -0
  31. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton.egg-info/entry_points.txt +0 -0
  32. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton.egg-info/top_level.txt +0 -0
  33. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/pyproject.toml +0 -0
  34. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/setup.cfg +0 -0
  35. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_ableton_generator.py +0 -0
  36. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_ableton_parser.py +0 -0
  37. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_cli.py +0 -0
  38. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_cli_protools.py +0 -0
  39. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_cli_reverse.py +0 -0
  40. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_forward_midi.py +0 -0
  41. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_logic_parser.py +0 -0
  42. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_logic_project_data.py +0 -0
  43. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_logic_transfer.py +0 -0
  44. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_midi_transfer.py +0 -0
  45. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_models.py +0 -0
  46. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_native_midi.py +0 -0
  47. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_plugin_database.py +0 -0
  48. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_plugin_matcher.py +0 -0
  49. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_protools_parser.py +0 -0
  50. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_protools_transfer.py +0 -0
  51. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_report.py +0 -0
  52. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_review_regressions.py +0 -0
  53. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_timeline.py +0 -0
  54. {logic2ableton-2.3.2 → logic2ableton-2.3.4}/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.2
3
+ Version: 2.3.4
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
@@ -125,6 +125,8 @@ The product goal is **speed with evidence**: every run emits a report showing ex
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
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
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
128
+ - A Live track plays one clip at a time. Where audio regions overlap on one Logic track, the later region is kept whole and the earlier one is cut around it; overlapping MIDI regions become one clip holding all their notes. The report lists each case. No real project with overlapping regions has been checked yet, so compare those spots with Logic
129
+ - Region fades, crossfades and region gain are not transferred, so edit points are plain cuts in Live. Take folders are untested; flatten them in Logic first
128
130
  - Logic's tempo track is not decoded (only the project tempo is); supply tempo changes with `--timeline`
129
131
  - `--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
130
132
  - Automation is not recreated
@@ -101,6 +101,8 @@ The product goal is **speed with evidence**: every run emits a report showing ex
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
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
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
104
+ - A Live track plays one clip at a time. Where audio regions overlap on one Logic track, the later region is kept whole and the earlier one is cut around it; overlapping MIDI regions become one clip holding all their notes. The report lists each case. No real project with overlapping regions has been checked yet, so compare those spots with Logic
105
+ - Region fades, crossfades and region gain are not transferred, so edit points are plain cuts in Live. Take folders are untested; flatten them in Logic first
104
106
  - Logic's tempo track is not decoded (only the project tempo is); supply tempo changes with `--timeline`
105
107
  - `--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
106
108
  - Automation is not recreated
@@ -0,0 +1 @@
1
+ __version__ = "2.3.4"
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  import bisect
9
9
  from dataclasses import replace
10
10
 
11
+ from logic2ableton.audio import read_audio_info
11
12
  from logic2ableton.logic_project_data import (
12
13
  PPQ,
13
14
  SEQUENCE_ORIGIN_TICKS,
@@ -912,6 +913,145 @@ def _unroll_regions(regions: list[LogicMidiRegion]) -> list[LogicMidiNote]:
912
913
  return notes
913
914
 
914
915
 
916
+ def _without_stacked_notes(notes: list[LogicMidiNote]) -> list[LogicMidiNote]:
917
+ """Drop duplicate notes and end a note where the next one of its pitch starts."""
918
+ by_pitch: dict[int, list[LogicMidiNote]] = {}
919
+ for note in sorted(notes, key=lambda note: (note.start_beats, -note.duration_beats)):
920
+ same_pitch = by_pitch.setdefault(note.pitch, [])
921
+ if same_pitch:
922
+ previous = same_pitch[-1]
923
+ if note.start_beats - previous.start_beats < 1e-9:
924
+ previous.velocity = max(previous.velocity, note.velocity)
925
+ continue
926
+ previous.duration_beats = min(previous.duration_beats, note.start_beats - previous.start_beats)
927
+ same_pitch.append(replace(note))
928
+ return sorted((note for group in by_pitch.values() for note in group), key=lambda note: (note.start_beats, note.pitch))
929
+
930
+
931
+ def _merge_overlapping_midi(regions: list[LogicMidiRegion]) -> tuple[list[LogicMidiRegion], list[str]]:
932
+ """Join MIDI regions that overlap on one track into a single region.
933
+
934
+ Logic plays overlapping MIDI regions together. A Live track plays one clip
935
+ at a time, so each overlapping group becomes one clip with every note the
936
+ group plays (loops unrolled). Returns the regions and the merged groups' names.
937
+ """
938
+ half_tick = 0.5 / PPQ
939
+ groups: list[list[LogicMidiRegion]] = []
940
+ group_end = 0.0
941
+ for region in sorted(regions, key=lambda region: region.start_beats):
942
+ if groups and region.start_beats < group_end - half_tick:
943
+ groups[-1].append(region)
944
+ group_end = max(group_end, region.end_beats)
945
+ else:
946
+ groups.append([region])
947
+ group_end = region.end_beats
948
+
949
+ out: list[LogicMidiRegion] = []
950
+ merged_names: list[str] = []
951
+ for group in groups:
952
+ if len(group) == 1:
953
+ out.append(group[0])
954
+ continue
955
+ start = group[0].start_beats
956
+ end = max(region.end_beats for region in group)
957
+ notes = _without_stacked_notes([
958
+ replace(note, start_beats=note.start_beats - start) for note in _unroll_regions(group)
959
+ ])
960
+ out.append(LogicMidiRegion(name=group[0].name, start_beats=start, length_beats=end - start, notes=notes))
961
+ merged_names.append(" + ".join(region.name for region in group))
962
+ return out, merged_names
963
+
964
+
965
+ # Adjacent regions can overlap by a fraction of a tick, because positions are
966
+ # stored in ticks and lengths in samples. Overlaps this short are trimmed
967
+ # without a report line.
968
+ _OVERLAP_REPORT_BEATS = 0.01
969
+
970
+
971
+ def _cut_overlapping_audio(refs: list[AudioFileRef], *, tempo: float, warnings: list[str]) -> list[AudioFileRef]:
972
+ """Cut audio regions that overlap on one track so no two clips overlap.
973
+
974
+ A Logic audio track plays one region at a time, and where two overlap the
975
+ one that starts later plays. A Live track holds one clip at a time, so the
976
+ earlier region is cut where the later one starts. If the earlier region
977
+ runs past the end of the later one, that remainder is kept as its own
978
+ clip: whether Logic plays it has not been checked against a real project,
979
+ and a clip that can be deleted is easier to deal with than missing audio.
980
+ Every cut is reported.
981
+ """
982
+ spans: list[tuple[float, float, float]] = []
983
+ by_track: dict[str, list[int]] = {}
984
+ for index, ref in enumerate(refs):
985
+ samples_per_beat = 60.0 / tempo * _get_audio_sample_rate(ref.file_path)
986
+ length = ref.content_duration_samples
987
+ if length is None:
988
+ try:
989
+ length = max(0, read_audio_info(ref.file_path).frame_count - ref.content_offset_samples)
990
+ except (OSError, ValueError):
991
+ length = 0
992
+ spans.append((ref.start_beats, ref.start_beats + length / samples_per_beat, samples_per_beat))
993
+ by_track.setdefault(ref.track_name, []).append(index)
994
+
995
+ pieces_by_index: dict[int, list[tuple[float, float]]] = {}
996
+ cut: list[str] = []
997
+ for indices in by_track.values():
998
+ ordered = sorted(indices, key=lambda index: (spans[index][0], index))
999
+ for position, index in enumerate(ordered):
1000
+ start, end, _ = spans[index]
1001
+ pieces = [(start, end)]
1002
+ first_later: int | None = None
1003
+ for later in ordered[position + 1:]:
1004
+ later_start, later_end, _ = spans[later]
1005
+ if later_end <= later_start or later_start >= end - 1e-9:
1006
+ continue
1007
+ first_later = later if first_later is None else first_later
1008
+ remaining: list[tuple[float, float]] = []
1009
+ for piece_start, piece_end in pieces:
1010
+ if later_end <= piece_start or later_start >= piece_end:
1011
+ remaining.append((piece_start, piece_end))
1012
+ continue
1013
+ if later_start > piece_start:
1014
+ remaining.append((piece_start, later_start))
1015
+ if later_end < piece_end:
1016
+ remaining.append((later_end, piece_end))
1017
+ pieces = remaining
1018
+ if pieces == [(start, end)]:
1019
+ continue
1020
+ pieces_by_index[index] = pieces
1021
+ if (end - start) - sum(piece_end - piece_start for piece_start, piece_end in pieces) >= _OVERLAP_REPORT_BEATS:
1022
+ ref, later_ref = refs[index], refs[first_later]
1023
+ cut.append(
1024
+ f"{ref.track_name}: {ref.clip_name or ref.filename} under {later_ref.clip_name or later_ref.filename}"
1025
+ )
1026
+
1027
+ if not pieces_by_index:
1028
+ return refs
1029
+ out: list[AudioFileRef] = []
1030
+ for index, ref in enumerate(refs):
1031
+ if index not in pieces_by_index:
1032
+ out.append(ref)
1033
+ continue
1034
+ start, _, samples_per_beat = spans[index]
1035
+ for piece_start, piece_end in pieces_by_index[index]:
1036
+ if piece_end - piece_start < _OVERLAP_REPORT_BEATS:
1037
+ continue
1038
+ out.append(replace(
1039
+ ref,
1040
+ start_position_samples=max(0, round(piece_start * samples_per_beat)),
1041
+ content_offset_samples=ref.content_offset_samples + round((piece_start - start) * samples_per_beat),
1042
+ content_duration_samples=int((piece_end - piece_start) * samples_per_beat + 1e-6),
1043
+ start_beats=piece_start,
1044
+ ))
1045
+ if cut:
1046
+ examples = ", ".join(cut[:5]) + (", ..." if len(cut) > 5 else "")
1047
+ warnings.append(
1048
+ f"{len(cut)} audio region(s) overlap a later region on the same track. A track plays one clip at a "
1049
+ f"time, so the later region was kept whole and the earlier one cut around it; compare these with "
1050
+ f"Logic: {examples}"
1051
+ )
1052
+ return out
1053
+
1054
+
915
1055
  def _unique_track_names(arrangement: LogicArrangement, warnings: list[str]) -> dict[int, str]:
916
1056
  """Display name per Logic track id, numbering tracks that share a name.
917
1057
 
@@ -971,6 +1111,7 @@ def _arrangement_midi_tracks(
971
1111
 
972
1112
  tracks: list[LogicMidiTrack] = []
973
1113
  muted: list[str] = []
1114
+ merged: list[str] = []
974
1115
  for track_id, placements in sorted(
975
1116
  by_track.items(), key=lambda item: (min(p.lane for p in item[1]), min(p.tick for p in item[1]))
976
1117
  ):
@@ -1014,7 +1155,15 @@ def _arrangement_midi_tracks(
1014
1155
  loop_span_beats=placement.loop_span / PPQ if placement.looped else None,
1015
1156
  ))
1016
1157
  if regions:
1158
+ regions, merged_names = _merge_overlapping_midi(regions)
1159
+ merged.extend(f"{track_name}: {names}" for names in merged_names)
1017
1160
  tracks.append(LogicMidiTrack(name=track_name, notes=_unroll_regions(regions), regions=regions))
1161
+ if merged:
1162
+ examples = ", ".join(merged[:5]) + (", ..." if len(merged) > 5 else "")
1163
+ warnings.append(
1164
+ f"{len(merged)} group(s) of overlapping MIDI regions were each written as one clip holding all "
1165
+ f"their notes, because a Live track plays one clip at a time: {examples}"
1166
+ )
1018
1167
  if muted:
1019
1168
  examples = ", ".join(muted[:5]) + (", ..." if len(muted) > 5 else "")
1020
1169
  warnings.append(f"Skipped {len(muted)} muted MIDI region(s) as Logic would not play them: {examples}")
@@ -1107,6 +1256,7 @@ def _arrangement_audio_refs(
1107
1256
  start_beats=pass_start,
1108
1257
  ))
1109
1258
  lanes.setdefault(track_name, placement.lane)
1259
+ refs = _cut_overlapping_audio(refs, tempo=tempo, warnings=warnings)
1110
1260
  if looped:
1111
1261
  examples = ", ".join(looped[:5]) + (", ..." if len(looped) > 5 else "")
1112
1262
  warnings.append(
@@ -35,6 +35,11 @@ and later) and a u32 payload size. The objects read here:
35
35
  first id is the AuFl id; the second id tells apart regions cut from one
36
36
  file.
37
37
 
38
+ The file opens with a ``Song`` object (its payload holds the header fields
39
+ read below). A project saved with undo history has further ``Song`` objects
40
+ after the current state, one per history step, each followed by old copies of
41
+ the objects that step changed; decoding stops at the second ``Song``.
42
+
38
43
  Ticks are 960 per quarter note and three tick frames coexist: note ticks are
39
44
  region-relative with the content origin at 38400; marker ticks count from
40
45
  38400 = bar 1; region placements count from 34560 = the project start, whose
@@ -175,6 +180,7 @@ class LogicArrangement:
175
180
  audio_files: dict[int, str] = field(default_factory=dict)
176
181
  audio_regions: dict[tuple[int, int], LogicAudioRegion] = field(default_factory=dict)
177
182
  warnings: list[str] = field(default_factory=list)
183
+ history_steps: int = 0 # undo-history snapshots found after the current state and ignored
178
184
 
179
185
  @property
180
186
  def regions(self) -> list[LogicPlacement]:
@@ -435,6 +441,16 @@ def decode_project_data(data: bytes, *, beats_per_bar: float = 4.0) -> LogicArra
435
441
  objects = scan_objects(data)
436
442
  if not objects:
437
443
  return arrangement
444
+ # A project saved with undo history carries one extra Song object per
445
+ # history step, each followed by the old copies of whatever that step
446
+ # changed (arrangement sequences, regions, tracks, audio regions). Only
447
+ # the objects before the second Song are the project's current state;
448
+ # reading the rest would stack every past arrangement on top of it and let
449
+ # stale names and trims overwrite current ones.
450
+ song_offsets = [obj.offset for obj in objects if obj.tag == "Song"]
451
+ if len(song_offsets) > 1:
452
+ arrangement.history_steps = len(song_offsets) - 1
453
+ objects = [obj for obj in objects if obj.offset < song_offsets[1]]
438
454
  arrangement.format_version = max(set(obj.version for obj in objects), key=[obj.version for obj in objects].count)
439
455
  arrangement.sequences = _sequences(data, objects)
440
456
  arrangement.track_names = _track_names(data, objects)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logic2ableton
3
- Version: 2.3.2
3
+ Version: 2.3.4
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
@@ -125,6 +125,8 @@ The product goal is **speed with evidence**: every run emits a report showing ex
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
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
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
128
+ - A Live track plays one clip at a time. Where audio regions overlap on one Logic track, the later region is kept whole and the earlier one is cut around it; overlapping MIDI regions become one clip holding all their notes. The report lists each case. No real project with overlapping regions has been checked yet, so compare those spots with Logic
129
+ - Region fades, crossfades and region gain are not transferred, so edit points are plain cuts in Live. Take folders are untested; flatten them in Logic first
128
130
  - Logic's tempo track is not decoded (only the project tempo is); supply tempo changes with `--timeline`
129
131
  - `--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
130
132
  - Automation is not recreated
@@ -178,3 +178,158 @@ def test_generation_failure_is_written_into_the_saved_report(tmp_path, capsys):
178
178
  saved = Path(event["report_path"]).read_text(encoding="utf-8")
179
179
  assert "CONVERSION FAILED" in saved and "No AudioTrack found" in saved
180
180
  assert "TRACKS TRANSFERRED" in saved # the analysis report is kept
181
+
182
+
183
+ def test_undo_history_snapshots_are_not_read_as_the_current_arrangement(tmp_path):
184
+ """Logic keeps undo history in the save: extra Song objects followed by old copies of
185
+ whatever each step changed. Only the state before the second Song is current."""
186
+ old_state = dict(
187
+ tracks={1: "Gtr old name"},
188
+ sequences=[{"id": 44, "name": "Riff", "length": 3840, "notes": [(0, 40, 90, 240), (960, 41, 90, 240)]}],
189
+ midi_regions=[{"bar": 9, "track": 2, "sequence": 44, "lane": 2}],
190
+ audio_files={10: "Guitar.wav"},
191
+ audio_regions=[{"file": 10, "index": 0, "name": "Old trim", "offset": 0, "length": RATE}],
192
+ audio_placements=[{"bar": 5, "track": 1, "file": 10, "lane": 1}],
193
+ markers=[(17, 12, "Old marker")],
194
+ )
195
+ data = build_logic_arrangement_project_data(
196
+ tracks={1: "Guitar", 2: "Keys"},
197
+ sequences=[{"id": 44, "name": "Riff", "length": 3840, "notes": [(0, 60, 100, 240)]}],
198
+ midi_regions=[{"bar": 2, "track": 2, "sequence": 44, "lane": 2}],
199
+ audio_files={10: "Guitar.wav"},
200
+ audio_regions=[{"file": 10, "index": 0, "name": "Middle", "offset": 2 * RATE, "length": 2 * RATE}],
201
+ audio_placements=[{"bar": 3, "track": 1, "file": 10, "lane": 1}],
202
+ markers=[(5, 12, "Verse")],
203
+ history=[old_state, old_state, old_state],
204
+ )
205
+ arrangement = decode_project_data(data)
206
+ assert arrangement.history_steps == 3
207
+ assert len(arrangement.regions) == 2
208
+ assert arrangement.track_names == {1: "Guitar", 2: "Keys"}
209
+
210
+ logicx = build_synthetic_logicx(tmp_path / "source", project_data=data)
211
+ _three_level_wav(logicx / "Media" / "Audio Files" / "Guitar.wav")
212
+ project = parse_logic_project(logicx)
213
+
214
+ assert [(c.track_name, c.clip_name, c.start_beats, c.content_offset_samples) for c in project.audio_files] == [
215
+ ("Guitar", "Middle", 8.0, 2 * RATE),
216
+ ]
217
+ assert [(t.name, len(t.regions), t.regions[0].start_beats) for t in project.midi_tracks] == [("Keys", 1, 4.0)]
218
+ assert [(n.pitch, n.start_beats) for n in project.midi_tracks[0].notes] == [(60, 4.0)]
219
+ assert [(m.beat, m.name) for m in project.timeline.markers] == [(16.0, "Verse")]
220
+
221
+
222
+ def _overlap_project(tmp_path: Path, *, audio_regions, audio_placements, sequences=(), midi_regions=()) -> Path:
223
+ """A 120 BPM project (two beats per second) over a 12-second Guitar.wav."""
224
+ data = build_logic_arrangement_project_data(
225
+ tracks={1: "Guitar", 2: "Keys"},
226
+ sequences=list(sequences),
227
+ midi_regions=list(midi_regions),
228
+ audio_files={10: "Guitar.wav"},
229
+ audio_regions=audio_regions,
230
+ audio_placements=audio_placements,
231
+ )
232
+ logicx = build_synthetic_logicx(tmp_path / "source", project_data=data)
233
+ wav = logicx / "Media" / "Audio Files" / "Guitar.wav"
234
+ wav.parent.mkdir(parents=True, exist_ok=True)
235
+ with wave.open(str(wav), "wb") as handle:
236
+ handle.setparams((1, 2, RATE, 0, "NONE", "not compressed"))
237
+ handle.writeframes(struct.pack("<h", 1000) * RATE * 12)
238
+ return logicx
239
+
240
+
241
+ def test_audio_region_on_top_of_a_longer_one_cuts_it_instead_of_overlapping(tmp_path):
242
+ # "Long" covers beats 0-16; "Patch" is dropped on top of it at beats 4-8.
243
+ logicx = _overlap_project(
244
+ tmp_path,
245
+ audio_regions=[
246
+ {"file": 10, "index": 0, "name": "Long", "offset": 0, "length": 8 * RATE},
247
+ {"file": 10, "index": 1, "name": "Patch", "offset": 8 * RATE, "length": 2 * RATE},
248
+ ],
249
+ audio_placements=[
250
+ {"bar": 1, "track": 1, "file": 10, "lane": 1},
251
+ {"bar": 2, "track": 1, "file": 10, "lane": 1, "index": 1},
252
+ ],
253
+ )
254
+ project = parse_logic_project(logicx)
255
+
256
+ clips = sorted((c.start_beats, c.clip_name, c.content_offset_samples, c.content_duration_samples) for c in project.audio_files)
257
+ assert clips == [
258
+ (0.0, "Long", 0, 2 * RATE),
259
+ (4.0, "Patch", 8 * RATE, 2 * RATE),
260
+ (8.0, "Long", 4 * RATE, 4 * RATE),
261
+ ]
262
+ assert any("Guitar: Long under Patch" in warning for warning in project.compatibility_warnings)
263
+
264
+ als = generate_als(project, tmp_path / "out", copy_audio=False, template_path=_BUNDLED_TEMPLATE)
265
+ rendered = sorted((c.start_beats, c.end_beats) for c in parse_ableton_project(als).clips)
266
+ assert rendered == [(0.0, 4.0), (4.0, 8.0), (8.0, 16.0)]
267
+
268
+
269
+ def test_audio_region_running_into_the_next_one_ends_where_it_starts(tmp_path):
270
+ logicx = _overlap_project(
271
+ tmp_path,
272
+ audio_regions=[
273
+ {"file": 10, "index": 0, "name": "First", "offset": 0, "length": 4 * RATE},
274
+ {"file": 10, "index": 1, "name": "Second", "offset": 6 * RATE, "length": 4 * RATE},
275
+ ],
276
+ audio_placements=[
277
+ {"bar": 1, "track": 1, "file": 10, "lane": 1},
278
+ {"bar": 2, "track": 1, "file": 10, "lane": 1, "index": 1},
279
+ ],
280
+ )
281
+ project = parse_logic_project(logicx)
282
+
283
+ clips = [(c.start_beats, c.clip_name, c.content_offset_samples, c.content_duration_samples) for c in project.audio_files]
284
+ assert clips == [(0.0, "First", 0, 2 * RATE), (4.0, "Second", 6 * RATE, 4 * RATE)]
285
+
286
+
287
+ def test_regions_that_only_touch_are_left_alone(tmp_path):
288
+ logicx = _overlap_project(
289
+ tmp_path,
290
+ audio_regions=[
291
+ {"file": 10, "index": 0, "name": "First", "offset": 0, "length": 2 * RATE},
292
+ {"file": 10, "index": 1, "name": "Second", "offset": 2 * RATE, "length": 2 * RATE},
293
+ ],
294
+ audio_placements=[
295
+ {"bar": 1, "track": 1, "file": 10, "lane": 1},
296
+ {"bar": 2, "track": 1, "file": 10, "lane": 1, "index": 1},
297
+ # the same slice again on another track at the same time is not an overlap
298
+ {"bar": 1, "track": 2, "file": 10, "lane": 2},
299
+ ],
300
+ )
301
+ project = parse_logic_project(logicx)
302
+
303
+ clips = sorted((c.track_name, c.start_beats, c.content_offset_samples, c.content_duration_samples) for c in project.audio_files)
304
+ assert clips == [("Guitar", 0.0, 0, 2 * RATE), ("Guitar", 4.0, 2 * RATE, 2 * RATE), ("Keys", 0.0, 0, 2 * RATE)]
305
+ assert not any("overlap" in warning for warning in project.compatibility_warnings)
306
+
307
+
308
+ def test_overlapping_midi_regions_become_one_clip_with_all_their_notes(tmp_path):
309
+ # A four-bar region with a note on every beat, and a one-bar region laid over its second bar.
310
+ logicx = _overlap_project(
311
+ tmp_path,
312
+ audio_regions=[],
313
+ audio_placements=[],
314
+ sequences=[
315
+ {"id": 44, "name": "Long", "length": 4 * 3840, "notes": [(960 * beat, 60, 100, 240) for beat in range(16)]},
316
+ {"id": 45, "name": "Short", "length": 3840, "notes": [(0, 72, 90, 240), (0, 60, 127, 240)]},
317
+ ],
318
+ midi_regions=[
319
+ {"bar": 1, "track": 2, "sequence": 44, "lane": 2},
320
+ {"bar": 2, "track": 2, "sequence": 45, "lane": 2},
321
+ ],
322
+ )
323
+ project = parse_logic_project(logicx)
324
+
325
+ track = project.midi_tracks[0]
326
+ assert [(r.start_beats, r.end_beats, r.is_looping) for r in track.regions] == [(0.0, 16.0, False)]
327
+ # 16 notes from the long region, one new pitch from the short one; the doubled note at beat 4 is kept once
328
+ assert len(track.notes) == 17
329
+ assert [(n.pitch, n.velocity) for n in track.notes if n.start_beats == 4.0] == [(60, 127), (72, 90)]
330
+ assert any("Keys: Long + Short" in warning for warning in project.compatibility_warnings)
331
+
332
+ als = generate_als(project, tmp_path / "out", copy_audio=False, template_path=_BUNDLED_TEMPLATE)
333
+ root = ET.fromstring(gzip.decompress(als.read_bytes()))
334
+ clips = root.findall(".//Tracks/MidiTrack//ArrangerAutomation/Events/MidiClip")
335
+ assert [(c.find("CurrentStart").get("Value"), c.find("CurrentEnd").get("Value")) for c in clips] == [("0", "16")]
@@ -1 +0,0 @@
1
- __version__ = "2.3.2"
File without changes
File without changes