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.
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/PKG-INFO +3 -1
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/README.md +2 -0
- logic2ableton-2.3.4/logic2ableton/__init__.py +1 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/logic_parser.py +150 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/logic_project_data.py +16 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton.egg-info/PKG-INFO +3 -1
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_arrangement_regressions.py +155 -0
- logic2ableton-2.3.2/logic2ableton/__init__.py +0 -1
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/LICENSE +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/__main__.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/ableton_generator.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/ableton_metadata.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/ableton_parser.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/audio.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/cli.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/data/DefaultLiveSet.als +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/logic_transfer.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/models.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/paths.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/plugin_database.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/plugin_matcher.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/protools_import.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/protools_parser.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/protools_transfer.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/report.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/smf.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/timeline.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton/vst3_scanner.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton.egg-info/SOURCES.txt +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton.egg-info/dependency_links.txt +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton.egg-info/entry_points.txt +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/logic2ableton.egg-info/top_level.txt +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/pyproject.toml +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/setup.cfg +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_ableton_generator.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_ableton_parser.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_cli.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_cli_protools.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_cli_reverse.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_forward_midi.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_logic_parser.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_logic_project_data.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_logic_transfer.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_midi_transfer.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_models.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_native_midi.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_plugin_database.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_plugin_matcher.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_protools_parser.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_protools_transfer.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_report.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_review_regressions.py +0 -0
- {logic2ableton-2.3.2 → logic2ableton-2.3.4}/tests/test_timeline.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|