from __future__ import annotations import argparse import gzip import json from collections import Counter from pathlib import Path import xml.etree.ElementTree as ET def _node_name(node: ET.Element | None) -> str: if node is None: return "" for tag in ("EffectiveName", "UserName", "Name"): child = node.find(tag) if child is not None: value = child.attrib.get("Value", "") if value: return value return node.attrib.get("Value", "") def _device_name(device: ET.Element) -> str: if device.tag == "PluginDevice": info = device.find("PluginDesc/VstPluginInfo") if info is None: info = device.find("PluginDesc/AuPluginInfo") if info is not None: plug = info.find("PlugName") if plug is not None and plug.attrib.get("Value"): return plug.attrib["Value"] return device.tag def _session_clip_count(track: ET.Element) -> int: count = 0 for slot in track.findall("./DeviceChain/MainSequencer/ClipSlotList/ClipSlot"): if slot.find("Value/MidiClip") is not None or slot.find("Value/AudioClip") is not None: count += 1 return count def _arrangement_clip_count(track: ET.Element) -> int: return len(track.findall(".//MainSequencer//MidiClip")) + len( track.findall(".//MainSequencer//AudioClip") ) def _tempo_value(live_set: ET.Element) -> float | None: node = live_set.find(".//Tempo/Manual") if node is None: return None try: return float(node.attrib.get("Value", "0")) except ValueError: return None def _locator_summary(live_set: ET.Element) -> list[dict[str, float | str | None]]: locators: list[tuple[float, str]] = [] for locator in live_set.findall(".//Locators/Locators/Locator"): try: time = float(locator.find("Time").attrib.get("Value", "0")) except (AttributeError, ValueError): time = 0.0 name = _node_name(locator.find("Name")) locators.append((time, name)) locators.sort(key=lambda item: item[0]) summary: list[dict[str, float | str | None]] = [] for index, (time, name) in enumerate(locators): next_time = locators[index + 1][0] if index + 1 < len(locators) else None summary.append( { "time_beats": time, "name": name, "section_length_beats": None if next_time is None else next_time - time, } ) return summary def _arrangement_length_beats(root: ET.Element) -> float: max_end = 0.0 for clip in root.findall(".//MidiClip") + root.findall(".//AudioClip"): current_end = clip.find("CurrentEnd") start = clip.attrib.get("Time") if current_end is None or start is None: continue try: end = float(start) + float(current_end.attrib.get("Value", "0")) except ValueError: continue max_end = max(max_end, end) return max_end def analyze_set(als_path: Path) -> dict: with gzip.open(als_path, "rb") as handle: root = ET.parse(handle).getroot() live_set = root.find("LiveSet") if live_set is None: raise ValueError(f"Invalid ALS file: {als_path}") tracks = list(live_set.find("Tracks") or []) track_summaries = [] device_counter: Counter[str] = Counter() for track in tracks: devices = track.findall("./DeviceChain/DeviceChain/Devices/*") device_names = [_device_name(device) for device in devices] device_counter.update(device_names) track_summaries.append( { "type": track.tag, "name": _node_name(track.find("Name")), "group_id": track.find("TrackGroupId").attrib.get("Value", "") if track.find("TrackGroupId") is not None else "", "session_clip_count": _session_clip_count(track), "arrangement_clip_count": _arrangement_clip_count(track), "devices": device_names, } ) automation_events = 0 for automation in root.findall(".//ArrangerAutomation"): automation_events += len(automation.findall(".//FloatEvent")) automation_events += len(automation.findall(".//EnumEvent")) automation_events += len(automation.findall(".//BoolEvent")) return { "file": str(als_path), "tempo": _tempo_value(live_set), "track_type_counts": dict(Counter(track.tag for track in tracks)), "scene_count": len(live_set.findall("./SceneNames/Scene")), "locators": _locator_summary(live_set), "arrangement_length_beats": _arrangement_length_beats(root), "automation_event_count": automation_events, "top_devices": dict(device_counter.most_common(16)), "tracks": track_summaries, } def main() -> None: parser = argparse.ArgumentParser(description="Analyze Ableton .als templates.") parser.add_argument("path", nargs="?", default=".", help="Folder containing .als files") parser.add_argument("--json", action="store_true", help="Emit JSON") args = parser.parse_args() base = Path(args.path).resolve() results = [analyze_set(path) for path in sorted(base.rglob("*.als"))] if args.json: print(json.dumps(results, indent=2)) return for result in results: print(f"=== {Path(result['file']).name} ===") print(f"tempo: {result['tempo']}") print(f"tracks: {result['track_type_counts']}") print(f"scenes: {result['scene_count']}") print(f"arrangement_length_beats: {result['arrangement_length_beats']}") print(f"automation_event_count: {result['automation_event_count']}") print("locators:") for locator in result["locators"]: print( f" - {locator['time_beats']:>6} {locator['name']}" f" len={locator['section_length_beats']}" ) print("top_devices:") for name, count in result["top_devices"].items(): print(f" - {name}: {count}") print() if __name__ == "__main__": main()