- MCP Server with audio fallback, sample management - Song generator with bus routing - Reference listener and audio resampler - Vector-based sample search - Master chain with limiter and calibration - Fix: Audio fallback now works without M4L - Fix: Full song detection in sample loader Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
178 lines
6.1 KiB
Python
178 lines
6.1 KiB
Python
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()
|