logic2ableton 2.3.0__tar.gz → 2.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/PKG-INFO +17 -1
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/README.md +16 -0
- logic2ableton-2.3.2/logic2ableton/__init__.py +1 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/ableton_generator.py +29 -23
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/cli.py +34 -12
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/logic_parser.py +116 -29
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/logic_project_data.py +1 -1
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/protools_import.py +94 -33
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/protools_parser.py +7 -3
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/protools_transfer.py +25 -9
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton.egg-info/PKG-INFO +17 -1
- {logic2ableton-2.3.0 → 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.0 → logic2ableton-2.3.2}/tests/test_cli.py +19 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_cli_protools.py +126 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_protools_parser.py +68 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_review_regressions.py +2 -1
- logic2ableton-2.3.0/logic2ableton/__init__.py +0 -1
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/LICENSE +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/__main__.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/ableton_metadata.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/ableton_parser.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/audio.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/data/DefaultLiveSet.als +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/logic_transfer.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/models.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/paths.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/plugin_database.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/plugin_matcher.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/report.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/smf.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/timeline.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton/vst3_scanner.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton.egg-info/dependency_links.txt +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton.egg-info/entry_points.txt +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/logic2ableton.egg-info/top_level.txt +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/pyproject.toml +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/setup.cfg +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_ableton_generator.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_ableton_parser.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_cli_reverse.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_forward_midi.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_logic_parser.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_logic_project_data.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_logic_transfer.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_midi_transfer.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_models.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_native_midi.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_plugin_database.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_plugin_matcher.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_protools_transfer.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_report.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_timeline.py +0 -0
- {logic2ableton-2.3.0 → logic2ableton-2.3.2}/tests/test_vst3_scanner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: logic2ableton
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.2
|
|
4
4
|
Summary: Session transfer between Logic Pro, Ableton Live, and Pro Tools - six conversion lanes with evidence-first reports
|
|
5
5
|
Author: evilander
|
|
6
6
|
License-Expression: MIT
|
|
@@ -123,6 +123,8 @@ The product goal is **speed with evidence**: every run emits a report showing ex
|
|
|
123
123
|
- MIDI notes transfer as native MIDI tracks (and `.mid` exports), but the software instruments, their settings, and MIDI effects are not recreated — reload instruments in Ableton
|
|
124
124
|
- Region placement, loops, track names and markers come from the project's own arrangement data. That decoding was worked out on Logic 10.6 and Logic 11 saves; the report says "Region positions: read from the Logic arrangement" when it applies. Saves it cannot read fall back to audio timestamps, `MIDI 1`, `MIDI 2`, ... track names, and a warning
|
|
125
125
|
- Regions that start before bar 1 are moved to bar 1 (audio is trimmed by the same amount) because a Live arrangement cannot start earlier
|
|
126
|
+
- Looped audio regions are written as repeated clips, spaced at the project tempo. If the tempo changes under a looped audio region, check the repeats; the report lists every region it unrolled
|
|
127
|
+
- Logic tracks that share a name are kept apart by numbering the later ones (`Guitar`, `Guitar (2)`), and the report says which were renamed
|
|
126
128
|
- Logic's tempo track is not decoded (only the project tempo is); supply tempo changes with `--timeline`
|
|
127
129
|
- `--smpte-start` only matters for the timestamp fallback; it must then match the project's own synchronization setting (default `01:00:00:00`), and the report lists any files placed at bar 1 because their timestamp precedes it
|
|
128
130
|
- Automation is not recreated
|
|
@@ -314,6 +316,20 @@ logic2ableton "/path/to/MySong.logicx" --output ./output --json-progress
|
|
|
314
316
|
ableton2logic "/path/to/MySet.als" --output ./output --json-progress
|
|
315
317
|
```
|
|
316
318
|
|
|
319
|
+
Progress events report audio counts (`tracks`, `clips`, `audio_files`) separately
|
|
320
|
+
from `midi_tracks` and `midi_notes`. Previews include recovered MIDI content.
|
|
321
|
+
For Ableton output, the completion event's `midi_tracks` counts native tracks in
|
|
322
|
+
the set; `midi_files` separately counts exported `.mid` sidecars.
|
|
323
|
+
|
|
324
|
+
A failed run ends with an `error` event whose `failure_stage` names the step that
|
|
325
|
+
failed (for example `timeline`, `parsing` or `generating`) and whose `error`
|
|
326
|
+
holds the reason. The saved report keeps any analysis already done and ends
|
|
327
|
+
with the same stage and reason.
|
|
328
|
+
|
|
329
|
+
Pro Tools previews check referenced audio on disk. The report lists found,
|
|
330
|
+
missing, and skipped sources, and `compatibility_warnings` names missing media
|
|
331
|
+
before conversion begins. These notes also appear in the desktop preview.
|
|
332
|
+
|
|
317
333
|
## CLI Options
|
|
318
334
|
|
|
319
335
|
Every lane's input argument accepts one or more paths. Pass several to convert
|
|
@@ -99,6 +99,8 @@ The product goal is **speed with evidence**: every run emits a report showing ex
|
|
|
99
99
|
- MIDI notes transfer as native MIDI tracks (and `.mid` exports), but the software instruments, their settings, and MIDI effects are not recreated — reload instruments in Ableton
|
|
100
100
|
- Region placement, loops, track names and markers come from the project's own arrangement data. That decoding was worked out on Logic 10.6 and Logic 11 saves; the report says "Region positions: read from the Logic arrangement" when it applies. Saves it cannot read fall back to audio timestamps, `MIDI 1`, `MIDI 2`, ... track names, and a warning
|
|
101
101
|
- Regions that start before bar 1 are moved to bar 1 (audio is trimmed by the same amount) because a Live arrangement cannot start earlier
|
|
102
|
+
- Looped audio regions are written as repeated clips, spaced at the project tempo. If the tempo changes under a looped audio region, check the repeats; the report lists every region it unrolled
|
|
103
|
+
- Logic tracks that share a name are kept apart by numbering the later ones (`Guitar`, `Guitar (2)`), and the report says which were renamed
|
|
102
104
|
- Logic's tempo track is not decoded (only the project tempo is); supply tempo changes with `--timeline`
|
|
103
105
|
- `--smpte-start` only matters for the timestamp fallback; it must then match the project's own synchronization setting (default `01:00:00:00`), and the report lists any files placed at bar 1 because their timestamp precedes it
|
|
104
106
|
- Automation is not recreated
|
|
@@ -290,6 +292,20 @@ logic2ableton "/path/to/MySong.logicx" --output ./output --json-progress
|
|
|
290
292
|
ableton2logic "/path/to/MySet.als" --output ./output --json-progress
|
|
291
293
|
```
|
|
292
294
|
|
|
295
|
+
Progress events report audio counts (`tracks`, `clips`, `audio_files`) separately
|
|
296
|
+
from `midi_tracks` and `midi_notes`. Previews include recovered MIDI content.
|
|
297
|
+
For Ableton output, the completion event's `midi_tracks` counts native tracks in
|
|
298
|
+
the set; `midi_files` separately counts exported `.mid` sidecars.
|
|
299
|
+
|
|
300
|
+
A failed run ends with an `error` event whose `failure_stage` names the step that
|
|
301
|
+
failed (for example `timeline`, `parsing` or `generating`) and whose `error`
|
|
302
|
+
holds the reason. The saved report keeps any analysis already done and ends
|
|
303
|
+
with the same stage and reason.
|
|
304
|
+
|
|
305
|
+
Pro Tools previews check referenced audio on disk. The report lists found,
|
|
306
|
+
missing, and skipped sources, and `compatibility_warnings` names missing media
|
|
307
|
+
before conversion begins. These notes also appear in the desktop preview.
|
|
308
|
+
|
|
293
309
|
## CLI Options
|
|
294
310
|
|
|
295
311
|
Every lane's input argument accepts one or more paths. Pass several to convert
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.3.2"
|
|
@@ -314,33 +314,39 @@ def _make_audio_clip_xml(
|
|
|
314
314
|
_val(ts_remote, "Denominator", str(time_sig_denominator))
|
|
315
315
|
_val(ts_remote, "Time", "0")
|
|
316
316
|
|
|
317
|
-
# WarpMarkers
|
|
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")
|
|
@@ -25,6 +25,7 @@ from logic2ableton.protools_import import (
|
|
|
25
25
|
build_protools_import_report,
|
|
26
26
|
protools_to_ableton_project,
|
|
27
27
|
protools_to_logic_project,
|
|
28
|
+
resolve_protools_media,
|
|
28
29
|
)
|
|
29
30
|
from logic2ableton.protools_parser import parse_protools_session
|
|
30
31
|
from logic2ableton.protools_transfer import (
|
|
@@ -277,12 +278,21 @@ def _emit_failure(
|
|
|
277
278
|
report_path = _report_path(
|
|
278
279
|
output_dir, input_path, project_name=project_name, suffix=report_suffix, unique=unique
|
|
279
280
|
)
|
|
280
|
-
|
|
281
|
+
if report:
|
|
282
|
+
# A failure after analysis keeps the analysis report, but the saved
|
|
283
|
+
# report must still say what failed and why.
|
|
284
|
+
report_text = (
|
|
285
|
+
f"{report.rstrip()}\n\nCONVERSION FAILED\n Stage: {stage}\n Error: {error}\n"
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
report_text = _build_failure_report(mode, input_path, stage, error)
|
|
281
289
|
_, report_note = _persist_report_with_note(report_path, report_text)
|
|
282
290
|
message = f"Failed during {stage}: {error}.{report_note}"
|
|
283
291
|
payload: dict[str, object] = {
|
|
284
292
|
"direction": mode,
|
|
285
293
|
"input": str(input_path),
|
|
294
|
+
"failure_stage": stage,
|
|
295
|
+
"error": error,
|
|
286
296
|
"report": report_text,
|
|
287
297
|
"report_path": str(report_path),
|
|
288
298
|
"artifact_path": str(report_path),
|
|
@@ -671,6 +681,7 @@ def _run_forward(args: argparse.Namespace) -> int:
|
|
|
671
681
|
compatibility_warnings=project.compatibility_warnings,
|
|
672
682
|
)
|
|
673
683
|
if jp:
|
|
684
|
+
midi_track_count = sum(1 for track in project.midi_tracks if track.note_count > 0)
|
|
674
685
|
_emit(
|
|
675
686
|
"complete",
|
|
676
687
|
1.0,
|
|
@@ -683,6 +694,8 @@ def _run_forward(args: argparse.Namespace) -> int:
|
|
|
683
694
|
tracks=len(project.track_names),
|
|
684
695
|
audio_files=len(project.audio_files),
|
|
685
696
|
plugins=len(project.plugins),
|
|
697
|
+
midi_tracks=midi_track_count,
|
|
698
|
+
midi_notes=project.total_midi_notes,
|
|
686
699
|
compatibility_warnings=project.compatibility_warnings,
|
|
687
700
|
)
|
|
688
701
|
else:
|
|
@@ -739,7 +752,8 @@ def _run_forward(args: argparse.Namespace) -> int:
|
|
|
739
752
|
tracks=len(project.track_names),
|
|
740
753
|
clips=clip_count,
|
|
741
754
|
audio_files=audio_count,
|
|
742
|
-
midi_tracks=
|
|
755
|
+
midi_tracks=sum(1 for track in project.midi_tracks if track.note_count > 0),
|
|
756
|
+
midi_files=midi_files,
|
|
743
757
|
midi_notes=project.total_midi_notes,
|
|
744
758
|
compatibility_warnings=project.compatibility_warnings,
|
|
745
759
|
**({"warning": warning} if warning else {}),
|
|
@@ -1016,7 +1030,16 @@ def _run_protools_import(args: argparse.Namespace, mode: str) -> int:
|
|
|
1016
1030
|
)
|
|
1017
1031
|
|
|
1018
1032
|
try:
|
|
1019
|
-
|
|
1033
|
+
# Share resolved media and counts between preview and conversion.
|
|
1034
|
+
media_preflight = resolve_protools_media(session)
|
|
1035
|
+
project = (
|
|
1036
|
+
protools_to_logic_project(session, tempo=args.tempo, preflight=media_preflight)
|
|
1037
|
+
if to_ableton else
|
|
1038
|
+
protools_to_ableton_project(session, tempo=args.tempo, preflight=media_preflight)
|
|
1039
|
+
)
|
|
1040
|
+
report = build_protools_import_report(
|
|
1041
|
+
session, destination=destination, tempo=tempo, preflight=media_preflight,
|
|
1042
|
+
)
|
|
1020
1043
|
except Exception as exc:
|
|
1021
1044
|
return _emit_failure(
|
|
1022
1045
|
mode=mode,
|
|
@@ -1067,12 +1090,12 @@ def _run_protools_import(args: argparse.Namespace, mode: str) -> int:
|
|
|
1067
1090
|
artifact_path=str(report_path),
|
|
1068
1091
|
report=report,
|
|
1069
1092
|
report_path=str(report_path),
|
|
1070
|
-
tracks=len(
|
|
1071
|
-
clips=
|
|
1072
|
-
audio_files=len(
|
|
1073
|
-
midi_tracks=
|
|
1074
|
-
midi_notes=
|
|
1075
|
-
compatibility_warnings=
|
|
1093
|
+
tracks=len(project.track_names) if to_ableton else len(project.audio_tracks),
|
|
1094
|
+
clips=len(project.audio_files) if to_ableton else len(project.clips),
|
|
1095
|
+
audio_files=len(media_preflight.referenced_files),
|
|
1096
|
+
midi_tracks=sum(1 for track in project.midi_tracks if track.note_count > 0),
|
|
1097
|
+
midi_notes=project.total_midi_notes,
|
|
1098
|
+
compatibility_warnings=project.compatibility_warnings,
|
|
1076
1099
|
)
|
|
1077
1100
|
else:
|
|
1078
1101
|
print(f"\nReport: {report_path}")
|
|
@@ -1084,7 +1107,6 @@ def _run_protools_import(args: argparse.Namespace, mode: str) -> int:
|
|
|
1084
1107
|
print(f"\nGenerating {destination} output in {output_dir}...")
|
|
1085
1108
|
|
|
1086
1109
|
if to_ableton:
|
|
1087
|
-
project = protools_to_logic_project(session, tempo=args.tempo)
|
|
1088
1110
|
template_path = Path(args.template) if args.template else None
|
|
1089
1111
|
try:
|
|
1090
1112
|
als_path = generate_als(
|
|
@@ -1127,7 +1149,8 @@ def _run_protools_import(args: argparse.Namespace, mode: str) -> int:
|
|
|
1127
1149
|
tracks=len(project.track_names),
|
|
1128
1150
|
clips=clip_count,
|
|
1129
1151
|
audio_files=audio_count,
|
|
1130
|
-
midi_tracks=
|
|
1152
|
+
midi_tracks=sum(1 for track in project.midi_tracks if track.note_count > 0),
|
|
1153
|
+
midi_files=midi_files,
|
|
1131
1154
|
midi_notes=project.total_midi_notes,
|
|
1132
1155
|
compatibility_warnings=project.compatibility_warnings,
|
|
1133
1156
|
**({"warning": warning} if warning else {}),
|
|
@@ -1144,7 +1167,6 @@ def _run_protools_import(args: argparse.Namespace, mode: str) -> int:
|
|
|
1144
1167
|
print("\nDone!")
|
|
1145
1168
|
return 0
|
|
1146
1169
|
|
|
1147
|
-
project = protools_to_ableton_project(session, tempo=args.tempo)
|
|
1148
1170
|
try:
|
|
1149
1171
|
transfer = generate_logic_transfer(project, output_dir, copy_audio=not args.no_copy)
|
|
1150
1172
|
except Exception as exc:
|
|
@@ -912,10 +912,55 @@ def _unroll_regions(regions: list[LogicMidiRegion]) -> list[LogicMidiNote]:
|
|
|
912
912
|
return notes
|
|
913
913
|
|
|
914
914
|
|
|
915
|
+
def _unique_track_names(arrangement: LogicArrangement, warnings: list[str]) -> dict[int, str]:
|
|
916
|
+
"""Display name per Logic track id, numbering tracks that share a name.
|
|
917
|
+
|
|
918
|
+
Outputs group clips by track name, so two Logic tracks both called
|
|
919
|
+
"Guitar" would otherwise merge into one track. The first track (by lane,
|
|
920
|
+
then position) keeps the plain name; later ones become "Guitar (2)", ...
|
|
921
|
+
"""
|
|
922
|
+
first_seen: dict[int, tuple[int, int]] = {}
|
|
923
|
+
for placement in arrangement.regions:
|
|
924
|
+
# Only tracks that produce output compete for a name.
|
|
925
|
+
if placement.muted:
|
|
926
|
+
continue
|
|
927
|
+
if placement.kind == "midi":
|
|
928
|
+
sequence = arrangement.sequences.get(placement.sequence_id)
|
|
929
|
+
if sequence is None or not sequence.notes:
|
|
930
|
+
continue
|
|
931
|
+
elif placement.audio_file_id not in arrangement.audio_files:
|
|
932
|
+
continue
|
|
933
|
+
key = (placement.lane, placement.tick)
|
|
934
|
+
if placement.track_id not in first_seen or key < first_seen[placement.track_id]:
|
|
935
|
+
first_seen[placement.track_id] = key
|
|
936
|
+
names: dict[int, str] = {}
|
|
937
|
+
used: set[str] = set()
|
|
938
|
+
renamed: list[str] = []
|
|
939
|
+
for track_id in sorted(first_seen, key=lambda tid: (first_seen[tid], tid)):
|
|
940
|
+
base = arrangement.track_name(track_id)
|
|
941
|
+
name = base
|
|
942
|
+
number = 2
|
|
943
|
+
while name.casefold() in used:
|
|
944
|
+
name = f"{base} ({number})"
|
|
945
|
+
number += 1
|
|
946
|
+
used.add(name.casefold())
|
|
947
|
+
names[track_id] = name
|
|
948
|
+
if name != base:
|
|
949
|
+
renamed.append(name)
|
|
950
|
+
if renamed:
|
|
951
|
+
examples = ", ".join(renamed[:5]) + (", ..." if len(renamed) > 5 else "")
|
|
952
|
+
warnings.append(
|
|
953
|
+
f"{len(renamed)} Logic track(s) share a name with another track and were numbered "
|
|
954
|
+
f"to keep them separate: {examples}"
|
|
955
|
+
)
|
|
956
|
+
return names
|
|
957
|
+
|
|
958
|
+
|
|
915
959
|
def _arrangement_midi_tracks(
|
|
916
960
|
arrangement: LogicArrangement,
|
|
917
961
|
*,
|
|
918
962
|
bar_beats: float,
|
|
963
|
+
track_names_by_id: dict[int, str],
|
|
919
964
|
warnings: list[str],
|
|
920
965
|
) -> list[LogicMidiTrack]:
|
|
921
966
|
"""One LogicMidiTrack per Logic track that has audible MIDI regions."""
|
|
@@ -929,7 +974,7 @@ def _arrangement_midi_tracks(
|
|
|
929
974
|
for track_id, placements in sorted(
|
|
930
975
|
by_track.items(), key=lambda item: (min(p.lane for p in item[1]), min(p.tick for p in item[1]))
|
|
931
976
|
):
|
|
932
|
-
track_name = arrangement.track_name(track_id)
|
|
977
|
+
track_name = track_names_by_id.get(track_id) or arrangement.track_name(track_id)
|
|
933
978
|
regions: list[LogicMidiRegion] = []
|
|
934
979
|
for placement in sorted(placements, key=lambda p: p.tick):
|
|
935
980
|
sequence = arrangement.sequences.get(placement.sequence_id)
|
|
@@ -982,14 +1027,16 @@ def _arrangement_audio_refs(
|
|
|
982
1027
|
*,
|
|
983
1028
|
tempo: float,
|
|
984
1029
|
bar_beats: float,
|
|
1030
|
+
track_names_by_id: dict[int, str],
|
|
985
1031
|
warnings: list[str],
|
|
986
1032
|
) -> tuple[list[AudioFileRef], list[str]]:
|
|
987
|
-
"""Audio clips as Logic placed them: one ref per region,
|
|
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
|