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.
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/PKG-INFO +8 -1
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/README.md +7 -0
- logic2ableton-2.3.2/logic2ableton/__init__.py +1 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/ableton_generator.py +29 -23
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/cli.py +10 -1
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/logic_parser.py +116 -29
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/logic_project_data.py +1 -1
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/protools_parser.py +7 -3
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/protools_transfer.py +25 -9
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton.egg-info/PKG-INFO +8 -1
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton.egg-info/SOURCES.txt +1 -0
- logic2ableton-2.3.2/tests/test_arrangement_regressions.py +180 -0
- logic2ableton-2.3.1/logic2ableton/__init__.py +0 -1
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/LICENSE +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/__main__.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/ableton_metadata.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/ableton_parser.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/audio.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/data/DefaultLiveSet.als +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/logic_transfer.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/models.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/paths.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/plugin_database.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/plugin_matcher.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/protools_import.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/report.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/smf.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/timeline.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton/vst3_scanner.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton.egg-info/dependency_links.txt +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton.egg-info/entry_points.txt +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/logic2ableton.egg-info/top_level.txt +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/pyproject.toml +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/setup.cfg +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_ableton_generator.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_ableton_parser.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_cli.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_cli_protools.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_cli_reverse.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_forward_midi.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_logic_parser.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_logic_project_data.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_logic_transfer.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_midi_transfer.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_models.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_native_midi.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_plugin_database.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_plugin_matcher.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_protools_parser.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_protools_transfer.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_report.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_review_regressions.py +0 -0
- {logic2ableton-2.3.1 → logic2ableton-2.3.2}/tests/test_timeline.py +0 -0
- {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.
|
|
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
|
|
318
|
-
# breakpoint get an extra marker at
|
|
319
|
-
# tracks the song's actual tempo curve
|
|
320
|
-
# the whole file. With no timeline, this
|
|
321
|
-
# single-tempo formula (no map lookups) so output is
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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,
|
|
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}: {
|
|
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
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
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,
|
|
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) >=
|
|
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
|
-
|
|
386
|
-
|
|
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 >
|
|
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 =
|
|
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
|
|
122
|
+
"""Render one Logic region as a Pro Tools-ready timestamped WAV.
|
|
121
123
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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=
|
|
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
|
|
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.
|
|
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
|
|
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
|